-
Notifications
You must be signed in to change notification settings - Fork 10
feat(watcher): chokidar auto-refresh with debounced incremental reindex #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import chokidar from 'chokidar'; | ||
|
|
||
| export interface FileWatcherOptions { | ||
| rootPath: string; | ||
| /** ms after last change before triggering. Default: 2000 */ | ||
| debounceMs?: number; | ||
| /** Called once the debounce window expires after the last detected change */ | ||
| onChanged: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Watch rootPath for source file changes and call onChanged (debounced). | ||
| * Returns a stop() function that cancels the debounce timer and closes the watcher. | ||
| */ | ||
| export function startFileWatcher(opts: FileWatcherOptions): () => void { | ||
| const { rootPath, debounceMs = 2000, onChanged } = opts; | ||
| let debounceTimer: ReturnType<typeof setTimeout> | undefined; | ||
|
|
||
| const trigger = () => { | ||
| if (debounceTimer !== undefined) clearTimeout(debounceTimer); | ||
| debounceTimer = setTimeout(() => { | ||
| debounceTimer = undefined; | ||
| onChanged(); | ||
| }, debounceMs); | ||
| }; | ||
|
|
||
| const watcher = chokidar.watch(rootPath, { | ||
| ignored: ['**/node_modules/**', '**/.codebase-context/**', '**/.git/**', '**/dist/**'], | ||
| persistent: true, | ||
| ignoreInitial: true, | ||
| awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 } | ||
| }); | ||
|
|
||
| watcher | ||
| .on('add', trigger) | ||
| .on('change', trigger) | ||
| .on('unlink', trigger) | ||
| .on('error', (err: unknown) => console.error('[file-watcher] error:', err)); | ||
|
|
||
| return () => { | ||
| if (debounceTimer !== undefined) clearTimeout(debounceTimer); | ||
| void watcher.close(); | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,7 @@ import { | |
| } from './constants/codebase-context.js'; | ||
| import { appendMemoryFile } from './memory/store.js'; | ||
| import { handleCliCommand } from './cli.js'; | ||
| import { startFileWatcher } from './core/file-watcher.js'; | ||
| import { parseGitLogLineToMemory } from './memory/git-memory.js'; | ||
| import { | ||
| isComplementaryPatternCategory, | ||
|
|
@@ -726,6 +727,33 @@ async function main() { | |
| await server.connect(transport); | ||
|
|
||
| if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready'); | ||
|
|
||
| // Auto-refresh: watch for file changes and trigger incremental reindex | ||
| const debounceMs = parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10) || 2000; | ||
| const stopWatcher = startFileWatcher({ | ||
| rootPath: ROOT_PATH, | ||
| debounceMs, | ||
| onChanged: () => { | ||
| if (indexState.status === 'indexing') { | ||
| if (process.env.CODEBASE_CONTEXT_DEBUG) { | ||
| console.error('[file-watcher] Index in progress — skipping auto-refresh'); | ||
| } | ||
| return; | ||
|
Comment on lines
+756
to
+761
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This branch drops file-change events whenever Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
+756
to
+762
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. File changes during initial indexing are silently and permanently dropped
A simple mitigation is to track whether at least one change was skipped and trigger a follow-up incremental reindex after the initial run finishes: let pendingChanges = false;
onChanged: () => {
if (indexState.status === 'indexing') {
pendingChanges = true;
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error('[file-watcher] Index in progress — queuing auto-refresh');
}
return;
}
pendingChanges = false;
console.error('[file-watcher] Changes detected — incremental reindex starting');
performIndexing(true);
}Then, at the end of |
||
| console.error('[file-watcher] Changes detected — incremental reindex starting'); | ||
| performIndexing(true); | ||
| } | ||
| }); | ||
|
|
||
| process.once('exit', stopWatcher); | ||
| process.once('SIGINT', () => { | ||
| stopWatcher(); | ||
| process.exit(0); | ||
| }); | ||
| process.once('SIGTERM', () => { | ||
| stopWatcher(); | ||
| process.exit(0); | ||
| }); | ||
| } | ||
|
|
||
| // Export server components for programmatic use | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | ||
| import { promises as fs } from 'fs'; | ||
| import path from 'path'; | ||
| import os from 'os'; | ||
| import { startFileWatcher } from '../src/core/file-watcher.js'; | ||
|
|
||
| describe('FileWatcher', () => { | ||
| let tempDir: string; | ||
|
|
||
| beforeEach(async () => { | ||
| tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-watcher-test-')); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await fs.rm(tempDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('triggers onChanged after debounce window', async () => { | ||
| const debounceMs = 400; | ||
| let callCount = 0; | ||
|
|
||
| const stop = startFileWatcher({ | ||
| rootPath: tempDir, | ||
| debounceMs, | ||
| onChanged: () => { callCount++; }, | ||
| }); | ||
|
|
||
| try { | ||
| // Give chokidar a moment to finish initializing before the first write | ||
| await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| await fs.writeFile(path.join(tempDir, 'test.ts'), 'export const x = 1;'); | ||
| // Wait for chokidar to pick up the event (including awaitWriteFinish stabilityThreshold) | ||
| // + debounce window + OS scheduling slack | ||
| await new Promise((resolve) => setTimeout(resolve, debounceMs + 1000)); | ||
| expect(callCount).toBe(1); | ||
| } finally { | ||
| stop(); | ||
| } | ||
| }, 8000); | ||
|
|
||
| it('debounces rapid changes into a single callback', async () => { | ||
| const debounceMs = 300; | ||
| let callCount = 0; | ||
|
|
||
| const stop = startFileWatcher({ | ||
| rootPath: tempDir, | ||
| debounceMs, | ||
| onChanged: () => { callCount++; }, | ||
| }); | ||
|
|
||
| try { | ||
| // Write 5 files in quick succession — all within the debounce window | ||
| for (let i = 0; i < 5; i++) { | ||
| await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`); | ||
| await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| } | ||
| // Wait for debounce to settle | ||
| await new Promise((resolve) => setTimeout(resolve, debounceMs + 400)); | ||
| expect(callCount).toBe(1); | ||
| } finally { | ||
| stop(); | ||
| } | ||
| }, 8000); | ||
|
|
||
| it('stop() cancels a pending callback', async () => { | ||
| const debounceMs = 500; | ||
| let callCount = 0; | ||
|
|
||
| const stop = startFileWatcher({ | ||
| rootPath: tempDir, | ||
| debounceMs, | ||
| onChanged: () => { callCount++; }, | ||
| }); | ||
|
|
||
| await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;'); | ||
| // Let chokidar detect the event but stop before debounce fires | ||
| await new Promise((resolve) => setTimeout(resolve, 150)); | ||
| stop(); | ||
|
Comment on lines
+79
to
+83
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test doesn't actually test debounce cancellation The test waits 150 ms before calling
To actually test cancellation of a live debounce timer, the wait before await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;');
// Wait long enough for chokidar's awaitWriteFinish (200 ms) to fire trigger(),
// but short enough that the debounce (500 ms) hasn't elapsed yet.
await new Promise((resolve) => setTimeout(resolve, 300));
stop();
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
expect(callCount).toBe(0); |
||
| // Wait past where debounce would have fired | ||
| await new Promise((resolve) => setTimeout(resolve, debounceMs + 200)); | ||
| expect(callCount).toBe(0); | ||
| }, 5000); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseInt || 2000prevents setting debounce to0parseInt('0', 10)returns0, which is falsy, so0 || 2000evaluates to2000. AnyCODEBASE_CONTEXT_DEBOUNCE_MS=0(or any value that parses to0) silently falls back to the default. If near-instant refresh is ever needed for testing or tight workflows, this would be surprising.A more explicit guard avoids the pitfall: