watcher.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /**
  2. * File Watcher
  3. *
  4. * Watches the project directory for file changes and triggers
  5. * debounced sync operations to keep the code graph up-to-date.
  6. *
  7. * Uses Node.js native fs.watch with recursive mode (macOS FSEvents,
  8. * Windows ReadDirectoryChangesW, Linux inotify on Node 19+).
  9. */
  10. import * as fs from 'fs';
  11. import { CodeGraphConfig } from '../types';
  12. import { shouldIncludeFile } from '../extraction';
  13. import { logDebug, logWarn } from '../errors';
  14. import { normalizePath } from '../utils';
  15. import { watchDisabledReason } from './watch-policy';
  16. /**
  17. * Options for the file watcher
  18. */
  19. export interface WatchOptions {
  20. /**
  21. * Debounce delay in milliseconds.
  22. * After the last file change, wait this long before triggering sync.
  23. * Default: 2000ms
  24. */
  25. debounceMs?: number;
  26. /**
  27. * Callback when a sync completes (for logging/diagnostics).
  28. */
  29. onSyncComplete?: (result: { filesChanged: number; durationMs: number }) => void;
  30. /**
  31. * Callback when a sync errors (for logging/diagnostics).
  32. */
  33. onSyncError?: (error: Error) => void;
  34. }
  35. /**
  36. * FileWatcher monitors a project directory for changes and triggers
  37. * debounced sync operations via a provided callback.
  38. *
  39. * Design goals:
  40. * - Minimal resource usage (native OS file events, no polling)
  41. * - Debounced to avoid thrashing on rapid saves
  42. * - Filters against CodeGraph include/exclude patterns
  43. * - Ignores .codegraph/ directory changes
  44. */
  45. export class FileWatcher {
  46. private watcher: fs.FSWatcher | null = null;
  47. private debounceTimer: ReturnType<typeof setTimeout> | null = null;
  48. private hasChanges = false;
  49. private syncing = false;
  50. private stopped = false;
  51. private readonly projectRoot: string;
  52. private readonly config: CodeGraphConfig;
  53. private readonly debounceMs: number;
  54. private readonly syncFn: () => Promise<{ filesChanged: number; durationMs: number }>;
  55. private readonly onSyncComplete?: WatchOptions['onSyncComplete'];
  56. private readonly onSyncError?: WatchOptions['onSyncError'];
  57. constructor(
  58. projectRoot: string,
  59. config: CodeGraphConfig,
  60. syncFn: () => Promise<{ filesChanged: number; durationMs: number }>,
  61. options: WatchOptions = {}
  62. ) {
  63. this.projectRoot = projectRoot;
  64. this.config = config;
  65. this.syncFn = syncFn;
  66. this.debounceMs = options.debounceMs ?? 2000;
  67. this.onSyncComplete = options.onSyncComplete;
  68. this.onSyncError = options.onSyncError;
  69. }
  70. /**
  71. * Start watching for file changes.
  72. * Returns true if watching started successfully, false otherwise.
  73. */
  74. start(): boolean {
  75. if (this.watcher) return true; // Already watching
  76. this.stopped = false;
  77. // Some environments make recursive fs.watch unusable — most notably WSL2
  78. // /mnt/ drives, where setup blocks long enough to break MCP startup
  79. // handshakes (issue #199). Skip watching there; callers fall back to
  80. // manual `codegraph sync` or the git sync hooks.
  81. const disabledReason = watchDisabledReason(this.projectRoot);
  82. if (disabledReason) {
  83. logDebug('File watcher disabled', { reason: disabledReason, projectRoot: this.projectRoot });
  84. return false;
  85. }
  86. try {
  87. this.watcher = fs.watch(
  88. this.projectRoot,
  89. { recursive: true },
  90. (_eventType, filename) => {
  91. if (!filename || this.stopped) return;
  92. // Normalize path separators
  93. const normalized = normalizePath(filename);
  94. // Ignore .codegraph/ directory changes (our own DB writes)
  95. if (
  96. normalized === '.codegraph' ||
  97. normalized.startsWith('.codegraph/') ||
  98. normalized.startsWith('.codegraph\\')
  99. ) {
  100. return;
  101. }
  102. // Filter against include/exclude patterns
  103. if (!shouldIncludeFile(normalized, this.config)) {
  104. return;
  105. }
  106. logDebug('File change detected', { file: normalized });
  107. this.hasChanges = true;
  108. this.scheduleSync();
  109. }
  110. );
  111. // Handle watcher errors gracefully
  112. this.watcher.on('error', (err) => {
  113. logWarn('File watcher error', { error: String(err) });
  114. // Don't crash — watcher may recover or user can restart
  115. });
  116. logDebug('File watcher started', { projectRoot: this.projectRoot, debounceMs: this.debounceMs });
  117. return true;
  118. } catch (err) {
  119. // Recursive watch not supported (e.g., Linux < Node 19)
  120. logWarn('Could not start file watcher — recursive fs.watch not supported on this platform', { error: String(err) });
  121. return false;
  122. }
  123. }
  124. /**
  125. * Stop watching for file changes.
  126. */
  127. stop(): void {
  128. this.stopped = true;
  129. if (this.debounceTimer) {
  130. clearTimeout(this.debounceTimer);
  131. this.debounceTimer = null;
  132. }
  133. if (this.watcher) {
  134. this.watcher.close();
  135. this.watcher = null;
  136. }
  137. this.hasChanges = false;
  138. logDebug('File watcher stopped');
  139. }
  140. /**
  141. * Whether the watcher is currently active.
  142. */
  143. isActive(): boolean {
  144. return this.watcher !== null && !this.stopped;
  145. }
  146. /**
  147. * Schedule a debounced sync.
  148. */
  149. private scheduleSync(): void {
  150. if (this.debounceTimer) {
  151. clearTimeout(this.debounceTimer);
  152. }
  153. this.debounceTimer = setTimeout(() => {
  154. this.debounceTimer = null;
  155. this.flush();
  156. }, this.debounceMs);
  157. }
  158. /**
  159. * Flush pending changes by running sync.
  160. */
  161. private async flush(): Promise<void> {
  162. // If already syncing, the post-sync check will re-trigger
  163. if (this.syncing || this.stopped) return;
  164. this.hasChanges = false;
  165. this.syncing = true;
  166. try {
  167. const result = await this.syncFn();
  168. this.onSyncComplete?.(result);
  169. } catch (err) {
  170. const error = err instanceof Error ? err : new Error(String(err));
  171. logWarn('Watch sync failed', { error: error.message });
  172. this.onSyncError?.(error);
  173. } finally {
  174. this.syncing = false;
  175. // If new changes arrived during sync, schedule another
  176. if (this.hasChanges && !this.stopped) {
  177. this.scheduleSync();
  178. }
  179. }
  180. }
  181. }