feat: Add file watcher with debounced auto-sync and comprehensive test coverage

Addresses the need for automatic graph synchronization on file changes. Implements FileWatcher using native OS file events (FSEvents/inotify/ReadDirectoryChangesW) with 2-second debouncing to prevent thrashing on rapid saves. Filters changes against include/exclude patterns and ignores .codegraph directory modifications. Integrates with CodeGraph API (watch/unwatch/isWatching methods) and MCP server for automatic activation. Updates documentation to reflect shift from semantic to full-text search and removal of manual hook installation requirements.
This commit is contained in:
Colby McHenry
2026-04-07 16:02:15 -05:00
parent 453c39d774
commit 3da5c96a0b
6 changed files with 599 additions and 30 deletions
+53
View File
@@ -48,6 +48,7 @@ import {
import { GraphTraverser, GraphQueryManager } from './graph';
import { ContextBuilder, createContextBuilder } from './context';
import { Mutex, FileLock } from './utils';
import { FileWatcher, WatchOptions } from './sync';
// Re-export types for consumers
export * from './types';
@@ -77,6 +78,7 @@ export {
defaultLogger,
} from './errors';
export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
export { FileWatcher, WatchOptions } from './sync';
export { MCPServer } from './mcp';
/**
@@ -140,6 +142,9 @@ export class CodeGraph {
// File lock for preventing concurrent writes across processes (CLI, MCP, git hooks)
private fileLock: FileLock;
// File watcher for auto-sync on file changes
private watcher: FileWatcher | null = null;
private constructor(
db: DatabaseConnection,
queries: QueryBuilder,
@@ -319,6 +324,7 @@ export class CodeGraph {
* Close the CodeGraph instance and release resources
*/
close(): void {
this.unwatch();
// Release file lock if held
this.fileLock.release();
this.db.close();
@@ -491,6 +497,53 @@ export class CodeGraph {
return this.indexMutex.isLocked();
}
// ===========================================================================
// File Watching
// ===========================================================================
/**
* Start watching for file changes and auto-syncing.
*
* Uses native OS file events (FSEvents on macOS, inotify on Linux 19+,
* ReadDirectoryChangesW on Windows) with debouncing to avoid thrashing.
*
* @param options - Watch options (debounce delay, callbacks)
* @returns true if watching started successfully
*/
watch(options: WatchOptions = {}): boolean {
if (this.watcher?.isActive()) return true;
this.watcher = new FileWatcher(
this.projectRoot,
this.config,
async () => {
const result = await this.sync();
const filesChanged = result.filesAdded + result.filesModified + result.filesRemoved;
return { filesChanged, durationMs: result.durationMs };
},
options
);
return this.watcher.start();
}
/**
* Stop watching for file changes.
*/
unwatch(): void {
if (this.watcher) {
this.watcher.stop();
this.watcher = null;
}
}
/**
* Check if the file watcher is active.
*/
isWatching(): boolean {
return this.watcher?.isActive() ?? false;
}
/**
* Get files that have changed since last index
*/
+27
View File
@@ -116,6 +116,7 @@ export class MCPServer {
try {
this.cg = await CodeGraph.open(resolvedRoot);
this.toolHandler.setDefaultCodeGraph(this.cg);
this.startWatching();
} catch (err) {
// Log the error so transient failures are diagnosable (see issue #47)
const msg = err instanceof Error ? err.message : String(err);
@@ -147,11 +148,37 @@ export class MCPServer {
this.cg = CodeGraph.openSync(resolvedRoot);
this.projectPath = resolvedRoot;
this.toolHandler.setDefaultCodeGraph(this.cg);
this.startWatching();
} catch {
// Still failing — will retry on next tool call
}
}
/**
* Start file watching on the active CodeGraph instance.
* Logs sync activity to stderr for diagnostics.
*/
private startWatching(): void {
if (!this.cg) return;
const started = this.cg.watch({
onSyncComplete: (result) => {
if (result.filesChanged > 0) {
process.stderr.write(
`[CodeGraph MCP] Auto-synced ${result.filesChanged} file(s) in ${result.durationMs}ms\n`
);
}
},
onSyncError: (err) => {
process.stderr.write(`[CodeGraph MCP] Auto-sync error: ${err.message}\n`);
},
});
if (started) {
process.stderr.write('[CodeGraph MCP] File watcher active — graph will auto-sync on changes\n');
}
}
/**
* Stop the server
*/
+2 -6
View File
@@ -4,14 +4,10 @@
* Provides synchronization functionality for keeping the code graph
* up-to-date with file system changes.
*
* Note: Git hooks functionality has been removed. CodeGraph sync is now
* triggered through codegraph's Claude Code hooks integration instead.
*
* Components:
* - FileWatcher: Debounced fs.watch that auto-triggers sync on file changes
* - Content hashing for change detection (in extraction module)
* - Incremental reindexing (in extraction module)
*/
// This module is kept for potential future sync-related exports
// Currently all sync functionality is in the extraction module
export {};
export { FileWatcher, WatchOptions } from './watcher';
+196
View File
@@ -0,0 +1,196 @@
/**
* File Watcher
*
* Watches the project directory for file changes and triggers
* debounced sync operations to keep the code graph up-to-date.
*
* Uses Node.js native fs.watch with recursive mode (macOS FSEvents,
* Windows ReadDirectoryChangesW, Linux inotify on Node 19+).
*/
import * as fs from 'fs';
import { CodeGraphConfig } from '../types';
import { shouldIncludeFile } from '../extraction';
import { logDebug, logWarn } from '../errors';
import { normalizePath } from '../utils';
/**
* Options for the file watcher
*/
export interface WatchOptions {
/**
* Debounce delay in milliseconds.
* After the last file change, wait this long before triggering sync.
* Default: 2000ms
*/
debounceMs?: number;
/**
* Callback when a sync completes (for logging/diagnostics).
*/
onSyncComplete?: (result: { filesChanged: number; durationMs: number }) => void;
/**
* Callback when a sync errors (for logging/diagnostics).
*/
onSyncError?: (error: Error) => void;
}
/**
* FileWatcher monitors a project directory for changes and triggers
* debounced sync operations via a provided callback.
*
* Design goals:
* - Minimal resource usage (native OS file events, no polling)
* - Debounced to avoid thrashing on rapid saves
* - Filters against CodeGraph include/exclude patterns
* - Ignores .codegraph/ directory changes
*/
export class FileWatcher {
private watcher: fs.FSWatcher | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private hasChanges = false;
private syncing = false;
private stopped = false;
private readonly projectRoot: string;
private readonly config: CodeGraphConfig;
private readonly debounceMs: number;
private readonly syncFn: () => Promise<{ filesChanged: number; durationMs: number }>;
private readonly onSyncComplete?: WatchOptions['onSyncComplete'];
private readonly onSyncError?: WatchOptions['onSyncError'];
constructor(
projectRoot: string,
config: CodeGraphConfig,
syncFn: () => Promise<{ filesChanged: number; durationMs: number }>,
options: WatchOptions = {}
) {
this.projectRoot = projectRoot;
this.config = config;
this.syncFn = syncFn;
this.debounceMs = options.debounceMs ?? 2000;
this.onSyncComplete = options.onSyncComplete;
this.onSyncError = options.onSyncError;
}
/**
* Start watching for file changes.
* Returns true if watching started successfully, false otherwise.
*/
start(): boolean {
if (this.watcher) return true; // Already watching
this.stopped = false;
try {
this.watcher = fs.watch(
this.projectRoot,
{ recursive: true },
(_eventType, filename) => {
if (!filename || this.stopped) return;
// Normalize path separators
const normalized = normalizePath(filename);
// Ignore .codegraph/ directory changes (our own DB writes)
if (
normalized === '.codegraph' ||
normalized.startsWith('.codegraph/') ||
normalized.startsWith('.codegraph\\')
) {
return;
}
// Filter against include/exclude patterns
if (!shouldIncludeFile(normalized, this.config)) {
return;
}
logDebug('File change detected', { file: normalized });
this.hasChanges = true;
this.scheduleSync();
}
);
// Handle watcher errors gracefully
this.watcher.on('error', (err) => {
logWarn('File watcher error', { error: String(err) });
// Don't crash — watcher may recover or user can restart
});
logDebug('File watcher started', { projectRoot: this.projectRoot, debounceMs: this.debounceMs });
return true;
} catch (err) {
// Recursive watch not supported (e.g., Linux < Node 19)
logWarn('Could not start file watcher — recursive fs.watch not supported on this platform', { error: String(err) });
return false;
}
}
/**
* Stop watching for file changes.
*/
stop(): void {
this.stopped = true;
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
this.hasChanges = false;
logDebug('File watcher stopped');
}
/**
* Whether the watcher is currently active.
*/
isActive(): boolean {
return this.watcher !== null && !this.stopped;
}
/**
* Schedule a debounced sync.
*/
private scheduleSync(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.flush();
}, this.debounceMs);
}
/**
* Flush pending changes by running sync.
*/
private async flush(): Promise<void> {
// If already syncing, the post-sync check will re-trigger
if (this.syncing || this.stopped) return;
this.hasChanges = false;
this.syncing = true;
try {
const result = await this.syncFn();
this.onSyncComplete?.(result);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
logWarn('Watch sync failed', { error: error.message });
this.onSyncError?.(error);
} finally {
this.syncing = false;
// If new changes arrived during sync, schedule another
if (this.hasChanges && !this.stopped) {
this.scheduleSync();
}
}
}
}