Skip to content

Commit 59e3686

Browse files
authored
Merge pull request #52 from PatrickSys/feat/chokidar-file-watcher
feat(watcher): chokidar auto-refresh with debounced incremental reindex
2 parents 04e68eb + 070433c commit 59e3686

File tree

5 files changed

+240
-1
lines changed

5 files changed

+240
-1
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: 51 additions & 1 deletion
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,
@@ -251,6 +252,8 @@ const indexState: IndexState = {
251252
status: 'idle'
252253
};
253254

255+
let autoRefreshQueued = false;
256+
254257
const server: Server = new Server(
255258
{
256259
name: 'codebase-context',
@@ -511,7 +514,7 @@ async function extractGitMemories(): Promise<number> {
511514
return added;
512515
}
513516

514-
async function performIndexing(incrementalOnly?: boolean): Promise<void> {
517+
async function performIndexingOnce(incrementalOnly?: boolean): Promise<void> {
515518
indexState.status = 'indexing';
516519
const mode = incrementalOnly ? 'incremental' : 'full';
517520
console.error(`Indexing (${mode}): ${ROOT_PATH}`);
@@ -565,6 +568,22 @@ async function performIndexing(incrementalOnly?: boolean): Promise<void> {
565568
}
566569
}
567570

571+
async function performIndexing(incrementalOnly?: boolean): Promise<void> {
572+
let nextMode = incrementalOnly;
573+
for (;;) {
574+
await performIndexingOnce(nextMode);
575+
576+
const shouldRunQueuedRefresh = autoRefreshQueued && indexState.status === 'ready';
577+
autoRefreshQueued = false;
578+
if (!shouldRunQueuedRefresh) return;
579+
580+
if (process.env.CODEBASE_CONTEXT_DEBUG) {
581+
console.error('[file-watcher] Running queued auto-refresh');
582+
}
583+
nextMode = true;
584+
}
585+
}
586+
568587
async function shouldReindex(): Promise<boolean> {
569588
const indexPath = PATHS.keywordIndex;
570589
try {
@@ -726,6 +745,37 @@ async function main() {
726745
await server.connect(transport);
727746

728747
if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready');
748+
749+
// Auto-refresh: watch for file changes and trigger incremental reindex
750+
const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10);
751+
const debounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000;
752+
const stopWatcher = startFileWatcher({
753+
rootPath: ROOT_PATH,
754+
debounceMs,
755+
onChanged: () => {
756+
if (indexState.status === 'indexing') {
757+
autoRefreshQueued = true;
758+
if (process.env.CODEBASE_CONTEXT_DEBUG) {
759+
console.error('[file-watcher] Index in progress — queueing auto-refresh');
760+
}
761+
return;
762+
}
763+
if (process.env.CODEBASE_CONTEXT_DEBUG) {
764+
console.error('[file-watcher] Changes detected — incremental reindex starting');
765+
}
766+
void performIndexing(true);
767+
}
768+
});
769+
770+
process.once('exit', stopWatcher);
771+
process.once('SIGINT', () => {
772+
stopWatcher();
773+
process.exit(0);
774+
});
775+
process.once('SIGTERM', () => {
776+
stopWatcher();
777+
process.exit(0);
778+
});
729779
}
730780

731781
// Export server components for programmatic use

tests/file-watcher.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
// Give chokidar a moment to finish initializing before the first write
53+
await new Promise((resolve) => setTimeout(resolve, 100));
54+
// Write 5 files in quick succession — all within the debounce window
55+
for (let i = 0; i < 5; i++) {
56+
await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`);
57+
await new Promise((resolve) => setTimeout(resolve, 50));
58+
}
59+
// Wait for debounce to settle
60+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 400));
61+
expect(callCount).toBe(1);
62+
} finally {
63+
stop();
64+
}
65+
}, 8000);
66+
67+
it('stop() cancels a pending callback', async () => {
68+
const debounceMs = 500;
69+
let callCount = 0;
70+
71+
const stop = startFileWatcher({
72+
rootPath: tempDir,
73+
debounceMs,
74+
onChanged: () => { callCount++; },
75+
});
76+
77+
// Give chokidar a moment to finish initializing before the first write
78+
await new Promise((resolve) => setTimeout(resolve, 100));
79+
await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;');
80+
// Let chokidar detect the event (including awaitWriteFinish stabilityThreshold)
81+
// but stop before the debounce window expires.
82+
await new Promise((resolve) => setTimeout(resolve, 350));
83+
stop();
84+
// Wait past where debounce would have fired
85+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
86+
expect(callCount).toBe(0);
87+
}, 5000);
88+
});

0 commit comments

Comments
 (0)