fix(watch): schedule a sync when a directory is deleted (#1313)

A directory deletion arrives as ONE event on the directory's own path.
That path has no source extension, so handleChange dropped it at the
isSourceFile gate before ever scheduling a sync — and the files inside
may never get events of their own (Windows's recursive watcher reports
only the top-most removed entry; FSEvents can coalesce a tree deletion
the same way). Every child record then sat stale in the index until an
unrelated edit happened to trigger a sync (#1285).

A non-source path that no longer EXISTS on disk now schedules the
debounced sync; the sync's scan-diff removes whatever vanished (already
correct — verified: manual `codegraph sync` cascades fine). Events for
live non-source files stay fully ignored, so build churn schedules
nothing.

Fixes #1285

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-16 15:27:10 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 18f0745f81
commit 8dcf92f285
3 changed files with 97 additions and 3 deletions
+63 -2
View File
@@ -438,8 +438,11 @@ describe('FileWatcher', () => {
watcher.start();
await watcher.waitUntilReady();
// A non-source-file event — FileWatcher's `isSourceFile` gate must drop
// it before scheduling sync.
// An EXISTING non-source file changing — FileWatcher's `isSourceFile`
// gate must drop it before scheduling sync. (It must exist on disk:
// a VANISHED non-source path is the deleted-directory shape, which
// deliberately schedules a sync — #1285.)
fs.writeFileSync(path.join(testDir, 'src', 'readme.md'), '# docs\n');
__emitWatchEventForTests(testDir, 'src/readme.md');
// Wait a bit longer than debounce — sync should NOT trigger.
@@ -449,6 +452,64 @@ describe('FileWatcher', () => {
watcher.stop();
});
it('a deleted directory schedules a sync so child records get removed (#1285)', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
// A directory deletion arrives as ONE event on the directory path —
// no extension, nothing on disk anymore. Must schedule a sync (the
// sync's scan-diff removes the children), not be dropped as
// "non-source".
const sub = path.join(testDir, 'docs');
fs.mkdirSync(path.join(sub, 'nested'), { recursive: true });
fs.writeFileSync(path.join(sub, 'nested', 'mod.ts'), 'export const q = 1;');
fs.rmSync(sub, { recursive: true, force: true });
__emitWatchEventForTests(testDir, 'docs');
await waitFor(() => syncFn.mock.calls.length > 0);
expect(syncFn).toHaveBeenCalled();
watcher.stop();
});
it('end-to-end: deleting a subdirectory removes its files from the index via watch sync (#1285)', async () => {
// Real CodeGraph as the sync target; the watcher is inert and driven
// by the synthetic event seam for determinism.
fs.writeFileSync(path.join(testDir, 'root.ts'), 'export const r = 1;');
const deep = path.join(testDir, 'docs', 'a', 'b');
fs.mkdirSync(deep, { recursive: true });
fs.writeFileSync(path.join(deep, 'inner.ts'), 'export const i = 2;');
const cg = CodeGraph.initSync(testDir);
await cg.indexAll();
const before = cg.getFiles().map((f) => f.path);
expect(before).toContain('docs/a/b/inner.ts');
const syncFn = vi.fn(async () => {
const r = await cg.sync();
return { filesChanged: r.filesAdded + r.filesModified + r.filesRemoved, durationMs: r.durationMs };
});
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
fs.rmSync(path.join(testDir, 'docs'), { recursive: true, force: true });
__emitWatchEventForTests(testDir, 'docs');
await waitFor(() => syncFn.mock.calls.length > 0, 5000);
// The sync body is async — poll the DB until the removal commits.
await waitFor(() => !cg.getFiles().some((f) => f.path.startsWith('docs/')), 5000);
const after = cg.getFiles().map((f) => f.path);
expect(after).toContain('root.ts');
expect(after.some((p) => p.startsWith('docs/'))).toBe(false);
watcher.stop();
cg.close();
});
it('should ignore .codegraph directory changes', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 200 });