Преглед изворни кода

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.
Colby McHenry пре 2 месеци
родитељ
комит
3da5c96a0b
6 измењених фајлова са 599 додато и 30 уклоњено
  1. 30 24
      README.md
  2. 291 0
      __tests__/watcher.test.ts
  3. 53 0
      src/index.ts
  4. 27 0
      src/mcp/index.ts
  5. 2 6
      src/sync/index.ts
  6. 196 0
      src/sync/watcher.ts

+ 30 - 24
README.md

@@ -146,8 +146,8 @@ One tool call returns everything Claude needs—entry points, related symbols, a
 </td>
 </td>
 <td width="33%" valign="top">
 <td width="33%" valign="top">
 
 
-### 🔍 Semantic Search
-Find code by meaning, not just text. Search for "authentication" and find `login`, `validateToken`, `AuthService`—even with different naming conventions.
+### 🔍 Full-Text Search
+Find code by name across your entire codebase instantly. Search for "auth" and find `authenticate`, `AuthService`, `validateToken` — powered by FTS5.
 
 
 </td>
 </td>
 <td width="33%" valign="top">
 <td width="33%" valign="top">
@@ -173,7 +173,7 @@ No data leaves your machine. No API keys. No external services. Everything runs
 <td width="33%" valign="top">
 <td width="33%" valign="top">
 
 
 ### ⚡ Always Fresh
 ### ⚡ Always Fresh
-Claude Code hooks automatically sync the index as you work. Your code intelligence is always up to date.
+The MCP server watches your files and auto-syncs on save — debounced, filtered to source files only, zero config. Your code intelligence is always up to date.
 
 
 </td>
 </td>
 </tr>
 </tr>
@@ -181,6 +181,12 @@ Claude Code hooks automatically sync the index as you work. Your code intelligen
 
 
 ---
 ---
 
 
+## 📋 Requirements
+
+- **Node.js >= 18.0.0**
+
+---
+
 ## 🎯 Quick Start
 ## 🎯 Quick Start
 
 
 ### 1. Run the Installer
 ### 1. Run the Installer
@@ -190,11 +196,10 @@ npx @colbymchenry/codegraph
 ```
 ```
 
 
 The interactive installer will:
 The interactive installer will:
-- Prompt to install `codegraph` globally (needed for hooks & MCP server to work)
+- Prompt to install `codegraph` globally (needed for the MCP server to work)
 - Configure the MCP server in `~/.claude.json`
 - Configure the MCP server in `~/.claude.json`
 - Set up auto-allow permissions for CodeGraph tools
 - Set up auto-allow permissions for CodeGraph tools
 - Add global instructions to `~/.claude/CLAUDE.md` (teaches Claude how to use CodeGraph)
 - Add global instructions to `~/.claude/CLAUDE.md` (teaches Claude how to use CodeGraph)
-- Install Claude Code hooks for automatic index syncing
 - Optionally initialize your current project
 - Optionally initialize your current project
 
 
 ### 2. Restart Claude Code
 ### 2. Restart Claude Code
@@ -298,12 +303,6 @@ At the start of a session, ask the user if they'd like to initialize CodeGraph:
 
 
 ---
 ---
 
 
-## 📋 Requirements
-
-- Node.js >= 18.0.0
-
----
-
 ## 💻 CLI Usage
 ## 💻 CLI Usage
 
 
 ```bash
 ```bash
@@ -334,13 +333,12 @@ npx @colbymchenry/codegraph       # Run via npx (no global install needed)
 ```
 ```
 
 
 The installer will:
 The installer will:
-1. Prompt to install `codegraph` globally (needed for hooks & MCP server)
+1. Prompt to install `codegraph` globally (needed for the MCP server)
 2. Ask for installation location (global `~/.claude` or local `./.claude`)
 2. Ask for installation location (global `~/.claude` or local `./.claude`)
 3. Optionally set up auto-allow permissions
 3. Optionally set up auto-allow permissions
 4. Configure the MCP server in `claude.json`
 4. Configure the MCP server in `claude.json`
 5. Add global instructions to `~/.claude/CLAUDE.md` (teaches Claude how to use CodeGraph)
 5. Add global instructions to `~/.claude/CLAUDE.md` (teaches Claude how to use CodeGraph)
