fix(sync): refresh the watcher's scope when codegraph.json or a .gitignore changes (#1590) (#1594)

Fixes #1590.

## What was wrong

The live file watcher built its scope matcher — built-in defaults + `.gitignore` + the `codegraph.json` `exclude`/`include` rules — once in `start()` and kept it for the watcher's lifetime. The MCP server is long-lived, so a `codegraph.json` created or edited after it started was invisible to the watcher, while `codegraph sync` (a fresh process with a fresh matcher) honoured it immediately. From the user's side: the CLI removed a newly excluded file, and the daemon re-indexed it a few seconds later, which reads as "`exclude` doesn't work". As the report points out, `extensions` on the very same config file *was* read live (its loader is mtime-cached), so two fields of one file behaved differently.

There was a second half to it. The watcher's scoped fast path hands the exact edited paths to sync, and that path stat'ed and re-parsed them without consulting the scope matcher at all — so the stale view of scope leaked straight into the index.

## What this does

**Watcher — rebuild on a scope change, then reconcile in full.** An event for the root `codegraph.json` or `.gitignore` rebuilds the matcher, marks the next sync as a full reconcile, and schedules it. A scope change has no per-file events: newly excluded files must be *removed* from the index and newly included ones *added*, and only the scan-diff (which builds its own fresh matcher) knows which those are. Two ordering details are deliberate:

