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:
co-authored by
Claude Fable 5
parent
18f0745f81
commit
8dcf92f285
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user