-6. Install Claude Code hooks for automatic index syncing
-7. For local installs: initialize and index the current project
+6. For local installs: initialize and index the current project
 
 
 ### `codegraph init [path]`
 ### `codegraph init [path]`
 
 
@@ -422,7 +420,7 @@ codegraph files --json                    # Output as JSON
 
 
 ### `codegraph context <task>`
 ### `codegraph context <task>`
 
 
-Build relevant code context for a task. Uses semantic search to find entry points, then expands through the graph to find related code.
+Build relevant code context for a task. Uses full-text search to find entry points, then expands through the graph to find related code.
 
 
 ```bash
 ```bash
 codegraph context "fix checkout bug"
 codegraph context "fix checkout bug"
@@ -589,9 +587,13 @@ const context = await cg.buildContext('fix login bug', {
 // Get impact radius
 // Get impact radius
 const impact = cg.getImpactRadius(node.id, 2);
 const impact = cg.getImpactRadius(node.id, 2);
 
 
-// Sync changes
+// Sync changes manually
 const syncResult = await cg.sync();
 const syncResult = await cg.sync();
 
 
+// Or watch for changes and auto-sync
+cg.watch(); // uses native OS file events, debounced
+cg.unwatch(); // stop watching
+
 // Clean up
 // Clean up
 cg.close();
 cg.close();
 ```
 ```
@@ -615,7 +617,6 @@ All data is stored in a local SQLite database (`.codegraph/codegraph.db`):
 - **edges** table: Relationships between nodes
 - **edges** table: Relationships between nodes
 - **files** table: File tracking for incremental updates
 - **files** table: File tracking for incremental updates
 - **unresolved_refs** table: References pending resolution
 - **unresolved_refs** table: References pending resolution
-- **vectors** table: Embeddings stored as BLOBs for semantic search
 - **nodes_fts**: FTS5 virtual table for full-text search
 - **nodes_fts**: FTS5 virtual table for full-text search
 - **schema_versions** table: Schema version tracking
 - **schema_versions** table: Schema version tracking
 - **project_metadata** table: Project-level key-value metadata
 - **project_metadata** table: Project-level key-value metadata
@@ -629,13 +630,17 @@ After extraction, CodeGraph resolves references:
 3. Link class inheritance and interface implementations
 3. Link class inheritance and interface implementations
 4. Apply framework-specific patterns (Express routes, etc.)
 4. Apply framework-specific patterns (Express routes, etc.)
 
 
-### 4. Semantic Search
+### 4. File Watching
+
+The MCP server automatically watches your project for file changes using native OS file events (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows):
 
 
-CodeGraph uses local embeddings (via [@xenova/transformers](https://github.com/xenova/transformers.js)) to enable semantic search:
+1. File saves are detected instantly via OS-level events — no polling
+2. Changes are **debounced** (2-second quiet window) so rapid saves don't thrash
+3. Only source files matching your include/exclude patterns trigger a sync
+4. Build outputs, node_modules, and `.codegraph/` changes are ignored
+5. Incremental sync runs automatically — only changed files are re-parsed
 
 
-1. Code symbols are embedded using a transformer model
-2. Queries are embedded and compared using cosine similarity
-3. Results are ranked by relevance
+No configuration needed. The graph stays fresh as you code.
 
 
 ### 5. Graph Queries
 ### 5. Graph Queries
 
 
@@ -650,7 +655,7 @@ The graph structure enables powerful queries:
 
 
 When you request context for a task:
 When you request context for a task:
 
 
-1. Semantic search finds relevant entry points
+1. FTS search finds relevant entry points
 2. Graph traversal expands to related code
 2. Graph traversal expands to related code
 3. Code snippets are extracted
 3. Code snippets are extracted
 4. Results are formatted for AI consumption
 4. Results are formatted for AI consumption
@@ -730,7 +735,8 @@ Run `codegraph init` in your project directory first.
 
 
 ### Missing symbols in search
 ### Missing symbols in search
 
 
-- Run `codegraph sync` to pick up recent changes
+- The MCP server auto-syncs on file changes — wait a couple seconds after saving
+- Run `codegraph sync` manually if needed
 - Check if the file's language is supported
 - Check if the file's language is supported
 - Verify the file isn't excluded by config patterns
 - Verify the file isn't excluded by config patterns
 
 

+ 291 - 0
__tests__/watcher.test.ts

@@ -0,0 +1,291 @@
+/**
+ * FileWatcher Tests
+ *
+ * Tests for the file watcher that auto-syncs on changes.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { FileWatcher } from '../src/sync/watcher';
+import type { CodeGraphConfig } from '../src/types';
+import CodeGraph from '../src/index';
+
+/**
+ * Helper to wait for a condition with timeout
+ */
+function waitFor(
+  condition: () => boolean,
+  timeoutMs = 10000,
+  intervalMs = 100
+): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const start = Date.now();
+    const check = () => {
+      if (condition()) return resolve();
+      if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
+      setTimeout(check, intervalMs);
+    };
+    check();
+  });
+}
+
+describe('FileWatcher', () => {
+  let testDir: string;
+
+  const baseConfig: CodeGraphConfig = {
+    version: 1,
+    rootDir: '.',
+    include: ['**/*.ts', '**/*.js'],
+    exclude: ['**/node_modules/**', '**/dist/**'],
+    languages: [],
+    frameworks: [],
+    maxFileSize: 1024 * 1024,
+    extractDocstrings: true,
+    trackCallSites: true,
+  };
+
+  beforeEach(() => {
+    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watcher-'));
+    // Create a source file so the directory isn't empty
+    const srcDir = path.join(testDir, 'src');
+    fs.mkdirSync(srcDir);
+    fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
+  });
+
+  afterEach(() => {
+    if (fs.existsSync(testDir)) {
+      fs.rmSync(testDir, { recursive: true, force: true });
+    }
+  });
+
+  describe('start/stop lifecycle', () => {
+    it('should start and stop without errors', () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn);
+
+      const started = watcher.start();
+      expect(started).toBe(true);
+      expect(watcher.isActive()).toBe(true);
+
+      watcher.stop();
+      expect(watcher.isActive()).toBe(false);
+    });
+
+    it('should be idempotent on double start', () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn);
+
+      expect(watcher.start()).toBe(true);
+      expect(watcher.start()).toBe(true); // Should not throw
+      expect(watcher.isActive()).toBe(true);
+
+      watcher.stop();
+    });
+
+    it('should be idempotent on double stop', () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn);
+
+      watcher.start();
+      watcher.stop();
+      watcher.stop(); // Should not throw
+      expect(watcher.isActive()).toBe(false);
+    });
+  });
+
+  describe('debounced sync', () => {
+    it('should trigger sync after file change', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn, { debounceMs: 200 });
+
+      watcher.start();
+
+      // Create a new file
+      fs.writeFileSync(path.join(testDir, 'src', 'new.ts'), 'export const y = 2;');
+
+      // Wait for debounced sync to fire
+      await waitFor(() => syncFn.mock.calls.length > 0, 5000);
+      expect(syncFn).toHaveBeenCalled();
+
+      watcher.stop();
+    });
+
+    it('should debounce rapid changes into a single sync', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn, { debounceMs: 500 });
+
+      watcher.start();
+
+      // Rapid-fire changes
+      for (let i = 0; i < 5; i++) {
+        fs.writeFileSync(
+          path.join(testDir, 'src', `file${i}.ts`),
+          `export const v${i} = ${i};`
+        );
+        await new Promise((r) => setTimeout(r, 50));
+      }
+
+      // Wait for the single debounced sync
+      await waitFor(() => syncFn.mock.calls.length > 0, 5000);
+
+      // Should have been called once (debounced), not 5 times
+      expect(syncFn.mock.calls.length).toBe(1);
+
+      watcher.stop();
+    });
+  });
+
+  describe('filtering', () => {
+    it('should ignore files not matching include patterns', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn, { debounceMs: 200 });
+
+      watcher.start();
+
+      // Let watcher settle — fs.watch may fire residual events from beforeEach
+      await new Promise((r) => setTimeout(r, 400));
+      syncFn.mockClear();
+
+      // Create a file that doesn't match include patterns
+      fs.writeFileSync(path.join(testDir, 'src', 'readme.md'), '# Hello');
+
+      // Wait a bit longer than debounce — sync should NOT trigger
+      await new Promise((r) => setTimeout(r, 500));
+      expect(syncFn).not.toHaveBeenCalled();
+
+      watcher.stop();
+    });
+
+    it('should ignore .codegraph directory changes', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn, { debounceMs: 200 });
+
+      watcher.start();
+
+      // Let watcher settle — fs.watch may fire residual events from beforeEach
+      await new Promise((r) => setTimeout(r, 400));
+      syncFn.mockClear();
+
+      // Simulate a .codegraph directory change
+      const cgDir = path.join(testDir, '.codegraph');
+      fs.mkdirSync(cgDir, { recursive: true });
+      fs.writeFileSync(path.join(cgDir, 'db.sqlite'), 'fake');
+
+      // Wait — sync should NOT trigger
+      await new Promise((r) => setTimeout(r, 500));
+      expect(syncFn).not.toHaveBeenCalled();
+
+      watcher.stop();
+    });
+  });
+
+  describe('callbacks', () => {
+    it('should call onSyncComplete after successful sync', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 2, durationMs: 50 });
+      const onSyncComplete = vi.fn();
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn, {
+        debounceMs: 200,
+        onSyncComplete,
+      });
+
+      watcher.start();
+
+      fs.writeFileSync(path.join(testDir, 'src', 'test.ts'), 'export const z = 3;');
+
+      await waitFor(() => onSyncComplete.mock.calls.length > 0, 5000);
+      expect(onSyncComplete).toHaveBeenCalledWith({ filesChanged: 2, durationMs: 50 });
+
+      watcher.stop();
+    });
+
+    it('should call onSyncError when sync throws', async () => {
+      const syncFn = vi.fn().mockRejectedValue(new Error('sync failed'));
+      const onSyncError = vi.fn();
+      const watcher = new FileWatcher(testDir, baseConfig, syncFn, {
+        debounceMs: 200,
+        onSyncError,
+      });
+
+      watcher.start();
+
+      fs.writeFileSync(path.join(testDir, 'src', 'test.ts'), 'export const z = 3;');
+
+      await waitFor(() => onSyncError.mock.calls.length > 0, 5000);
+      expect(onSyncError).toHaveBeenCalled();
+      expect(onSyncError.mock.calls[0]![0]).toBeInstanceOf(Error);
+
+      watcher.stop();
+    });
+  });
+
+  describe('CodeGraph integration', () => {
+    let cg: CodeGraph;
+
+    afterEach(() => {
+      if (cg) cg.close();
+    });
+
+    it('should watch and unwatch via CodeGraph API', async () => {
+      cg = CodeGraph.initSync(testDir, {
+        config: { include: ['**/*.ts'], exclude: [] },
+      });
+      await cg.indexAll();
+
+      expect(cg.isWatching()).toBe(false);
+
+      const started = cg.watch({ debounceMs: 200 });
+      expect(started).toBe(true);
+      expect(cg.isWatching()).toBe(true);
+
+      cg.unwatch();
+      expect(cg.isWatching()).toBe(false);
+    });
+
+    it('should stop watching on close', async () => {
+      cg = CodeGraph.initSync(testDir, {
+        config: { include: ['**/*.ts'], exclude: [] },
+      });
+      await cg.indexAll();
+
+      cg.watch({ debounceMs: 200 });
+      expect(cg.isWatching()).toBe(true);
+
+      cg.close();
+      // After close, isWatching should be false
+      // (we can't call isWatching after close since DB is closed,
+      //  but we verify no errors are thrown)
+    });
+
+    it('should auto-sync when files change while watching', async () => {
+      cg = CodeGraph.initSync(testDir, {
+        config: { include: ['**/*.ts'], exclude: [] },
+      });
+      await cg.indexAll();
+
+      const initialStats = cg.getStats();
+      const initialNodes = initialStats.nodeCount;
+
+      cg.watch({ debounceMs: 300 });
+
+      // Add a new file with a function
+      fs.writeFileSync(
+        path.join(testDir, 'src', 'added.ts'),
+        'export function added() { return 42; }'
+      );
+
+      // Wait for auto-sync to pick it up
+      await waitFor(() => {
+        const stats = cg.getStats();
+        return stats.nodeCount > initialNodes;
+      }, 10000);
+
+      // The new function should be in the graph
+      const results = cg.searchNodes('added');
+      expect(results.length).toBeGreaterThan(0);
+
+      cg.unwatch();
+    });
+  });
+});

