Repository: colbymchenry/codegraph (2026-07-16T20:27:08Z)

Issue:
Directory deletion not cascading to child file records in index
## Summary

When a **subdirectory containing source files** is deleted from the project, CodeGraph fails to detect the change and does not update the SQLite index. The deleted files remain in the database, and index counts (files, nodes, edges) stay unchanged.

This appears to be a **cascade deletion bug**: removing a parent directory does not trigger removal of its child file records from the index.

## Reproduction Steps

1. `codegraph init` — initialize index
2. Create nested source file: `docs/1/2/新建文件夹/_0204_.py`
3. Wait for CodeGraph to sync (check `Last Sync` timestamp)
4. **Delete the subdirectory `docs/1`** (which contains `_0204_.py`)
5. Observe: index counts unchanged, deleted file still appears in queries

## Attachment: Diagnostic Tool

I built a lightweight real-time monitor (`codegraph_state.js`) to visualize this bug. It displays live index counts alongside filesystem events, making the mismatch immediately obvious.

**Author: feel free to use or adapt this tool for debugging.**

[codegraph_state.js](https://github.com/user-attachments/files/30005409/codegraph_state.js)


Discussion:
Fixed in #1313 (merged to main, ships in the next release) — and thanks for the live-monitor tool, it made the failure mode easy to see.

One clarification on the mechanics: the database side was already cascading correctly — a manual `codegraph sync` removes a deleted directory's file records fine. The gap was the **watcher**: a directory deletion arrives from the OS as a single event on the directory's own path (often with no per-file events for the contents), and since that path has no source extension, the watcher discarded it before ever scheduling a sync. Your index then kept the child records until some unrelated edit happened to trigger one.

The watcher now treats a filtered path that has vanished from disk as a potential subtree removal and schedules the debounced sync; the sync's scan-diff removes whatever is gone. Events for non-source files that still exist stay fully ignored, so build churn doesn't schedule extra work.

Pinned by an end-to-end regression test (index → delete nested directory → watch event → child records gone).



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2e7c46ee..1da973b10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### 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)
 - 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)
diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts
index 059f54334..652a6734b 100644
--- a/src/sync/watcher.ts
+++ b/src/sync/watcher.ts
@@ -551,7 +551,10 @@ export class FileWatcher {
     if (!rel || rel === '.' || rel.startsWith('..')) return;
     if (this.isAlwaysIgnored(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 });
     if (this.ready) {
@@ -565,6 +568,35 @@ export class FileWatcher {
     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. */
   private unwatchDir(dir: string): void {
     const w = this.dirWatchers.get(dir);


Test patch:
diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts
index 9eb0b621d..10e1b6085 100644
--- a/__tests__/watcher.test.ts
+++ b/__tests__/watcher.test.ts
@@ -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 });