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:
co-authored by
Claude Opus 4.7
parent
79b9601aae
commit
cf7db7cb98
@@ -20,6 +20,17 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
the line number while the line-numbered arm answered with zero follow-up
|
the line number while the line-numbered arm answered with zero follow-up
|
||||||
tool calls. Payload cost is small (~3-5%). Set
|
tool calls. Payload cost is small (~3-5%). Set
|
||||||
`CODEGRAPH_EXPLORE_LINENUMS=0` to disable.
|
`CODEGRAPH_EXPLORE_LINENUMS=0` to disable.
|
||||||
|
- **MCP / watcher**: CodeGraph now skips the live file watcher on WSL2
|
||||||
|
`/mnt/*` drives, where recursive `fs.watch` is slow enough to break MCP
|
||||||
|
startup (see Fixed). When the watcher is off, `codegraph init` /
|
||||||
|
`codegraph install` offer to keep the index fresh via git hooks
|
||||||
|
(`post-commit`, `post-merge`, `post-checkout`) that run `codegraph sync`
|
||||||
|
in the background — accept for automatic refresh on commit / pull /
|
||||||
|
checkout, or decline and sync by hand. Either way you're told the index
|
||||||
|
stays frozen until it's re-synced. New controls: `CODEGRAPH_NO_WATCH=1`
|
||||||
|
(or `codegraph serve --mcp --no-watch`) forces the watcher off anywhere;
|
||||||
|
`CODEGRAPH_FORCE_WATCH=1` overrides the WSL auto-detect when your `/mnt`
|
||||||
|
setup is actually fast. `codegraph uninit` removes any hooks it installed.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **MCP / explore**: `codegraph_explore` output is now adaptive to project
|
- **MCP / explore**: `codegraph_explore` output is now adaptive to project
|
||||||
@@ -46,6 +57,19 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
Thanks to [@essopsp](https://github.com/essopsp) for the repro.
|
Thanks to [@essopsp](https://github.com/essopsp) for the repro.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **MCP**: the server no longer hangs on startup under WSL2 when the project
|
||||||
|
lives on an NTFS `/mnt/*` mount. Setting up the recursive file watcher
|
||||||
|
there took tens of seconds — every directory read crosses the Windows/9p
|
||||||
|
boundary — which blew past the host's initialization timeout (opencode's
|
||||||
|
30s), so the codegraph tools silently never appeared, even on small
|
||||||
|
projects. This is the file-watcher half of the
|
||||||
|
[#172](https://github.com/colbymchenry/codegraph/issues/172) startup fix:
|
||||||
|
that one moved the database/WASM open off the handshake, but the watcher
|
||||||
|
setup was still on the critical path. CodeGraph now auto-skips the watcher
|
||||||
|
on those mounts, with manual and git-hook sync fallbacks (see Added).
|
||||||
|
Closes [#199](https://github.com/colbymchenry/codegraph/issues/199).
|
||||||
|
Thanks to [@mengfanbo123](https://github.com/mengfanbo123) for the precise
|
||||||
|
root-cause analysis and workaround.
|
||||||
- **Installer (Claude Code)**: project-local installs (`Just this project`)
|
- **Installer (Claude Code)**: project-local installs (`Just this project`)
|
||||||
now write the MCP server to `.mcp.json` in the project root — the file
|
now write the MCP server to `.mcp.json` in the project root — the file
|
||||||
Claude Code actually reads for project-scoped servers. Previously they
|
Claude Code actually reads for project-scoped servers. Previously they
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Git Sync Hooks Tests
|
||||||
|
*
|
||||||
|
* Covers installing/removing the opt-in commit/merge/checkout hooks that
|
||||||
|
* keep the index fresh when the live watcher is disabled (issue #199).
|
||||||
|
* Exercises real git repos in temp dirs — no mocking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import {
|
||||||
|
installGitSyncHook,
|
||||||
|
removeGitSyncHook,
|
||||||
|
isSyncHookInstalled,
|
||||||
|
isGitRepo,
|
||||||
|
DEFAULT_SYNC_HOOKS,
|
||||||
|
} from '../src/sync/git-hooks';
|
||||||
|
|
||||||
|
function gitInit(dir: string): void {
|
||||||
|
execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutable(file: string): boolean {
|
||||||
|
if (process.platform === 'win32') return true; // mode bits not meaningful
|
||||||
|
return (fs.statSync(file).mode & 0o111) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('git sync hooks', () => {
|
||||||
|
let repo: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-githooks-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (fs.existsSync(repo)) fs.rmSync(repo, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('installs all default hooks, executable, invoking codegraph sync', () => {
|
||||||
|
gitInit(repo);
|
||||||
|
const result = installGitSyncHook(repo);
|
||||||
|
|
||||||
|
expect(result.installed.sort()).toEqual([...DEFAULT_SYNC_HOOKS].sort());
|
||||||
|
expect(result.skipped).toBeUndefined();
|
||||||
|
|
||||||
|
for (const hook of DEFAULT_SYNC_HOOKS) {
|
||||||
|
const file = path.join(repo, '.git', 'hooks', hook);
|
||||||
|
expect(fs.existsSync(file)).toBe(true);
|
||||||
|
const body = fs.readFileSync(file, 'utf8');
|
||||||
|
expect(body).toContain('codegraph sync');
|
||||||
|
expect(body).toContain('command -v codegraph'); // no-op when not on PATH
|
||||||
|
expect(isExecutable(file)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isSyncHookInstalled(repo)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — re-install does not duplicate the block', () => {
|
||||||
|
gitInit(repo);
|
||||||
|
installGitSyncHook(repo);
|
||||||
|
installGitSyncHook(repo);
|
||||||
|
|
||||||
|
const body = fs.readFileSync(path.join(repo, '.git', 'hooks', 'post-commit'), 'utf8');
|
||||||
|
const occurrences = body.split('# >>> codegraph sync hook >>>').length - 1;
|
||||||
|
expect(occurrences).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a pre-existing user hook and appends our block', () => {
|
||||||
|
gitInit(repo);
|
||||||
|
const file = path.join(repo, '.git', 'hooks', 'post-commit');
|
||||||
|
fs.writeFileSync(file, '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
installGitSyncHook(repo, ['post-commit']);
|
||||||
|
|
||||||
|
const body = fs.readFileSync(file, 'utf8');
|
||||||
|
expect(body).toContain('echo "my custom hook"');
|
||||||
|
expect(body).toContain('codegraph sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove strips our block; deletes a hook that was only ours', () => {
|
||||||
|
gitInit(repo);
|
||||||
|
installGitSyncHook(repo, ['post-commit']);
|
||||||
|
const file = path.join(repo, '.git', 'hooks', 'post-commit');
|
||||||
|
expect(fs.existsSync(file)).toBe(true);
|
||||||
|
|
||||||
|
const result = removeGitSyncHook(repo, ['post-commit']);
|
||||||
|
expect(result.installed).toEqual(['post-commit']);
|
||||||
|
expect(fs.existsSync(file)).toBe(false); // was ours-only → deleted
|
||||||
|
expect(isSyncHookInstalled(repo)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove keeps user content when the hook is shared', () => {
|
||||||
|
gitInit(repo);
|
||||||
|
const file = path.join(repo, '.git', 'hooks', 'post-commit');
|
||||||
|
fs.writeFileSync(file, '#!/bin/sh\necho "keep me"\n', { mode: 0o755 });
|
||||||
|
installGitSyncHook(repo, ['post-commit']);
|
||||||
|
|
||||||
|
removeGitSyncHook(repo, ['post-commit']);
|
||||||
|
|
||||||
|
expect(fs.existsSync(file)).toBe(true);
|
||||||
|
const body = fs.readFileSync(file, 'utf8');
|
||||||
|
expect(body).toContain('echo "keep me"');
|
||||||
|
expect(body).not.toContain('codegraph sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors core.hooksPath', () => {
|
||||||
|
gitInit(repo);
|
||||||
|
const customHooks = path.join(repo, '.husky');
|
||||||
|
fs.mkdirSync(customHooks);
|
||||||
|
execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: repo, stdio: 'ignore' });
|
||||||
|
|
||||||
|
const result = installGitSyncHook(repo, ['post-commit']);
|
||||||
|
expect(result.hooksDir).toBe(customHooks);
|
||||||
|
expect(fs.existsSync(path.join(customHooks, 'post-commit'))).toBe(true);
|
||||||
|
// The default .git/hooks dir should NOT have received the hook.
|
||||||
|
expect(fs.existsSync(path.join(repo, '.git', 'hooks', 'post-commit'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips cleanly when not a git repository', () => {
|
||||||
|
expect(isGitRepo(repo)).toBe(false);
|
||||||
|
const result = installGitSyncHook(repo);
|
||||||
|
expect(result.installed).toEqual([]);
|
||||||
|
expect(result.hooksDir).toBeNull();
|
||||||
|
expect(result.skipped).toMatch(/not a git repository/);
|
||||||
|
expect(isSyncHookInstalled(repo)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Watch Policy Tests
|
||||||
|
*
|
||||||
|
* Covers the decision of whether the live file watcher runs, including the
|
||||||
|
* WSL2 /mnt auto-detect and the env-var escape hatches (issue #199), plus
|
||||||
|
* that FileWatcher.start() honors the decision.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { watchDisabledReason } from '../src/sync/watch-policy';
|
||||||
|
import { FileWatcher } from '../src/sync/watcher';
|
||||||
|
import type { CodeGraphConfig } from '../src/types';
|
||||||
|
|
||||||
|
describe('watchDisabledReason', () => {
|
||||||
|
it('returns a reason when CODEGRAPH_NO_WATCH=1', () => {
|
||||||
|
const reason = watchDisabledReason('/home/me/project', {
|
||||||
|
env: { CODEGRAPH_NO_WATCH: '1' },
|
||||||
|
isWsl: false,
|
||||||
|
});
|
||||||
|
expect(reason).toBeTruthy();
|
||||||
|
expect(reason).toMatch(/CODEGRAPH_NO_WATCH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-disables on a WSL2 /mnt drive', () => {
|
||||||
|
const reason = watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: true });
|
||||||
|
expect(reason).toBeTruthy();
|
||||||
|
expect(reason).toMatch(/mnt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT disable on a native WSL home path', () => {
|
||||||
|
expect(watchDisabledReason('/home/me/project', { env: {}, isWsl: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT disable on /mnt when not running under WSL', () => {
|
||||||
|
// A real Linux box may legitimately have a fast /mnt mount.
|
||||||
|
expect(watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: false })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT treat /mnt/wsl (fast Linux mount) as a Windows drive', () => {
|
||||||
|
expect(watchDisabledReason('/mnt/wsl/project', { env: {}, isWsl: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CODEGRAPH_FORCE_WATCH=1 overrides WSL auto-detect', () => {
|
||||||
|
const reason = watchDisabledReason('/mnt/d/code/project', {
|
||||||
|
env: { CODEGRAPH_FORCE_WATCH: '1' },
|
||||||
|
isWsl: true,
|
||||||
|
});
|
||||||
|
expect(reason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CODEGRAPH_NO_WATCH wins over CODEGRAPH_FORCE_WATCH', () => {
|
||||||
|
const reason = watchDisabledReason('/home/me/project', {
|
||||||
|
env: { CODEGRAPH_NO_WATCH: '1', CODEGRAPH_FORCE_WATCH: '1' },
|
||||||
|
isWsl: false,
|
||||||
|
});
|
||||||
|
expect(reason).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FileWatcher honors the watch policy', () => {
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
const baseConfig: CodeGraphConfig = {
|
||||||
|
version: 1,
|
||||||
|
rootDir: '.',
|
||||||
|
include: ['**/*.ts'],
|
||||||
|
exclude: ['**/node_modules/**'],
|
||||||
|
languages: [],
|
||||||
|
frameworks: [],
|
||||||
|
maxFileSize: 1024 * 1024,
|
||||||
|
extractDocstrings: true,
|
||||||
|
trackCallSites: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CODEGRAPH_NO_WATCH;
|
||||||
|
if (testDir && fs.existsSync(testDir)) {
|
||||||
|
fs.rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start when CODEGRAPH_NO_WATCH=1', () => {
|
||||||
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-nowatch-'));
|
||||||
|
process.env.CODEGRAPH_NO_WATCH = '1';
|
||||||
|
|
||||||
|
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
|
||||||
|
const watcher = new FileWatcher(testDir, baseConfig, syncFn);
|
||||||
|
|
||||||
|
expect(watcher.start()).toBe(false);
|
||||||
|
expect(watcher.isActive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-1
@@ -415,6 +415,10 @@ program
|
|||||||
clack.log.success(`${target.displayName}: ${file.action} ${file.path}`);
|
clack.log.success(`${target.displayName}: ${file.action} ${file.path}`);
|
||||||
}
|
}
|
||||||
} catch { /* non-fatal */ }
|
} catch { /* non-fatal */ }
|
||||||
|
try {
|
||||||
|
const { offerWatchFallback } = await import('../installer');
|
||||||
|
await offerWatchFallback(clack, projectPath);
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
clack.outro('');
|
clack.outro('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -459,6 +463,11 @@ program
|
|||||||
clack.log.info('Run "codegraph index" to index the project');
|
clack.log.info('Run "codegraph index" to index the project');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { offerWatchFallback } = await import('../installer');
|
||||||
|
await offerWatchFallback(clack, projectPath);
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
|
||||||
clack.outro('Done');
|
clack.outro('Done');
|
||||||
cg.destroy();
|
cg.destroy();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -505,6 +514,15 @@ program
|
|||||||
const cg = CodeGraph.openSync(projectPath);
|
const cg = CodeGraph.openSync(projectPath);
|
||||||
cg.uninitialize();
|
cg.uninitialize();
|
||||||
|
|
||||||
|
// Clean up any git sync hooks we installed (no-op if none / not a repo).
|
||||||
|
try {
|
||||||
|
const { removeGitSyncHook } = await import('../sync/git-hooks');
|
||||||
|
const removed = removeGitSyncHook(projectPath);
|
||||||
|
if (removed.installed.length > 0) {
|
||||||
|
info(`Removed git ${removed.installed.join(', ')} sync hook${removed.installed.length > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
|
||||||
success(`Removed CodeGraph from ${projectPath}`);
|
success(`Removed CodeGraph from ${projectPath}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
|
error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
@@ -1085,9 +1103,16 @@ program
|
|||||||
.description('Start CodeGraph as an MCP server for AI assistants')
|
.description('Start CodeGraph as an MCP server for AI assistants')
|
||||||
.option('-p, --path <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
|
.option('-p, --path <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
|
||||||
.option('--mcp', 'Run as MCP server (stdio transport)')
|
.option('--mcp', 'Run as MCP server (stdio transport)')
|
||||||
.action(async (options: { path?: string; mcp?: boolean }) => {
|
.option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)')
|
||||||
|
.action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => {
|
||||||
const projectPath = options.path ? resolveProjectPath(options.path) : undefined;
|
const projectPath = options.path ? resolveProjectPath(options.path) : undefined;
|
||||||
|
|
||||||
|
// Commander sets watch=false when --no-watch is passed. Route it through
|
||||||
|
// the same env-var chokepoint the watcher and MCP server already honor.
|
||||||
|
if (options.watch === false) {
|
||||||
|
process.env.CODEGRAPH_NO_WATCH = '1';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (options.mcp) {
|
if (options.mcp) {
|
||||||
// Start MCP server - it handles initialization lazily based on rootUri from client
|
// Start MCP server - it handles initialization lazily based on rootUri from client
|
||||||
|
|||||||
+87
-4
@@ -22,6 +22,11 @@ import {
|
|||||||
} from './targets/registry';
|
} from './targets/registry';
|
||||||
import type { AgentTarget, Location, WriteResult } from './targets/types';
|
import type { AgentTarget, Location, WriteResult } from './targets/types';
|
||||||
import { getGlyphs } from '../ui/glyphs';
|
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
|
// Backwards-compat: keep these named exports — downstream code may
|
||||||
// import them. The shim in `config-writer.ts` continues to re-export
|
// 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.
|
// Step 6: for local install, initialize the project.
|
||||||
if (location === 'local') {
|
if (location === 'local') {
|
||||||
await initializeLocalProject(clack);
|
await initializeLocalProject(clack, useDefaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location === 'global') {
|
if (location === 'global') {
|
||||||
@@ -304,10 +309,14 @@ async function resolveTargets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize CodeGraph in the current project (for local installs).
|
* Initialize CodeGraph in the current project (for local installs), then
|
||||||
* Unchanged from the pre-refactor version — agent-agnostic by nature.
|
* 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();
|
const projectPath = process.cwd();
|
||||||
|
|
||||||
let CodeGraph: typeof import('../index').default;
|
let CodeGraph: typeof import('../index').default;
|
||||||
@@ -323,6 +332,7 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P
|
|||||||
// Check if already initialized
|
// Check if already initialized
|
||||||
if (CodeGraph.isInitialized(projectPath)) {
|
if (CodeGraph.isInitialized(projectPath)) {
|
||||||
clack.log.info('CodeGraph already initialized in this project');
|
clack.log.info('CodeGraph already initialized in this project');
|
||||||
|
await offerWatchFallback(clack, projectPath, { yes: useDefaults });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,4 +358,77 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
cg.close();
|
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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
||||||
|
import { watchDisabledReason } from '../sync';
|
||||||
import { StdioTransport, JsonRpcRequest, JsonRpcNotification, ErrorCodes } from './transport';
|
import { StdioTransport, JsonRpcRequest, JsonRpcNotification, ErrorCodes } from './transport';
|
||||||
import { tools, ToolHandler } from './tools';
|
import { tools, ToolHandler } from './tools';
|
||||||
import { SERVER_INSTRUCTIONS } from './server-instructions';
|
import { SERVER_INSTRUCTIONS } from './server-instructions';
|
||||||
@@ -173,6 +174,18 @@ export class MCPServer {
|
|||||||
private startWatching(): void {
|
private startWatching(): void {
|
||||||
if (!this.cg) return;
|
if (!this.cg) return;
|
||||||
|
|
||||||
|
// When the watcher is intentionally disabled (e.g. WSL2 /mnt drives, or
|
||||||
|
// CODEGRAPH_NO_WATCH=1), say so explicitly and tell the user how to keep
|
||||||
|
// the graph fresh — otherwise the silent staleness is hard to diagnose.
|
||||||
|
const disabledReason = watchDisabledReason(this.projectPath ?? process.cwd());
|
||||||
|
if (disabledReason) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[CodeGraph MCP] File watcher disabled — ${disabledReason}. ` +
|
||||||
|
`The graph will not auto-update; run \`codegraph sync\` (or install the git sync hooks via \`codegraph init\`) to refresh.\n`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const started = this.cg.watch({
|
const started = this.cg.watch({
|
||||||
onSyncComplete: (result) => {
|
onSyncComplete: (result) => {
|
||||||
if (result.filesChanged > 0) {
|
if (result.filesChanged > 0) {
|
||||||
@@ -188,6 +201,11 @@ export class MCPServer {
|
|||||||
|
|
||||||
if (started) {
|
if (started) {
|
||||||
process.stderr.write('[CodeGraph MCP] File watcher active — graph will auto-sync on changes\n');
|
process.stderr.write('[CodeGraph MCP] File watcher active — graph will auto-sync on changes\n');
|
||||||
|
} else {
|
||||||
|
// start() can also return false when recursive fs.watch isn't supported.
|
||||||
|
process.stderr.write(
|
||||||
|
'[CodeGraph MCP] File watcher unavailable on this platform — run `codegraph sync` to refresh the graph after changes.\n'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Git Sync Hooks
|
||||||
|
*
|
||||||
|
* When the live file watcher is disabled (e.g. on WSL2 `/mnt/*` drives,
|
||||||
|
* see watch-policy.ts), the CodeGraph index would otherwise go stale until
|
||||||
|
* the user runs `codegraph sync` by hand. As an opt-in alternative, we can
|
||||||
|
* install git hooks that refresh the index after the operations that change
|
||||||
|
* files on disk: commit, merge (covers `git pull`), and checkout.
|
||||||
|
*
|
||||||
|
* The hooks run `codegraph sync` in the background so they never block git,
|
||||||
|
* and are guarded by `command -v codegraph` so they no-op cleanly when the
|
||||||
|
* CLI isn't on PATH. Our snippet is delimited by marker comments so install
|
||||||
|
* is idempotent and removal preserves any user-authored hook content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
const MARKER_BEGIN = '# >>> codegraph sync hook >>>';
|
||||||
|
const MARKER_END = '# <<< codegraph sync hook <<<';
|
||||||
|
|
||||||
|
export type GitHookName = 'post-commit' | 'post-merge' | 'post-checkout';
|
||||||
|
|
||||||
|
/** Hooks installed by default: commit, merge (git pull), and checkout. */
|
||||||
|
export const DEFAULT_SYNC_HOOKS: GitHookName[] = ['post-commit', 'post-merge', 'post-checkout'];
|
||||||
|
|
||||||
|
export interface GitHookResult {
|
||||||
|
/** Hook names that were created or updated. */
|
||||||
|
installed: GitHookName[];
|
||||||
|
/** Resolved hooks directory, or null when not a git repo. */
|
||||||
|
hooksDir: string | null;
|
||||||
|
/** Reason nothing happened (e.g. not a git repository). */
|
||||||
|
skipped?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `projectRoot` is inside a git working tree. Returns false if git
|
||||||
|
* isn't installed or the path isn't a repo.
|
||||||
|
*/
|
||||||
|
export function isGitRepo(projectRoot: string): boolean {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim();
|
||||||
|
return out === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the git hooks directory for a project, honoring `core.hooksPath`
|
||||||
|
* and git worktrees. Returns an absolute path, or null when not a repo.
|
||||||
|
*/
|
||||||
|
function gitHooksDir(projectRoot: string): string | null {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim();
|
||||||
|
if (!out) return null;
|
||||||
|
return path.isAbsolute(out) ? out : path.resolve(projectRoot, out);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shell snippet (between markers) injected into each hook. */
|
||||||
|
function markerBlock(): string {
|
||||||
|
return [
|
||||||
|
MARKER_BEGIN,
|
||||||
|
'# Keeps the CodeGraph index fresh while the live file watcher is off',
|
||||||
|
'# (e.g. WSL2 /mnt drives). Runs in the background so it never blocks git.',
|
||||||
|
'# Managed by codegraph; remove with `codegraph uninit` or delete this block.',
|
||||||
|
'if command -v codegraph >/dev/null 2>&1; then',
|
||||||
|
' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1',
|
||||||
|
'fi',
|
||||||
|
MARKER_END,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove our marker block (and the marker lines) from hook content. */
|
||||||
|
function stripMarkerBlock(content: string): string {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const kept: string[] = [];
|
||||||
|
let inBlock = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === MARKER_BEGIN) { inBlock = true; continue; }
|
||||||
|
if (trimmed === MARKER_END) { inBlock = false; continue; }
|
||||||
|
if (!inBlock) kept.push(line);
|
||||||
|
}
|
||||||
|
return kept.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a hook body is just a shebang / blank lines (i.e. only ever ours). */
|
||||||
|
function isEffectivelyEmpty(content: string): boolean {
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.every((l) => l.length === 0 || l.startsWith('#!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function chmodExecutable(file: string): void {
|
||||||
|
try {
|
||||||
|
fs.chmodSync(file, 0o755);
|
||||||
|
} catch {
|
||||||
|
/* chmod is a no-op / unsupported on some platforms (e.g. Windows) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install (or update) the CodeGraph sync hooks in a git repository.
|
||||||
|
* Idempotent: re-running replaces our marker block rather than duplicating
|
||||||
|
* it, and any user-authored hook content is preserved.
|
||||||
|
*/
|
||||||
|
export function installGitSyncHook(
|
||||||
|
projectRoot: string,
|
||||||
|
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
|
||||||
|
): GitHookResult {
|
||||||
|
const hooksDir = gitHooksDir(projectRoot);
|
||||||
|
if (!hooksDir) {
|
||||||
|
return { installed: [], hooksDir: null, skipped: 'not a git repository' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(hooksDir, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
return { installed: [], hooksDir, skipped: 'could not access the git hooks directory' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = markerBlock();
|
||||||
|
const installed: GitHookName[] = [];
|
||||||
|
|
||||||
|
for (const hook of hooks) {
|
||||||
|
const file = path.join(hooksDir, hook);
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
// Strip any prior block, then re-append the current one.
|
||||||
|
const base = stripMarkerBlock(fs.readFileSync(file, 'utf8')).replace(/\s*$/, '');
|
||||||
|
content = base.length > 0
|
||||||
|
? `${base}\n\n${block}\n`
|
||||||
|
: `#!/bin/sh\n${block}\n`;
|
||||||
|
} else {
|
||||||
|
content = `#!/bin/sh\n${block}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(file, content);
|
||||||
|
chmodExecutable(file);
|
||||||
|
installed.push(hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { installed, hooksDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the CodeGraph sync hooks. Strips only our marker block; deletes the
|
||||||
|
* hook file entirely when nothing but a shebang remains, otherwise rewrites
|
||||||
|
* the user's content untouched.
|
||||||
|
*/
|
||||||
|
export function removeGitSyncHook(
|
||||||
|
projectRoot: string,
|
||||||
|
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
|
||||||
|
): GitHookResult {
|
||||||
|
const hooksDir = gitHooksDir(projectRoot);
|
||||||
|
if (!hooksDir) {
|
||||||
|
return { installed: [], hooksDir: null, skipped: 'not a git repository' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed: GitHookName[] = [];
|
||||||
|
|
||||||
|
for (const hook of hooks) {
|
||||||
|
const file = path.join(hooksDir, hook);
|
||||||
|
if (!fs.existsSync(file)) continue;
|
||||||
|
|
||||||
|
const original = fs.readFileSync(file, 'utf8');
|
||||||
|
if (!original.includes(MARKER_BEGIN)) continue;
|
||||||
|
|
||||||
|
const stripped = stripMarkerBlock(original);
|
||||||
|
if (isEffectivelyEmpty(stripped)) {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(file, `${stripped.replace(/\s*$/, '')}\n`);
|
||||||
|
chmodExecutable(file);
|
||||||
|
}
|
||||||
|
removed.push(hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { installed: removed, hooksDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any CodeGraph sync hook is currently installed. */
|
||||||
|
export function isSyncHookInstalled(
|
||||||
|
projectRoot: string,
|
||||||
|
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
|
||||||
|
): boolean {
|
||||||
|
const hooksDir = gitHooksDir(projectRoot);
|
||||||
|
if (!hooksDir) return false;
|
||||||
|
return hooks.some((hook) => {
|
||||||
|
const file = path.join(hooksDir, hook);
|
||||||
|
return fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(MARKER_BEGIN);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,8 +6,20 @@
|
|||||||
*
|
*
|
||||||
* Components:
|
* Components:
|
||||||
* - FileWatcher: Debounced fs.watch that auto-triggers sync on file changes
|
* - FileWatcher: Debounced fs.watch that auto-triggers sync on file changes
|
||||||
|
* - Watch policy: decides when the watcher must be disabled (e.g. WSL2 /mnt)
|
||||||
|
* - Git sync hooks: opt-in commit/merge/checkout hooks when watching is off
|
||||||
* - 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)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { FileWatcher, WatchOptions } from './watcher';
|
export { FileWatcher, WatchOptions } from './watcher';
|
||||||
|
export { watchDisabledReason, detectWsl } from './watch-policy';
|
||||||
|
export {
|
||||||
|
installGitSyncHook,
|
||||||
|
removeGitSyncHook,
|
||||||
|
isSyncHookInstalled,
|
||||||
|
isGitRepo,
|
||||||
|
DEFAULT_SYNC_HOOKS,
|
||||||
|
type GitHookName,
|
||||||
|
type GitHookResult,
|
||||||
|
} from './git-hooks';
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Watch Policy
|
||||||
|
*
|
||||||
|
* Decides whether the live file watcher should run for a given project.
|
||||||
|
*
|
||||||
|
* Native recursive `fs.watch` is pathologically slow on WSL2 `/mnt/*`
|
||||||
|
* drives (NTFS exposed over the 9p/drvfs bridge): setting up the recursive
|
||||||
|
* watch walks the directory tree, and every readdir/stat crosses the
|
||||||
|
* Windows boundary. Inside an MCP server this stalls the event loop during
|
||||||
|
* startup long enough to blow past host handshake timeouts (opencode's 30s),
|
||||||
|
* so the tools never appear. See issue #199.
|
||||||
|
*
|
||||||
|
* This module centralizes the on/off decision so the watcher, the MCP
|
||||||
|
* server (for diagnostics), and the installer all agree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { normalizePath } from '../utils';
|
||||||
|
|
||||||
|
let wslChecked = false;
|
||||||
|
let wslValue = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the current process is running under WSL (Windows
|
||||||
|
* Subsystem for Linux). Result is cached after the first call.
|
||||||
|
*
|
||||||
|
* Checks the WSL-specific env vars first (no I/O), then falls back to
|
||||||
|
* `/proc/version`, which contains "microsoft" on WSL kernels.
|
||||||
|
*/
|
||||||
|
export function detectWsl(): boolean {
|
||||||
|
if (wslChecked) return wslValue;
|
||||||
|
wslChecked = true;
|
||||||
|
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
wslValue = false;
|
||||||
|
return wslValue;
|
||||||
|
}
|
||||||
|
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
|
||||||
|
wslValue = true;
|
||||||
|
return wslValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const version = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
|
||||||
|
wslValue = version.includes('microsoft') || version.includes('wsl');
|
||||||
|
} catch {
|
||||||
|
wslValue = false;
|
||||||
|
}
|
||||||
|
return wslValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for WSL Windows-drive mounts like `/mnt/c` or `/mnt/d/project`.
|
||||||
|
* Deliberately matches only single-letter drive mounts, so genuinely fast
|
||||||
|
* Linux mounts such as `/mnt/wsl/...` are not flagged.
|
||||||
|
*/
|
||||||
|
function isWindowsDriveMount(projectRoot: string): boolean {
|
||||||
|
return /^\/mnt\/[a-z](\/|$)/i.test(normalizePath(projectRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inputs that can be overridden in tests so the decision is deterministic
|
||||||
|
* without touching real env vars or `/proc/version`.
|
||||||
|
*/
|
||||||
|
export interface WatchProbe {
|
||||||
|
/** Defaults to `process.env`. */
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
/** Defaults to `detectWsl()`. */
|
||||||
|
isWsl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether the file watcher should be disabled for a project, and why.
|
||||||
|
*
|
||||||
|
* Returns a short human-readable reason when watching should be skipped, or
|
||||||
|
* `null` when it should run normally.
|
||||||
|
*
|
||||||
|
* Precedence (first match wins):
|
||||||
|
* 1. `CODEGRAPH_NO_WATCH=1` → off (explicit opt-out always wins)
|
||||||
|
* 2. `CODEGRAPH_FORCE_WATCH=1` → on (overrides auto-detection)
|
||||||
|
* 3. WSL2 + `/mnt/*` drive → off (recursive fs.watch is too slow; #199)
|
||||||
|
*/
|
||||||
|
export function watchDisabledReason(projectRoot: string, probe: WatchProbe = {}): string | null {
|
||||||
|
const env = probe.env ?? process.env;
|
||||||
|
|
||||||
|
if (env.CODEGRAPH_NO_WATCH === '1') {
|
||||||
|
return 'CODEGRAPH_NO_WATCH=1 is set';
|
||||||
|
}
|
||||||
|
if (env.CODEGRAPH_FORCE_WATCH === '1') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWsl = probe.isWsl ?? detectWsl();
|
||||||
|
if (isWsl && isWindowsDriveMount(projectRoot)) {
|
||||||
|
return 'project is on a WSL2 /mnt/ drive, where recursive fs.watch is too slow to be reliable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: reset the cached WSL detection. */
|
||||||
|
export function __resetWslCacheForTests(): void {
|
||||||
|
wslChecked = false;
|
||||||
|
wslValue = false;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { CodeGraphConfig } from '../types';
|
|||||||
import { shouldIncludeFile } from '../extraction';
|
import { shouldIncludeFile } from '../extraction';
|
||||||
import { logDebug, logWarn } from '../errors';
|
import { logDebug, logWarn } from '../errors';
|
||||||
import { normalizePath } from '../utils';
|
import { normalizePath } from '../utils';
|
||||||
|
import { watchDisabledReason } from './watch-policy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for the file watcher
|
* Options for the file watcher
|
||||||
@@ -82,6 +83,16 @@ export class FileWatcher {
|
|||||||
if (this.watcher) return true; // Already watching
|
if (this.watcher) return true; // Already watching
|
||||||
this.stopped = false;
|
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.
|
||||||
|
const disabledReason = watchDisabledReason(this.projectRoot);
|
||||||
|
if (disabledReason) {
|
||||||
|
logDebug('File watcher disabled', { reason: disabledReason, projectRoot: this.projectRoot });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.watcher = fs.watch(
|
this.watcher = fs.watch(
|
||||||
this.projectRoot,
|
this.projectRoot,
|
||||||
|
|||||||
Reference in New Issue
Block a user