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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user