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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user