Explorar el Código

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>
Colby Mchenry hace 1 mes
padre
commit
8dcf92f285
Se han modificado 3 ficheros con 97 adiciones y 3 borrados
  1. 1 0
      CHANGELOG.md
  2. 63 2
      __tests__/watcher.test.ts
  3. 33 1
      src/sync/watcher.ts

+ 1 - 0
CHANGELOG.md

@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 
 ### Fixes
 ### Fixes
 
 
+- Deleting a whole directory is now picked up by watch mode: the files inside it are removed from the index on the next auto-sync instead of lingering as stale records until an unrelated edit happened to trigger one. Operating systems often report a directory deletion as a single event on the directory itself (with no per-file events for its contents), which the watcher previously discarded. (#1285)
 - `codegraph sync` now gets the same slow-disk fix that made full indexing fast in 1.4.0: database checkpointing is deferred for the whole incremental run instead of firing every few megabytes of writes. On mechanical drives and other high-latency storage, a small sync on a large index no longer stalls for minutes at near-zero CPU — the cost of a sync scales with what changed, not with the size of the existing index. The same `CODEGRAPH_NO_WAL_DEFER=1` switch turns it off. (#1248)
 - `codegraph sync` now gets the same slow-disk fix that made full indexing fast in 1.4.0: database checkpointing is deferred for the whole incremental run instead of firing every few megabytes of writes. On mechanical drives and other high-latency storage, a small sync on a large index no longer stalls for minutes at near-zero CPU — the cost of a sync scales with what changed, not with the size of the existing index. The same `CODEGRAPH_NO_WAL_DEFER=1` switch turns it off. (#1248)
 - C functions declared with a project-specific attribute macro in front of a typedef'd return type (`SEC_ATTR UINT32 MyFunc(VOID)` — common in embedded and kernel code) are now indexed under their real names. Previously the parser tripped over the unknown macro and stored the parameter list as the function name, leaving entries like `"(VOID)"` in the graph and making the real function unfindable. (#1211)
 - C functions declared with a project-specific attribute macro in front of a typedef'd return type (`SEC_ATTR UINT32 MyFunc(VOID)` — common in embedded and kernel code) are now indexed under their real names. Previously the parser tripped over the unknown macro and stored the parameter list as the function name, leaving entries like `"(VOID)"` in the graph and making the real function unfindable. (#1211)
 - C++ methods defined out-of-line inside a namespace (`namespace sim { Output MyClass::Apply(...) { ... } }`) now carry the namespace in their qualified name, matching their class. Fully-qualified call sites from other files (`sim::MyClass::Apply(...)`) resolve to the definition again, so `codegraph callers` and file impact no longer come up empty for this pattern. (#1291)
 - C++ methods defined out-of-line inside a namespace (`namespace sim { Output MyClass::Apply(...) { ... } }`) now carry the namespace in their qualified name, matching their class. Fully-qualified call sites from other files (`sim::MyClass::Apply(...)`) resolve to the definition again, so `codegraph callers` and file impact no longer come up empty for this pattern. (#1291)

+ 63 - 2
__tests__/watcher.test.ts

@@ -438,8 +438,11 @@ describe('FileWatcher', () => {
       watcher.start();
       watcher.start();
       await watcher.waitUntilReady();
       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');
       __emitWatchEventForTests(testDir, 'src/readme.md');
 
 
       // Wait a bit longer than debounce — sync should NOT trigger.
       // Wait a bit longer than debounce — sync should NOT trigger.
@@ -449,6 +452,64 @@ describe('FileWatcher', () => {
       watcher.stop();
       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 () => {
     it('should ignore .codegraph directory changes', async () => {
       const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
       const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
       const watcher = newWatcher(syncFn, { debounceMs: 200 });
       const watcher = newWatcher(syncFn, { debounceMs: 200 });

+ 33 - 1
src/sync/watcher.ts

@@ -551,7 +551,10 @@ export class FileWatcher {
     if (!rel || rel === '.' || rel.startsWith('..')) return;
     if (!rel || rel === '.' || rel.startsWith('..')) return;
     if (this.isAlwaysIgnored(rel)) return;
     if (this.isAlwaysIgnored(rel)) return;
     if (this.ignoreMatcher && this.ignoreMatcher.ignores(rel)) return;
     if (this.ignoreMatcher && this.ignoreMatcher.ignores(rel)) return;
-    if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot))) return;
+    if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot))) {
+      this.maybeScheduleForRemovedDir(rel);
+      return;
+    }
 
 
     logDebug('File change detected', { file: rel });
     logDebug('File change detected', { file: rel });
     if (this.ready) {
     if (this.ready) {
@@ -565,6 +568,35 @@ export class FileWatcher {
     this.scheduleSync();
     this.scheduleSync();
   }
   }
 
 
+  /**
+   * A deleted DIRECTORY arrives as one event on the directory's own path —
+   * no source extension, so the source-file filter drops it, and the files
+   * underneath may never get events of their own (Windows's recursive
+   * watcher reports only the top-most removed entry; macOS FSEvents can
+   * coalesce a tree deletion the same way). The index then kept every child
+   * record until some unrelated edit happened to trigger a sync (#1285).
+   *
+   * If a non-source path no longer exists on disk, treat it as a potential
+   * subtree removal and schedule the debounced sync — its scan-diff removes
+   * whatever is gone, which is the ground truth for what was underneath.
+   * pendingFiles is left alone (we can't know the children from the event).
+   * An event for an EXISTING non-source file stays fully ignored, so build
+   * churn on live files never schedules work; a deleted non-source file
+   * costs at most one no-op scan-diff, absorbed by the debounce.
+   */
+  private maybeScheduleForRemovedDir(rel: string): void {
+    try {
+      fs.statSync(path.join(this.projectRoot, rel));
+      return; // still on disk — an ordinary non-source change, ignore
+    } catch {
+      /* gone — fall through */
+    }
+    logDebug('Non-source path removed; scheduling sync for possible directory removal', {
+      path: rel,
+    });
+    this.scheduleSync();
+  }
+
   /** Close and forget the watch for a directory that errored/was removed. */
   /** Close and forget the watch for a directory that errored/was removed. */
   private unwatchDir(dir: string): void {
   private unwatchDir(dir: string): void {
     const w = this.dirWatchers.get(dir);
     const w = this.dirWatchers.get(dir);