fix(mcp): skip fs.watch on WSL2 /mnt drives that hang MCP startup (#199) (#210)

Recursive fs.watch on a WSL2 /mnt NTFS/9p mount walks the directory tree
with every readdir/stat crossing the Windows boundary, stalling the event
loop long enough to blow past opencode's 30s MCP handshake timeout so the
tools never appear. This is the file-watcher half of the #172 fix, which
moved the DB/WASM open off the handshake but left the watcher on the
critical path.

- Add watchDisabledReason() policy: CODEGRAPH_NO_WATCH (off) >
  CODEGRAPH_FORCE_WATCH (force on) > WSL2 + /mnt auto-detect (off).
  FileWatcher.start() and the MCP server both honor it; the server now
  logs why watching is off and how to refresh.
- Add `codegraph serve --mcp --no-watch`.
- When watching is off, init/install offer git sync hooks (post-commit,
  post-merge, post-checkout) that run `codegraph sync` in the background,
  or fall back to manual sync; either way the user is told the index
  stays frozen until re-synced. uninit removes the hooks.
- Tests: watch-policy + git-hooks (idempotency, user-content preservation,
  core.hooksPath).

Root-cause analysis and workaround by @mengfanbo123.

Closes #199

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-20 10:32:08 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 79b9601aae
commit cf7db7cb98
10 changed files with 714 additions and 5 deletions
+87 -4
View File
@@ -22,6 +22,11 @@ import {
} from './targets/registry';
import type { AgentTarget, Location, WriteResult } from './targets/types';
import { getGlyphs } from '../ui/glyphs';
// Import the lightweight submodules directly (not the ../sync barrel, which
// re-exports FileWatcher and would transitively pull in ../extraction — the
// installer must stay importable even when native modules can't load).
import { watchDisabledReason } from '../sync/watch-policy';
import { isGitRepo, isSyncHookInstalled, installGitSyncHook } from '../sync/git-hooks';
// Backwards-compat: keep these named exports — downstream code may
// import them. The shim in `config-writer.ts` continues to re-export
@@ -198,7 +203,7 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
// Step 6: for local install, initialize the project.
if (location === 'local') {
await initializeLocalProject(clack);
await initializeLocalProject(clack, useDefaults);
}
if (location === 'global') {
@@ -304,10 +309,14 @@ async function resolveTargets(
}
/**
* Initialize CodeGraph in the current project (for local installs).
* Unchanged from the pre-refactor version — agent-agnostic by nature.
* Initialize CodeGraph in the current project (for local installs), then
* offer the watch fallback when the live watcher won't run here (see
* offerWatchFallback). Agent-agnostic by nature.
*/
async function initializeLocalProject(clack: typeof import('@clack/prompts')): Promise<void> {
async function initializeLocalProject(
clack: typeof import('@clack/prompts'),
useDefaults = false,
): Promise<void> {
const projectPath = process.cwd();
let CodeGraph: typeof import('../index').default;
@@ -323,6 +332,7 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P
// Check if already initialized
if (CodeGraph.isInitialized(projectPath)) {
clack.log.info('CodeGraph already initialized in this project');
await offerWatchFallback(clack, projectPath, { yes: useDefaults });
return;
}
@@ -348,4 +358,77 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P
}
cg.close();
await offerWatchFallback(clack, projectPath, { yes: useDefaults });
}
/**
* When the live file watcher will be disabled for this project (e.g. WSL2
* /mnt drives, or CODEGRAPH_NO_WATCH), the index would silently go stale.
* Explain that, and offer to keep it fresh automatically via git hooks
* (commit / pull / checkout) instead of manual `codegraph sync`.
*
* No-op on environments where the watcher runs normally, so it's safe to
* call unconditionally after init.
*/
export async function offerWatchFallback(
clack: typeof import('@clack/prompts'),
projectPath: string,
opts: { yes?: boolean } = {},
): Promise<void> {
const reason = watchDisabledReason(projectPath);
if (!reason) return; // Watcher runs normally — nothing to set up.
clack.log.warn(`Live file watching is disabled here — ${reason}.`);
clack.log.info('Until you re-sync, the CodeGraph index stays frozen — it will not pick up edits on its own.');
// No git repo → the commit-hook path doesn't apply; point at manual sync.
if (!isGitRepo(projectPath)) {
clack.log.info('Run `codegraph sync` after changing files to refresh the index.');
return;
}
// Already wired up on a previous run — confirm and move on without nagging.
if (isSyncHookInstalled(projectPath)) {
clack.log.info('Git sync hooks are already installed — the index refreshes after commit / pull / checkout.');
return;
}
let choice: 'hook' | 'manual';
if (opts.yes) {
choice = 'hook';
} else {
const sel = await clack.select({
message: 'How should CodeGraph keep its index fresh?',
options: [
{ value: 'hook' as const, label: 'Sync on git commit / pull / checkout', hint: 'installs git hooks (recommended)' },
{ value: 'manual' as const, label: 'I\'ll run `codegraph sync` myself', hint: 'fully manual' },
],
initialValue: 'hook' as const,
});
if (clack.isCancel(sel)) {
clack.log.info('Skipped — run `codegraph sync` after changes to refresh the index.');
return;
}
choice = sel;
}
if (choice === 'manual') {
clack.log.info('Run `codegraph sync` after changing files to refresh the index.');
return;
}
const result = installGitSyncHook(projectPath);
if (result.installed.length > 0) {
clack.log.success(
`Installed git ${result.installed.join(', ')} hook${result.installed.length > 1 ? 's' : ''}` +
'the index refreshes in the background after each.',
);
clack.log.info('Run `codegraph sync` anytime to refresh immediately.');
} else {
clack.log.warn(
`Could not install git hooks${result.skipped ? ` (${result.skipped})` : ''}. ` +
'Run `codegraph sync` after changes instead.',
);
}
}