Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.3.0",
"chokidar": "^4.0.3",
"commander": "^14.0.2",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
Expand Down
158 changes: 126 additions & 32 deletions src/sync/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
* 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+).
* Uses chokidar under the hood, which provides cross-platform file
* watching with built-in filtering to avoid registering unnecessary
* inotify watches (fixes #276: fs.watch recursive exhausts kernel
* watch budget on large repos).
*/

import * as fs from 'fs';
import * as path from 'path';
import chokidar, { FSWatcher } from 'chokidar';
import ignore, { Ignore } from 'ignore';
import { isSourceFile } from '../extraction';
import { logDebug, logWarn } from '../errors';
import { normalizePath } from '../utils';
Expand Down Expand Up @@ -36,22 +41,64 @@ export interface WatchOptions {
onSyncError?: (error: Error) => void;
}

/**
* Represents a .gitignore file loaded from a specific directory.
* Rules in a .gitignore are relative to that directory, mirroring
* how git applies .gitignore files at every level.
*/
interface ScopedIgnore {
dir: string;
ig: Ignore;
}

/**
* Load .gitignore files from projectRoot upward through parent
* directories. Returns a list ordered from root to projectRoot
* so nested rules (closest to the project) are checked first.
*/
function loadGitignoreChain(projectRoot: string): ScopedIgnore[] {
const matchers: ScopedIgnore[] = [];
let dir = projectRoot;

// Determine the filesystem root (e.g. '/' on Linux)
const root = path.parse(dir).root;

while (dir !== root) {
const giPath = path.join(dir, '.gitignore');
try {
if (fs.existsSync(giPath)) {
matchers.unshift({
dir,
ig: ignore().add(fs.readFileSync(giPath, 'utf-8')),
});
}
} catch {
// Unreadable .gitignore — treat as absent
}
dir = path.dirname(dir);
}

return matchers;
}

/**
* 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)
* - Minimal resource usage (chokidar with .gitignore-aware filtering
* avoids registering inotify watches on excluded directories)
* - Debounced to avoid thrashing on rapid saves
* - Filters to supported source files by extension
* - Ignores .codegraph/ directory changes
*/
export class FileWatcher {
private watcher: fs.FSWatcher | null = null;
private watcher: FSWatcher | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private hasChanges = false;
private syncing = false;
private stopped = false;
private gitignoreMatchers: ScopedIgnore[] = [];

private readonly projectRoot: string;
private readonly debounceMs: number;
Expand Down Expand Up @@ -79,57 +126,103 @@ export class FileWatcher {
if (this.watcher) return true; // Already watching
this.stopped = false;

// Some environments make recursive fs.watch unusable — most notably WSL2
// /mnt/ drives, where setup blocks long enough to break MCP startup
// handshakes (issue #199). Skip watching there; callers fall back to
// manual `codegraph sync` or the git sync hooks.
// Some environments make filesystem watching unusable — most notably
// WSL2 /mnt/ drives, where the underlying fs.watch calls block long
// enough to break MCP startup handshakes (issue #199). Skip watching
// there; callers fall back to manual `codegraph sync` or git sync hooks.
const disabledReason = watchDisabledReason(this.projectRoot);
if (disabledReason) {
logDebug('File watcher disabled', { reason: disabledReason, projectRoot: this.projectRoot });
return false;
}

try {
this.watcher = fs.watch(
this.projectRoot,
{ recursive: true },
(_eventType, filename) => {
if (!filename || this.stopped) return;
// Load .gitignore rules from project root upward.
// These drive chokidar's `ignored` callback so we never register
// inotify watches on excluded directories (like node_modules/, .git/,
// dist/, .next/, etc.), avoiding kernel watch-budget exhaustion (#276).
this.gitignoreMatchers = loadGitignoreChain(this.projectRoot);

// Normalize path separators
const normalized = normalizePath(filename);
try {
this.watcher = chokidar.watch(this.projectRoot, {
// Core fix for #276: filter directories BEFORE they are watched.
// chokidar calls this for every file and directory it encounters,
// and only registers an underlying fs.watch on those that pass.
// This drops per-instance inotify watch count from hundreds of
// thousands (on a monorepo) to hundreds — only the directories
// that actually contain tracked source code.
ignored: (testPath: string) => {
const rel = normalizePath(path.relative(this.projectRoot, testPath));

// Ignore .codegraph/ directory changes (our own DB writes)
// Always ignore .codegraph/ (our own DB writes) and .git/
if (
normalized === '.codegraph' ||
normalized.startsWith('.codegraph/') ||
normalized.startsWith('.codegraph\\')
rel === '.codegraph' ||
rel.startsWith('.codegraph/') ||
rel === '.git' ||
rel.startsWith('.git/')
) {
return;
return true;
}

// Only sync changes to files we can actually parse.
if (!isSourceFile(normalized)) {
return;
// Check .gitignore rules
for (const { dir, ig } of this.gitignoreMatchers) {
let matcherRel = normalizePath(path.relative(dir, testPath));
if (!matcherRel || matcherRel.startsWith('..')) continue;

// For directory-only .gitignore rules (e.g. "build/"),
// append a trailing slash so the ignore package matches them.
try {
const stat = fs.statSync(testPath);
if (stat.isDirectory()) matcherRel += '/';
} catch {
// If we can't stat, assume it's a file — don't append '/'
}

if (ig.ignores(matcherRel)) return true;
}

logDebug('File change detected', { file: normalized });
this.hasChanges = true;
this.scheduleSync();
return false;
},
});

// Wire up the file-change handler. chokidar emits 'all' for every
// event type; we only care about files that were actually changed.
this.watcher.on('all', (_event: string, filePath: string) => {
if (this.stopped) return;

const normalized = normalizePath(path.relative(this.projectRoot, filePath));

// Defense in depth: filter again even though `ignored` should
// have prevented watches on these directories. Events can still
// arrive during watcher setup or from symlink traversal.
if (
normalized === '.codegraph' ||
normalized.startsWith('.codegraph/') ||
normalized === '.git' ||
normalized.startsWith('.git/')
) {
return;
}

// Only sync changes to files we can actually parse.
if (!isSourceFile(normalized)) {
return;
}
);

logDebug('File change detected', { file: normalized });
this.hasChanges = true;
this.scheduleSync();
});

// Handle watcher errors gracefully
this.watcher.on('error', (err) => {
this.watcher.on('error', (err: unknown) => {
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) });
// Watcher setup failed (e.g., permission denied, missing directory)
logWarn('Could not start file watcher', { error: String(err) });
return false;
}
}
Expand All @@ -151,6 +244,7 @@ export class FileWatcher {
}

this.hasChanges = false;
this.gitignoreMatchers = [];
logDebug('File watcher stopped');
}

Expand Down