fix(watcher): auto-sync symlinked directory changes across platforms (#770) (#2025)

* fix(watcher): auto-sync changes inside symlinked directories (#770)

The file watcher (Linux inotify strategy) did not follow directory
symlinks when building its watch set, so a project dir symlinked into
the workspace root was silently invisible to the watcher.

Changes:

1. watcher.ts: watchTree() now detects isSymbolicLink() entries,
   resolves real targets with realpathSync(), and recursively watches
   directory symlinks. Guards against cycles with a visitedRealDirs Set
   — tracking regular dirs too so a symlink→already-visited-path is
   also detected. Mirrors the indexer's walk() in extraction/index.ts.

2. utils.ts: Added allowSymlinkedDirs parameter to validatePathWithinRoot().
   When true, skips the realpath check that would reject watched symlinks
   whose targets live outside the lexical project root. All internal
   sync/index paths pass true here; user-facing paths (MCP layer) keep
   the existing strict validation.

3. extraction/index.ts: All internal sync/index paths now pass
   allowSymlinkedDirs: true so the watcher can read files from
   symlinked directories.

4. __tests__/watcher.test.ts: Added integration test for symlinked
   directory auto-sync, plus a symlink-cycle detection test.

Signed-off-by: dengzhongyuan <dengzhongyuan@uniontech.com>

* fix(watcher): auto-sync symlinked directory changes across platforms (#770)

Directory indexing followed symlinks that native watchers did not cover.
Discover targets with realpath cycle guards, preserve logical paths, and install bounded supplemental watches.
Reconcile watches after topology and scope changes, and preserve changes arriving during a full sync.
Retain strict content-serving containment and cover lifecycle, resource limits, and cleanup.

Co-authored-by: dengzhongyuan <dengzhongyuan@uniontech.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(watcher): auto-sync symlinked directory changes across platforms (#770)

Directory indexing followed symlinks that native watchers did not cover.
Discover targets with realpath cycle guards, preserve logical paths, and install bounded supplemental watches.
Reconcile watches after topology and scope changes, and preserve changes arriving during a full sync.
Retain strict content-serving containment and cover lifecycle, resource limits, and cleanup.

Co-authored-by: dengzhongyuan <dengzhongyuan@uniontech.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(watcher): auto-sync symlinked directory changes across platforms (#770)

Directory indexing followed symlinks that native watchers did not cover.
Discover targets with realpath cycle guards, preserve logical paths, and install bounded supplemental watches.
Reconcile watches after topology and scope changes, and preserve changes arriving during a full sync.
Retain strict content-serving containment and cover lifecycle, resource limits, and cleanup.

Co-authored-by: dengzhongyuan <dengzhongyuan@uniontech.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dengzhongyuan <dengzhongyuan@uniontech.com>
Co-authored-by: dengzhongyuan <dengzhongyuan@uniontech.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 13:25:29 +00:00
committed by GitHub
parent 61846105c4
commit c897daf85d
2 changed files with 319 additions and 89 deletions
+207 -12
View File
@@ -56,11 +56,22 @@ function waitFor(
describe('FileWatcher', () => {
let testDir: string;
const graphs = new Set<CodeGraph>();
const unitWatchers = new Set<FileWatcher>();
const initGraph = (...args: Parameters<typeof CodeGraph.initSync>) => {
const cg = CodeGraph.initSync(...args);
graphs.add(cg);
return cg;
};
// Inert by default — unit tests drive events via __emitWatchEventForTests
// and never depend on real OS watch delivery.
const newWatcher = (syncFn: SyncFn, opts: WatchOptions = {}) =>
new FileWatcher(testDir, syncFn, { inertForTests: true, ...opts });
const newWatcher = (syncFn: SyncFn, opts: WatchOptions = {}) => {
const watcher = new FileWatcher(testDir, syncFn, { inertForTests: true, ...opts });
unitWatchers.add(watcher);
return watcher;
};
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watcher-'));
@@ -70,7 +81,18 @@ describe('FileWatcher', () => {
fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
});
afterEach(() => {
afterEach(async () => {
// DB mutations become visible before async sync maintenance finishes.
// Stop new work and drain it BEFORE closing SQLite or removing the fixture;
// nested afterEach hooks run too late to protect this outer cleanup.
for (const watcher of unitWatchers) watcher.stop();
unitWatchers.clear();
for (const cg of graphs) {
cg.unwatch();
await waitFor(() => !cg.isIndexing(), 8000);
cg.close();
}
graphs.clear();
__setFsWatchForTests(null); // reset the injected fs.watch seam
vi.restoreAllMocks();
if (fs.existsSync(testDir)) {
@@ -482,7 +504,7 @@ describe('FileWatcher', () => {
fs.mkdirSync(deep, { recursive: true });
fs.writeFileSync(path.join(deep, 'inner.ts'), 'export const i = 2;');
const cg = CodeGraph.initSync(testDir);
const cg = initGraph(testDir);
await cg.indexAll();
const before = cg.getFiles().map((f) => f.path);
expect(before).toContain('docs/a/b/inner.ts');
@@ -507,7 +529,6 @@ describe('FileWatcher', () => {
expect(after.some((p) => p.startsWith('docs/'))).toBe(false);
watcher.stop();
cg.close();
});
it('should ignore .codegraph directory changes', async () => {
@@ -865,12 +886,8 @@ describe('FileWatcher', () => {
describe('CodeGraph integration', () => {
let cg: CodeGraph;
afterEach(() => {
if (cg) cg.close();
});
it('should watch and unwatch via CodeGraph API', async () => {
cg = CodeGraph.initSync(testDir, {
cg = initGraph(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
@@ -886,7 +903,7 @@ describe('FileWatcher', () => {
});
it('should stop watching on close', async () => {
cg = CodeGraph.initSync(testDir, {
cg = initGraph(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
@@ -903,7 +920,7 @@ describe('FileWatcher', () => {
it('should auto-sync when files change while watching (real fs.watch end-to-end)', async () => {
// The one test that exercises the genuine native watcher: a real file
// write must propagate through fs.watch → debounce → sync into the graph.
cg = CodeGraph.initSync(testDir, {
cg = initGraph(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
@@ -933,6 +950,161 @@ describe('FileWatcher', () => {
cg.unwatch();
});
});
describe('symlink directory watching (#770)', () => {
let cg: CodeGraph | undefined;
const externalDirs: string[] = [];
const watchers: FileWatcher[] = [];
const external = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-linked-'));
externalDirs.push(dir);
return dir;
};
// Junctions exercise Windows directory links without requiring symlink privileges.
const link = (target: string, name: string) =>
fs.symlinkSync(target, name, process.platform === 'win32' ? 'junction' : 'dir');
const has = (name: string) => cg!.getNodesByName(name).length > 0;
const start = async () => {
cg = initGraph(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
expect(cg.watch({ debounceMs: 100 })).toBe(true);
// FSEvents can coalesce writes made while a new stream is being registered.
await new Promise(resolve => setTimeout(resolve, 700));
};
afterEach(() => {
cg = undefined;
for (const watcher of watchers.splice(0)) watcher.stop();
for (const dir of externalDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it('auto-syncs edits, creates and deletes through an external directory link', async () => {
const dir = external();
fs.mkdirSync(path.join(dir, 'src'));
const file = path.join(dir, 'src', 'item.ts');
fs.writeFileSync(file, 'export function linkedBefore() {}');
link(dir, path.join(testDir, 'linked-project'));
await start();
expect(has('linkedBefore')).toBe(true);
fs.writeFileSync(file, 'export function linkedAfter() {}');
await waitFor(() => has('linkedAfter') && !has('linkedBefore'), 8000);
expect(cg!.getNodesByName('linkedAfter')[0].filePath).toBe('linked-project/src/item.ts');
fs.mkdirSync(path.join(dir, 'new'));
fs.writeFileSync(path.join(dir, 'new', 'added.ts'), 'export function linkedAdded() {}');
await waitFor(() => has('linkedAdded'), 8000);
fs.unlinkSync(file);
await waitFor(() => !has('linkedAfter'), 8000);
}, 30000);
it('watches both targets of a real cycle and deduplicates aliases', async () => {
const a = external();
const b = external();
link(b, path.join(a, 'toB'));
link(a, path.join(b, 'toA'));
link(testDir, path.join(a, 'toRoot'));
link(a, path.join(testDir, 'linked'));
link(a, path.join(testDir, 'duplicate'));
await start();
fs.writeFileSync(path.join(a, 'a.ts'), 'export function cycleAddedA() {}');
fs.writeFileSync(path.join(b, 'b.ts'), 'export function cycleAddedB() {}');
await waitFor(() => has('cycleAddedA') && has('cycleAddedB'), 8000);
expect(cg!.getNodesByName('cycleAddedA')).toHaveLength(1);
expect(cg!.getNodesByName('cycleAddedB')).toHaveLength(1);
}, 15000);
it('discovers new links, retargets them, removes stale paths and restarts', async () => {
const a = external();
const b = external();
fs.writeFileSync(path.join(a, 'a.ts'), 'export function firstTarget() {}');
fs.writeFileSync(path.join(b, 'b.ts'), 'export function secondTarget() {}');
await start();
const alias = path.join(testDir, 'linked');
link(a, alias);
await waitFor(() => has('firstTarget'), 8000);
fs.unlinkSync(alias);
link(b, alias);
await waitFor(() => has('secondTarget') && !has('firstTarget'), 8000);
fs.writeFileSync(path.join(b, 'b.ts'), 'export function retargetEdit() {}');
await waitFor(() => has('retargetEdit'), 8000);
cg!.unwatch();
expect(cg!.watch({ debounceMs: 100 })).toBe(true);
fs.writeFileSync(path.join(b, 'b.ts'), 'export function restartedEdit() {}');
await waitFor(() => has('restartedEdit'), 8000);
fs.unlinkSync(alias);
await waitFor(() => !has('restartedEdit'), 8000);
}, 45000);
it('refreshes excluded link scope without watching ignored targets', async () => {
const dir = external();
link(dir, path.join(testDir, 'linked'));
fs.writeFileSync(path.join(dir, 'a.ts'), 'export function scopeLinked() {}');
fs.writeFileSync(path.join(testDir, '.gitignore'), 'linked/\n');
await start();
expect(has('scopeLinked')).toBe(false);
fs.writeFileSync(path.join(testDir, '.gitignore'), '');
await waitFor(() => has('scopeLinked'), 8000);
fs.writeFileSync(path.join(dir, 'a.ts'), 'export function scopeEdited() {}');
await waitFor(() => has('scopeEdited'), 8000);
fs.writeFileSync(path.join(testDir, '.gitignore'), 'linked/\n');
await waitFor(() => !has('scopeEdited'), 8000);
}, 30000);
it('bounds watch handles, skips ignored targets and closes every handle on stop', () => {
const dir = external();
link(dir, path.join(testDir, 'linked'));
link(dir, path.join(testDir, 'duplicate'));
link(testDir, path.join(dir, 'cycle'));
const ignored = external();
link(ignored, path.join(testDir, 'node_modules'));
const opened: string[] = [];
const closed: string[] = [];
__setFsWatchForTests(((dir: fs.PathLike) => {
opened.push(String(dir));
const w = new EventEmitter() as fs.FSWatcher;
w.close = () => { closed.push(String(dir)); };
return w;
}) as typeof fs.watch);
const watcher = new FileWatcher(testDir, async () => ({ filesChanged: 0, durationMs: 0 }));
watchers.push(watcher);
expect(watcher.start()).toBe(true);
expect(opened.some(d => d.includes('node_modules'))).toBe(false);
expect(opened.filter(d => fs.realpathSync(d) === fs.realpathSync(dir))).toHaveLength(1);
watcher.stop();
expect(closed.sort()).toEqual(opened.sort());
expect(watcher.start()).toBe(true);
watcher.stop();
expect(closed.sort()).toEqual(opened.sort());
});
it.runIf(process.platform === 'darwin' || process.platform === 'win32')('caps supplemental recursive streams and degrades on their exhaustion', () => {
link(external(), path.join(testDir, 'first'));
link(external(), path.join(testDir, 'second'));
vi.stubEnv('CODEGRAPH_MAX_DIR_WATCHES', '1');
const close = vi.fn();
const watch = vi.fn(() => {
const w = new EventEmitter() as fs.FSWatcher;
w.close = close;
return w;
});
__setFsWatchForTests(watch as typeof fs.watch);
const watcher = new FileWatcher(testDir, async () => ({ filesChanged: 0, durationMs: 0 }));
watchers.push(watcher);
expect(watcher.start()).toBe(true);
expect(watch).toHaveBeenCalledTimes(2); // root + capped supplemental stream
watcher.stop();
expect(close).toHaveBeenCalledTimes(2);
watch.mockImplementationOnce(() => {
const w = new EventEmitter() as fs.FSWatcher;
w.close = close;
return w;
}).mockImplementation(() => { throw Object.assign(new Error('EMFILE'), { code: 'EMFILE' }); });
expect(watcher.start()).toBe(false);
expect(watcher.isDegraded()).toBe(true);
expect(close).toHaveBeenCalledTimes(3);
});
});
describe('scoped sync fast path (#watcher-scoped)', () => {
@@ -1040,6 +1212,29 @@ describe('FileWatcher', () => {
}
});
it('reconciles again when directory topology changes during a full sync', async () => {
let release!: () => void;
const firstRun = new Promise<void>(resolve => { release = resolve; });
const calls: (string[] | undefined)[] = [];
const watcher = newWatcher(async paths => {
calls.push(paths);
if (calls.length === 1) await firstRun;
return { filesChanged: 0, durationMs: 1 };
}, { debounceMs: 25 });
watcher.start();
try {
__emitWatchEventForTests(testDir, 'removed-link');
await waitFor(() => calls.length === 1);
__emitWatchEventForTests(testDir, 'another-removed-link');
release();
await waitFor(() => calls.length === 2);
expect(calls).toEqual([undefined, undefined]);
} finally {
release();
watcher.stop();
}
});
it('a lone file event fires on the quick window, well before the full debounce', async () => {
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async (paths?: string[]) => {
+112 -77
View File
@@ -8,7 +8,8 @@
* addon) with a per-platform strategy chosen to keep the open-descriptor /
* kernel-watch cost BOUNDED rather than growing with the number of files:
*
* - macOS / Windows: a SINGLE recursive `fs.watch(root, {recursive:true})`.
* - macOS / Windows: a recursive `fs.watch(root, {recursive:true})`, plus
* bounded supplemental streams for directory symlinks (#770).
* libuv maps this to one FSEvents stream (macOS) / one
* ReadDirectoryChangesW handle (Windows), so it costs O(1) descriptors no
* matter how large the tree. This is the fix for the macOS file-table
@@ -252,8 +253,8 @@ export interface PendingFile {
* debounced sync operations via a provided callback.
*
* Design goals:
* - Bounded resource usage: O(1) descriptors on macOS/Windows (one recursive
* watch), O(directories) inotify watches on Linux — never O(files), which
* - Bounded resource usage: one recursive stream plus capped symlink streams
* on macOS/Windows, O(directories) inotify watches on Linux — never O(files), which
* was the system-crashing fd leak on macOS (#644/#496/#555/#628).
* - Debounced to avoid thrashing on rapid saves
* - Filters to supported source files by extension
@@ -264,7 +265,7 @@ export interface PendingFile {
export class FileWatcher {
/** macOS/Windows: the single recursive watcher. Null on Linux. */
private recursiveWatcher: fs.FSWatcher | null = null;
/** Linux: one watcher per watched directory (keyed by absolute path). */
/** Linux directory watches / macOS and Windows supplemental symlink streams. */
private dirWatchers = new Map<string, fs.FSWatcher>();
/** Set once the per-directory watch cap is hit, so we log only once. */
private dirCapWarned = false;
@@ -289,6 +290,15 @@ export class FileWatcher {
private syncFailureRetryCount = 0;
/** Test-only inert mode: started, but with no OS watcher installed. */
private inert = false;
/**
* Resolved realpath of directories already watched (or skipped). Prevents
* infinite recursion via symlink cycles — mirrors the same guard used in
* the indexer's `walk()` (extraction/index.ts).
*/
private visitedRealDirs = new Set<string>();
private knownDirs = new Map<string, string>();
private watchedRealDirs = new Map<string, string>();
private treeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* True when the pending set does NOT exactly describe the change (a
@@ -372,6 +382,7 @@ export class FileWatcher {
this.degradedReason = null;
this.lockRetryCount = 0;
this.syncFailureRetryCount = 0;
this.needsFullScan = false;
// Some environments make filesystem watching unusable — most notably
// WSL2 /mnt/ drives, where the underlying fs.watch calls block long
@@ -393,11 +404,11 @@ export class FileWatcher {
} else if (supportsRecursiveWatch()) {
this.startRecursive();
} else {
this.startPerDirectory();
this.refreshWatchTree();
}
// The per-directory (Linux) path catches watch-resource exhaustion inside
// watchTree and degrades synchronously rather than throwing, so it never
// watchDirectory and degrades synchronously rather than throwing, so it never
// reaches the catch below. Surface that as a failed start here so both
// strategies report exhaustion identically (start() === false).
if (this.degradedReason) return false;
@@ -434,7 +445,7 @@ export class FileWatcher {
}
/**
* macOS/Windows: one recursive watcher for the whole tree. O(1) descriptors.
* macOS/Windows: one root stream, supplemented for directory symlinks.
* `filename` arrives relative to the project root (with subdirectories), so
* it maps straight to a project-relative path.
*/
@@ -442,10 +453,7 @@ export class FileWatcher {
this.recursiveWatcher = watchImpl(
this.projectRoot,
{ recursive: true, persistent: true },
(_event, filename) => {
if (this.stopped || filename == null) return;
this.handleChange(normalizePath(String(filename)));
}
(_event, filename) => this.handleDirEvent(this.projectRoot, filename)
);
this.recursiveWatcher.on('error', (err: unknown) => {
if (isWatchResourceExhaustion(err)) {
@@ -454,47 +462,78 @@ export class FileWatcher {
}
logWarn('File watcher error', { error: String(err) });
});
this.refreshWatchTree();
}
/**
* Linux: walk the (non-ignored) tree and watch each directory. One inotify
* watch per directory reports create/modify/delete for that directory's
* direct children, so we never watch individual files.
*/
private startPerDirectory(): void {
this.watchTree(this.projectRoot, /* markExisting */ false);
/** Reconcile logical directory paths with real targets, including retargeted links. */
private refreshWatchTree(): void {
if (this.stopped || this.inert) return;
this.visitedRealDirs.clear();
const directories = new Map<string, string>();
const desired = new Map<string, string>();
const recursive = supportsRecursiveWatch();
const walk = (dir: string, linked: boolean): void => {
if (this.shouldIgnoreDir(dir)) return;
let real: string;
try {
real = fs.realpathSync(dir);
if (this.visitedRealDirs.has(real)) return;
this.visitedRealDirs.add(real);
directories.set(dir, real);
if (!recursive || linked) desired.set(dir, real);
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const child = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(child, false);
} else if (entry.isSymbolicLink()) {
// Follow directory symlinks just like the indexer's walk. Realpath
// deduplication includes ordinary directories and the root itself.
try {
if (fs.statSync(child).isDirectory()) walk(child, true);
} catch { /* broken link */ }
}
}
} catch { /* removed or unreadable directory */ }
};
walk(this.projectRoot, false);
this.knownDirs = directories;
for (const dir of this.dirWatchers.keys()) {
if (desired.get(dir) !== this.watchedRealDirs.get(dir)) this.unwatchDir(dir);
}
for (const [dir, real] of desired) this.watchDirectory(dir, real, recursive);
}
/**
* Add an inotify watch for `dir` and recurse into its non-ignored
* subdirectories. When `markExisting` is true (a directory that appeared
* AFTER startup), the source files already inside it are recorded as pending
* — this closes the `mkdir + write` race where files created before the new
* directory's watch is installed would otherwise be missed until the next
* full sync. The initial startup walk passes false (the engine's catch-up
* sync owns the baseline).
*/
private watchTree(dir: string, markExisting: boolean): void {
// A degrade() mid-walk (exhaustion on an earlier directory) calls stop(),
// which sets `stopped`; bail so the recursion unwinds without adding more
// watches to a watcher that is shutting down. `inotifyLimitWarned` does the
// same after ENOSPC — the kernel budget is gone, so stop trying the rest of
// the tree (every add would fail) while keeping the watches already set.
/** Coalesce directory topology events before walking; file edits never walk the tree. */
private scheduleTreeRefresh(): void {
if (this.treeRefreshTimer || this.inert || this.stopped) return;
this.treeRefreshTimer = setTimeout(() => {
this.treeRefreshTimer = null;
this.refreshWatchTree();
if (this.stopped) return;
// A new/retargeted directory can already contain files before its watch
// is installed; a full reconcile closes that race and removes old paths.
this.needsFullScan = true;
this.scheduleSync();
}, 50);
}
private watchDirectory(dir: string, real: string, recursive: boolean): void {
if (this.stopped || this.degradedReason || this.inotifyLimitWarned) return;
if (this.dirWatchers.has(dir)) return;
if (this.dirWatchers.size >= maxDirWatches()) {
// Recursive streams cost more than inotify entries. Keep a separate hard
// ceiling while honouring a lower user-configured directory watch budget.
const cap = recursive ? Math.min(256, maxDirWatches()) : maxDirWatches();
if (this.dirWatchers.size >= cap) {
if (!this.dirCapWarned) {
this.dirCapWarned = true;
logWarn('File watcher hit directory-watch cap; remaining subtrees rely on manual/periodic sync', {
cap: maxDirWatches(),
});
logWarn('File watcher hit directory-watch cap; remaining subtrees rely on manual/periodic sync', { cap });
}
return;
}
let w: fs.FSWatcher;
try {
w = watchImpl(dir, { persistent: true }, (_event, filename) =>
w = watchImpl(dir, { persistent: true, recursive }, (_event, filename) =>
this.handleDirEvent(dir, filename)
);
} catch (err) {
@@ -523,46 +562,36 @@ export class FileWatcher {
});
this.dirWatchers.set(dir, w);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const child = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (this.shouldIgnoreDir(child)) continue;
this.watchTree(child, markExisting);
} else if (markExisting && entry.isFile()) {
this.handleChange(normalizePath(path.relative(this.projectRoot, child)));
}
}
this.watchedRealDirs.set(dir, real);
}
/**
* Linux per-directory event handler. `filename` is relative to `dir`. A new
* sub-directory is picked up by extending the watch tree; everything else is
* routed through the shared change handler.
*/
/** Events are relative to a watch's logical path, even for an external target. */
private handleDirEvent(dir: string, filename: string | Buffer | null): void {
if (this.stopped || filename == null) return;
if (this.stopped) return;
if (filename == null) {
this.scheduleTreeRefresh();
return;
}
const full = path.join(dir, String(filename));
// A newly-created directory needs its own watch (recursive isn't available
// on Linux). statSync is cheap and these events are rare relative to file
// edits. If the path vanished (rapid create/delete) the stat throws and we
// fall through to the change handler, which no-ops on a non-source path.
const rel = normalizePath(path.relative(this.projectRoot, full));
if (!rel || rel === '.') {
this.scheduleTreeRefresh();
return;
}
// Churn under an ignored directory (node_modules, dist, .git) is dropped
// before any per-event fs call; `.git/info/exclude` still reaches the scope
// refresh in handleChange (#1728).
if (rel !== '.git/info/exclude' && this.shouldIgnoreDir(path.dirname(full))) return;
try {
if (fs.statSync(full).isDirectory()) {
if (!this.shouldIgnoreDir(full)) this.watchTree(full, /* markExisting */ true);
if (this.shouldIgnoreDir(full)) return;
// Existing directory notifications can represent a retargeted link.
if (this.knownDirs.get(full) !== fs.realpathSync(full)) this.scheduleTreeRefresh();
return;
}
} catch {
// deleted/inaccessible — treat as a normal change below
}
this.handleChange(normalizePath(path.relative(this.projectRoot, full)));
} catch { /* deleted/inaccessible */ }
if (this.knownDirs.has(full) && !this.shouldIgnoreDir(full)) this.scheduleTreeRefresh();
this.handleChange(rel);
}
/**
@@ -645,6 +674,7 @@ export class FileWatcher {
private refreshScope(rel: string): void {
logDebug('Scope config changed; rebuilding watcher scope', { file: rel });
this.ignoreMatcher = buildScopeIgnore(this.projectRoot);
this.scheduleTreeRefresh();
this.needsFullScan = true;
this.scheduleSync();
}
@@ -689,19 +719,16 @@ export class FileWatcher {
/* already closed */
}
this.dirWatchers.delete(dir);
this.watchedRealDirs.delete(dir);
}
}
/** Our own dirs are always ignored, regardless of .gitignore. */
private isAlwaysIgnored(rel: string): boolean {
// First path segment. Ignore any CodeGraph data dir — the active one AND a
// Ignore any CodeGraph data dir — the active one AND a
// sibling like `.codegraph-win` a second environment (Windows/WSL) created
// in the same tree, so neither side watches the other's index (#636).
const top = rel.split('/')[0] ?? rel;
return (
isCodeGraphDataDir(top) ||
rel === '.git' || rel.startsWith('.git/')
);
return rel.split('/').some(part => isCodeGraphDataDir(part) || part === '.git');
}
/**
@@ -766,6 +793,10 @@ export class FileWatcher {
*/
stop(): void {
this.stopped = true;
if (this.treeRefreshTimer) clearTimeout(this.treeRefreshTimer);
this.treeRefreshTimer = null;
this.knownDirs.clear();
this.watchedRealDirs.clear();
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
@@ -794,6 +825,7 @@ export class FileWatcher {
this.syncFailureRetryCount = 0;
// NB: degradedReason is intentionally NOT reset here — it must survive the
// stop() that degrade() triggers so isDegraded() stays true. start() clears it.
this.visitedRealDirs.clear();
this.inert = false;
this.pendingFiles.clear();
@@ -910,9 +942,11 @@ export class FileWatcher {
? [...this.pendingFiles.keys()]
: undefined;
// Consume this request before yielding so a topology/scope change during
// the full sync remains pending for the next reconcile.
if (!scoped) this.needsFullScan = false;
try {
const result = await this.syncFn(scoped);
if (!scoped) this.needsFullScan = false;
this.lockRetryCount = 0; // a clean sync clears any contention backoff
this.syncFailureRetryCount = 0; // ...and any generic-failure backoff
// Remove entries whose most recent event predates this sync — those
@@ -929,6 +963,7 @@ export class FileWatcher {
}
this.onSyncComplete?.(result);
} catch (err) {
if (!scoped) this.needsFullScan = true;
if (err instanceof LockUnavailableError) {
this.lockRetryCount += 1;
// Lock-failure no-op (another writer holds the lock). pendingFiles