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:
@@ -851,4 +851,33 @@ describe('Scoped sync parity (#watcher-scoped)', () => {
|
||||
// b.ts untouched and still present
|
||||
expect(cg.searchNodes('beta').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a scoped path that codegraph.json now excludes is removed, never re-parsed (#1590)', async () => {
|
||||
// The daemon's watcher hands sync the exact edited path. If the project's
|
||||
// scope changed underneath it, that path must be treated the way the full
|
||||
// scan treats it — out of scope, hence gone — never parsed on trust.
|
||||
const cfg = path.join(testDir, 'codegraph.json');
|
||||
fs.writeFileSync(cfg, JSON.stringify({ exclude: ['src/b.ts'] }));
|
||||
fs.writeFileSync(path.join(testDir, 'src', 'b.ts'), `export function beta() { return 2; }\nexport function gamma() { return 3; }`);
|
||||
const scoped = await cg.sync({ paths: ['src/b.ts'] });
|
||||
expect(scoped.filesRemoved).toBe(1);
|
||||
expect(scoped.filesModified).toBe(0);
|
||||
expect(scoped.filesAdded).toBe(0);
|
||||
expect(cg.searchNodes('gamma').length).toBe(0);
|
||||
expect(cg.searchNodes('beta').filter((r) => r.node.filePath === 'src/b.ts').length).toBe(0);
|
||||
// Idempotent: the file stays out on a repeat scoped sync.
|
||||
const again = await cg.sync({ paths: ['src/b.ts'] });
|
||||
expect(again.filesRemoved).toBe(0);
|
||||
expect(again.filesAdded).toBe(0);
|
||||
|
||||
// Dropping the exclude readmits it through the same scoped path. The
|
||||
// scope matcher is mtime-keyed, so give the rewrite 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);
|
||||
const readmitted = await cg.sync({ paths: ['src/b.ts'] });
|
||||
expect(readmitted.filesAdded).toBe(1);
|
||||
expect(cg.searchNodes('gamma').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user