From 86abc01445b2a28a77e215951638a2f90bc82a7f Mon Sep 17 00:00:00 2001 From: CauchYoung <2024302072042@whu.edu.cn> Date: Tue, 28 Apr 2026 16:39:18 +0800 Subject: [PATCH] fix(hooks): ignore global registry during staleness checks (#1141) * fix(hooks): ignore global registry during staleness checks * test(hooks): cover indexed repos under global registry --------- Co-authored-by: laplace young --- gitnexus-claude-plugin/hooks/gitnexus-hook.js | 12 +- gitnexus/hooks/claude/gitnexus-hook.cjs | 12 +- gitnexus/test/unit/hooks.test.ts | 149 +++++++++++++++++- 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index e29726d85..c3c4ca0de 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -31,11 +31,21 @@ function readInput() { * Find the .gitnexus directory by walking up from startDir. * Returns the path to .gitnexus/ or null if not found. */ +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + function findGitNexusDir(startDir) { let dir = startDir || process.cwd(); for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 8b146acfc..34283b48e 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -31,11 +31,21 @@ function readInput() { * Find the .gitnexus directory by walking up from startDir. * Returns the path to .gitnexus/ or null if not found. */ +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + function findGitNexusDir(startDir) { let dir = startDir || process.cwd(); for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 541e88a67..7d61e2e8a 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -47,12 +47,12 @@ beforeAll(() => { fs.mkdirSync(gitNexusDir, { recursive: true }); // Initialize a bare git repo so git rev-parse HEAD works - spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }); - spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' }); - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' }); + runGit(tmpDir, ['init']); + runGit(tmpDir, ['config', 'user.email', 'test@test.com']); + runGit(tmpDir, ['config', 'user.name', 'Test']); fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello'); - spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }); - spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); + runGit(tmpDir, ['add', '.']); + runGit(tmpDir, ['commit', '-m', 'init']); }); afterAll(() => { @@ -61,15 +61,44 @@ afterAll(() => { // ─── Helper to get HEAD commit hash ───────────────────────────────── -function getHeadCommit(): string { - const result = spawnSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, +function runGit(dir: string, args: string[]) { + const result = spawnSync('git', args, { + cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); + if (result.status !== 0) { + const message = result.stderr || result.stdout || result.error?.message || 'unknown error'; + throw new Error(`git ${args.join(' ')} failed in ${dir}: ${message}`); + } + return result; +} + +function getHeadCommit(): string { + const result = runGit(tmpDir, ['rev-parse', 'HEAD']); return (result.stdout || '').trim(); } +function initGitRepo(dir: string) { + runGit(dir, ['init']); + runGit(dir, ['config', 'user.email', 'test@test.com']); + runGit(dir, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(dir, 'file.txt'), 'hello'); + runGit(dir, ['add', '.']); + runGit(dir, ['commit', '-m', 'init']); +} + +function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 'repos' = 'both') { + const registryDir = path.join(homeDir, '.gitnexus'); + fs.mkdirSync(registryDir, { recursive: true }); + if (marker === 'both' || marker === 'repos') { + fs.mkdirSync(path.join(registryDir, 'repos'), { recursive: true }); + } + if (marker === 'both' || marker === 'registry') { + fs.writeFileSync(path.join(registryDir, 'registry.json'), JSON.stringify({ repos: [] })); + } +} + // ─── Both hook files should exist ─────────────────────────────────── describe('Hook files exist', () => { @@ -469,6 +498,110 @@ describe('cwd validation (integration)', () => { } }); +// ─── Integration: global registry lookup ──────────────────────────── + +describe('Global registry lookup', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: PostToolUse stays silent for unindexed repo under global registry`, () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); + const repoDir = path.join(homeDir, 'work', 'unindexed'); + try { + createGlobalRegistry(homeDir); + fs.mkdirSync(repoDir, { recursive: true }); + initGitRepo(repoDir); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: repoDir, + }); + + expect(result.stdout.trim()).toBe(''); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it(`${label}: PreToolUse stays silent for unindexed repo under global registry`, () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); + const repoDir = path.join(homeDir, 'work', 'unindexed'); + try { + createGlobalRegistry(homeDir); + fs.mkdirSync(repoDir, { recursive: true }); + initGitRepo(repoDir); + + const result = runHook(hookPath, { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: repoDir, + }); + + expect(result.stdout.trim()).toBe(''); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it(`${label}: PostToolUse emits stale for indexed repo under parent global registry`, () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); + const repoDir = path.join(homeDir, 'work', 'indexed-repo'); + try { + createGlobalRegistry(homeDir); + fs.mkdirSync(path.join(repoDir, '.gitnexus'), { recursive: true }); + initGitRepo(repoDir); + fs.writeFileSync( + path.join(repoDir, '.gitnexus', 'meta.json'), + JSON.stringify({ lastCommit: 'oldcommit', stats: {} }), + ); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: repoDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('stale'); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + for (const marker of ['registry', 'repos'] as const) { + it(`${label}: PostToolUse skips global registry with only ${marker} marker`, () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); + const repoDir = path.join(homeDir, 'work', `unindexed-${marker}`); + try { + createGlobalRegistry(homeDir, marker); + fs.mkdirSync(repoDir, { recursive: true }); + initGitRepo(repoDir); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: repoDir, + }); + + expect(result.stdout.trim()).toBe(''); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + } + } +}); + // ─── Integration: dispatch map routes correctly ───────────────────── describe('Dispatch map routing (integration)', () => {