Skip to content

Commit f300961

Browse files
PatrickSysclaude
andcommitted
feat(watcher): chokidar auto-refresh with debounced incremental reindex
Adds a chokidar-based file watcher that runs in MCP server mode. Any source file change in the project root triggers a debounced incremental reindex automatically — no manual refresh_index needed. - src/core/file-watcher.ts: FileWatcherOptions interface + startFileWatcher() with configurable debounce (default 2 s, override via CODEBASE_CONTEXT_DEBOUNCE_MS) - src/index.ts: wires watcher after server.connect(), guards against concurrent reindexes with indexState.status check, cleans up on exit/SIGINT/SIGTERM - tests/file-watcher.test.ts: 3 vitest tests covering trigger, debounce coalescing, and stop() cancellation (real fs writes, no fake timers) - package.json: adds chokidar ^3.6.0 dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 04e68eb commit f300961

File tree

5 files changed

+212
-0
lines changed

5 files changed

+212
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
"@lancedb/lancedb": "^0.4.0",
127127
"@modelcontextprotocol/sdk": "^1.25.2",
128128
"@typescript-eslint/typescript-estree": "^7.0.0",
129+
"chokidar": "^3.6.0",
129130
"fuse.js": "^7.0.0",
130131
"glob": "^10.3.10",
131132
"hono": "^4.12.2",

pnpm-lock.yaml

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/file-watcher.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import chokidar from 'chokidar';
2+
3+
export interface FileWatcherOptions {
4+
rootPath: string;
5+
/** ms after last change before triggering. Default: 2000 */
6+
debounceMs?: number;
7+
/** Called once the debounce window expires after the last detected change */
8+
onChanged: () => void;
9+
}
10+
11+
/**
12+
* Watch rootPath for source file changes and call onChanged (debounced).
13+
* Returns a stop() function that cancels the debounce timer and closes the watcher.
14+
*/
15+
export function startFileWatcher(opts: FileWatcherOptions): () => void {
16+
const { rootPath, debounceMs = 2000, onChanged } = opts;
17+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
18+
19+
const trigger = () => {
20+
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
21+
debounceTimer = setTimeout(() => {
22+
debounceTimer = undefined;
23+
onChanged();
24+
}, debounceMs);
25+
};
26+
27+
const watcher = chokidar.watch(rootPath, {
28+
ignored: ['**/node_modules/**', '**/.codebase-context/**', '**/.git/**', '**/dist/**'],
29+
persistent: true,
30+
ignoreInitial: true,
31+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 }
32+
});
33+
34+
watcher
35+
.on('add', trigger)
36+
.on('change', trigger)
37+
.on('unlink', trigger)
38+
.on('error', (err: unknown) => console.error('[file-watcher] error:', err));
39+
40+
return () => {
41+
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
42+
void watcher.close();
43+
};
44+
}

src/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
} from './constants/codebase-context.js';
3939
import { appendMemoryFile } from './memory/store.js';
4040
import { handleCliCommand } from './cli.js';
41+
import { startFileWatcher } from './core/file-watcher.js';
4142
import { parseGitLogLineToMemory } from './memory/git-memory.js';
4243
import {
4344
isComplementaryPatternCategory,
@@ -726,6 +727,33 @@ async function main() {
726727
await server.connect(transport);
727728

728729
if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready');
730+
731+
// Auto-refresh: watch for file changes and trigger incremental reindex
732+
const debounceMs = parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10) || 2000;
733+
const stopWatcher = startFileWatcher({
734+
rootPath: ROOT_PATH,
735+
debounceMs,
736+
onChanged: () => {
737+
if (indexState.status === 'indexing') {
738+
if (process.env.CODEBASE_CONTEXT_DEBUG) {
739+
console.error('[file-watcher] Index in progress — skipping auto-refresh');
740+
}
741+
return;
742+
}
743+
console.error('[file-watcher] Changes detected — incremental reindex starting');
744+
performIndexing(true);
745+
}
746+
});
747+
748+
process.once('exit', stopWatcher);
749+
process.once('SIGINT', () => {
750+
stopWatcher();
751+
process.exit(0);
752+
});
753+
process.once('SIGTERM', () => {
754+
stopWatcher();
755+
process.exit(0);
756+
});
729757
}
730758

731759
// Export server components for programmatic use

tests/file-watcher.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { promises as fs } from 'fs';
3+
import path from 'path';
4+
import os from 'os';
5+
import { startFileWatcher } from '../src/core/file-watcher.js';
6+
7+
describe('FileWatcher', () => {
8+
let tempDir: string;
9+
10+
beforeEach(async () => {
11+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-watcher-test-'));
12+
});
13+
14+
afterEach(async () => {
15+
await fs.rm(tempDir, { recursive: true, force: true });
16+
});
17+
18+
it('triggers onChanged after debounce window', async () => {
19+
const debounceMs = 400;
20+
let callCount = 0;
21+
22+
const stop = startFileWatcher({
23+
rootPath: tempDir,
24+
debounceMs,
25+
onChanged: () => { callCount++; },
26+
});
27+
28+
try {
29+
// Give chokidar a moment to finish initializing before the first write
30+
await new Promise((resolve) => setTimeout(resolve, 100));
31+
await fs.writeFile(path.join(tempDir, 'test.ts'), 'export const x = 1;');
32+
// Wait for chokidar to pick up the event (including awaitWriteFinish stabilityThreshold)
33+
// + debounce window + OS scheduling slack
34+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 1000));
35+
expect(callCount).toBe(1);
36+
} finally {
37+
stop();
38+
}
39+
}, 8000);
40+
41+
it('debounces rapid changes into a single callback', async () => {
42+
const debounceMs = 300;
43+
let callCount = 0;
44+
45+
const stop = startFileWatcher({
46+
rootPath: tempDir,
47+
debounceMs,
48+
onChanged: () => { callCount++; },
49+
});
50+
51+
try {
52+
// Write 5 files in quick succession — all within the debounce window
53+
for (let i = 0; i < 5; i++) {
54+
await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`);
55+
await new Promise((resolve) => setTimeout(resolve, 50));
56+
}
57+
// Wait for debounce to settle
58+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 400));
59+
expect(callCount).toBe(1);
60+
} finally {
61+
stop();
62+
}
63+
}, 8000);
64+
65+
it('stop() cancels a pending callback', async () => {
66+
const debounceMs = 500;
67+
let callCount = 0;
68+
69+
const stop = startFileWatcher({
70+
rootPath: tempDir,
71+
debounceMs,
72+
onChanged: () => { callCount++; },
73+
});
74+
75+
await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;');
76+
// Let chokidar detect the event but stop before debounce fires
77+
await new Promise((resolve) => setTimeout(resolve, 150));
78+
stop();
79+
// Wait past where debounce would have fired
80+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
81+
expect(callCount).toBe(0);
82+
}, 5000);
83+
});

0 commit comments

Comments
 (0)