- the two root files are checked *before* the matcher is consulted, so a user pattern that happens to cover them (`*.json`, `.*`) can't hide their own edits;
- a nested `.gitignore` (an embedded child repo's own rules, or a subdirectory rule the git-backed scan honours) is checked *after* the matcher, so the thousands of package-local `.gitignore`s an `npm install` writes under an ignored `node_modules/` can never trigger a rebuild storm.

Rebuilding runs embedded-repo discovery (one `git ls-files`), which is fine per config edit and never happens per event. Replacing the field serves both watch strategies: the recursive handler and the per-directory `shouldIgnoreDir` walk read it on every call.

**Scoped sync — re-check the paths it was handed.** The orchestrator now runs scoped paths through the same scope matcher and source-extension gate the full walk applies. An out-of-scope path is treated as absent: removed if tracked, never parsed on trust. The matcher is memoized on the mtimes of the two root files it derives from (two `stat`s per sync while nothing changed), so the scoped path keeps skipping O(repo) work — paying embedded-repo discovery per sync would defeat its whole point.

## Tests

- `watcher.test.ts` — a `codegraph.json` edit schedules a full sync, after which an edit inside the newly excluded tree is dropped by the live matcher (not pending, no sync) while an in-scope edit still syncs scoped; a root `.gitignore` edit behaves the same; a nested `.gitignore` forces a full sync; a `.gitignore` under `node_modules/` schedules nothing; dropping the exclude again readmits the tree.
- `sync.test.ts` — end-to-end through `CodeGraph`: a scoped sync of a path that `codegraph.json` now excludes removes it (`filesRemoved: 1`, nothing parsed — the symbol added to the file never appears), stays out on a repeat, and is re-added through the same scoped path once the exclude is dropped.
- All five new tests fail on `main`; the `node_modules` guard passes both ways as expected.
- Full suite: 189 files, 3184 passed / 9 skipped.
- CLI half of the issue's repro (init with `exclude`, edit the config + the file, `codegraph sync`): the newly excluded file is removed and its new symbol never enters the index.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
This commit is contained in:
Colby Mchenry
2026-08-26 10:38:38 -05:00
committed by GitHub
parent 838006c947
commit cf1b0e341a
5 changed files with 265 additions and 8 deletions
+128
View File
@@ -545,6 +545,134 @@ describe('FileWatcher', () => {
});
});
describe('scope config refresh (#1590)', () => {
// The matcher used to be built once in start() and kept for the watcher's
// lifetime, so a `codegraph.json` written AFTER the daemon started was
// invisible to the live watcher while `codegraph sync` honoured it: the
// CLI removed a newly excluded file and the watcher re-added it.
it('a codegraph.json edit rebuilds the matcher and forces a full sync', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
// Scope the project after the watcher is already running.
fs.mkdirSync(path.join(testDir, 'skipme'));
fs.writeFileSync(path.join(testDir, 'skipme', 'b.ts'), 'export const b = 1;\n');
fs.writeFileSync(path.join(testDir, 'codegraph.json'), JSON.stringify({ exclude: ['skipme/'] }));
__emitWatchEventForTests(testDir, 'codegraph.json');
// The config edit schedules a FULL sync (no scoped path list): only the
// scan-diff can find the files the new scope drops or admits.
await waitFor(() => syncFn.mock.calls.length > 0);
expect(syncFn.mock.calls.length).toBe(1);
expect(syncFn.mock.calls[0]![0]).toBeUndefined();
expect(watcher.getPendingFiles()).toEqual([]);
await new Promise((r) => setTimeout(r, 50)); // let runSync settle
// An edit inside the newly excluded tree is dropped by the LIVE matcher:
// not pending, and no sync scheduled for it.
__emitWatchEventForTests(testDir, 'skipme/b.ts');
expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain('skipme/b.ts');
await new Promise((r) => setTimeout(r, 300)); // > debounce
expect(syncFn.mock.calls.length).toBe(1);
// In-scope edits still sync, scoped to the edited path as before.
__emitWatchEventForTests(testDir, 'src/index.ts');
await waitFor(() => syncFn.mock.calls.length > 1);
expect(syncFn.mock.calls[1]![0]).toEqual(['src/index.ts']);
watcher.stop();
});
it('a root .gitignore edit is a scope change too', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
fs.mkdirSync(path.join(testDir, 'gen'));
fs.writeFileSync(path.join(testDir, 'gen', 'out.ts'), 'export const g = 1;\n');
fs.writeFileSync(path.join(testDir, '.gitignore'), 'gen/\n');
__emitWatchEventForTests(testDir, '.gitignore');
await waitFor(() => syncFn.mock.calls.length > 0);
expect(syncFn.mock.calls[0]![0]).toBeUndefined();
await new Promise((r) => setTimeout(r, 50));
__emitWatchEventForTests(testDir, 'gen/out.ts');
expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain('gen/out.ts');
await new Promise((r) => setTimeout(r, 300));
expect(syncFn.mock.calls.length).toBe(1);
watcher.stop();
});
it('a nested .gitignore inside the scope forces a full sync', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
fs.mkdirSync(path.join(testDir, 'sub'));
fs.writeFileSync(path.join(testDir, 'sub', '.gitignore'), 'build/\n');
__emitWatchEventForTests(testDir, 'sub/.gitignore');
await waitFor(() => syncFn.mock.calls.length > 0);
expect(syncFn.mock.calls[0]![0]).toBeUndefined();
watcher.stop();
});
it('a .gitignore under an ignored tree (npm install churn) schedules nothing', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
fs.mkdirSync(path.join(testDir, 'node_modules', 'pkg'), { recursive: true });
fs.writeFileSync(path.join(testDir, 'node_modules', 'pkg', '.gitignore'), 'lib/\n');
__emitWatchEventForTests(testDir, 'node_modules/pkg/.gitignore');
await new Promise((r) => setTimeout(r, 300));
expect(syncFn).not.toHaveBeenCalled();
watcher.stop();
});
it('removing the exclude again readmits the tree', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
fs.mkdirSync(path.join(testDir, 'skipme'));
fs.writeFileSync(path.join(testDir, 'skipme', 'b.ts'), 'export const b = 1;\n');
const cfg = path.join(testDir, 'codegraph.json');
fs.writeFileSync(cfg, JSON.stringify({ exclude: ['skipme/'] }));
__emitWatchEventForTests(testDir, 'codegraph.json');
await waitFor(() => syncFn.mock.calls.length > 0);
await new Promise((r) => setTimeout(r, 50));
__emitWatchEventForTests(testDir, 'skipme/b.ts');
expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain('skipme/b.ts');
// Drop the exclude. The loader is mtime-keyed, so make sure the second
// write carries a distinct mtime even on a coarse-timestamp filesystem.
fs.writeFileSync(cfg, JSON.stringify({}));
const later = new Date(Date.now() + 5000);
fs.utimesSync(cfg, later, later);
__emitWatchEventForTests(testDir, 'codegraph.json');
await waitFor(() => syncFn.mock.calls.length > 1);
expect(syncFn.mock.calls[1]![0]).toBeUndefined();
await new Promise((r) => setTimeout(r, 50));
__emitWatchEventForTests(testDir, 'skipme/b.ts');
expect(watcher.getPendingFiles().map((p) => p.path)).toContain('skipme/b.ts');
watcher.stop();
});
});
describe('pending file tracking (#403)', () => {
it('should expose edited paths via getPendingFiles before sync fires', async () => {
// Slow debounce — pending entries are visible until the debounce fires.