+ 53 - 0
src/index.ts

@@ -48,6 +48,7 @@ import {
 import { GraphTraverser, GraphQueryManager } from './graph';
 import { GraphTraverser, GraphQueryManager } from './graph';
 import { ContextBuilder, createContextBuilder } from './context';
 import { ContextBuilder, createContextBuilder } from './context';
 import { Mutex, FileLock } from './utils';
 import { Mutex, FileLock } from './utils';
+import { FileWatcher, WatchOptions } from './sync';
 
 
 // Re-export types for consumers
 // Re-export types for consumers
 export * from './types';
 export * from './types';
@@ -77,6 +78,7 @@ export {
   defaultLogger,
   defaultLogger,
 } from './errors';
 } from './errors';
 export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
 export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
+export { FileWatcher, WatchOptions } from './sync';
 export { MCPServer } from './mcp';
 export { MCPServer } from './mcp';
 
 
 /**
 /**
@@ -140,6 +142,9 @@ export class CodeGraph {
   // File lock for preventing concurrent writes across processes (CLI, MCP, git hooks)
   // File lock for preventing concurrent writes across processes (CLI, MCP, git hooks)
   private fileLock: FileLock;
   private fileLock: FileLock;
 
 
+  // File watcher for auto-sync on file changes
+  private watcher: FileWatcher | null = null;
+
   private constructor(
   private constructor(
     db: DatabaseConnection,
     db: DatabaseConnection,
     queries: QueryBuilder,
     queries: QueryBuilder,
@@ -319,6 +324,7 @@ export class CodeGraph {
    * Close the CodeGraph instance and release resources
    * Close the CodeGraph instance and release resources
    */
    */
   close(): void {
   close(): void {
+    this.unwatch();
     // Release file lock if held
     // Release file lock if held
     this.fileLock.release();
     this.fileLock.release();
     this.db.close();
     this.db.close();
@@ -491,6 +497,53 @@ export class CodeGraph {
     return this.indexMutex.isLocked();
     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
    * Get files that have changed since last index
    */
    */

+ 27 - 0
src/mcp/index.ts

@@ -116,6 +116,7 @@ export class MCPServer {
     try {
     try {
       this.cg = await CodeGraph.open(resolvedRoot);
       this.cg = await CodeGraph.open(resolvedRoot);
       this.toolHandler.setDefaultCodeGraph(this.cg);
       this.toolHandler.setDefaultCodeGraph(this.cg);
+      this.startWatching();
     } catch (err) {
     } catch (err) {
       // Log the error so transient failures are diagnosable (see issue #47)
       // Log the error so transient failures are diagnosable (see issue #47)
       const msg = err instanceof Error ? err.message : String(err);
       const msg = err instanceof Error ? err.message : String(err);
@@ -147,11 +148,37 @@ export class MCPServer {
       this.cg = CodeGraph.openSync(resolvedRoot);
       this.cg = CodeGraph.openSync(resolvedRoot);
       this.projectPath = resolvedRoot;
       this.projectPath = resolvedRoot;
       this.toolHandler.setDefaultCodeGraph(this.cg);
       this.toolHandler.setDefaultCodeGraph(this.cg);
+      this.startWatching();
     } catch {
     } catch {
       // Still failing — will retry on next tool call
       // 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
    * Stop the server
    */
    */

+ 2 - 6
src/sync/index.ts

@@ -4,14 +4,10 @@
  * Provides synchronization functionality for keeping the code graph
  * Provides synchronization functionality for keeping the code graph
  * up-to-date with file system changes.
  * 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:
  * Components:
+ * - FileWatcher: Debounced fs.watch that auto-triggers sync on file changes
  * - Content hashing for change detection (in extraction module)
  * - Content hashing for change detection (in extraction module)
  * - Incremental reindexing (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 - 0
src/sync/watcher.ts

@@ -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();
+      }
+    }
+  }
+}