From c4eaf45ab1578ea53d829b980cdc157f5fefc182 Mon Sep 17 00:00:00 2001 From: Linus Beckhaus <47142578+L1nusB@users.noreply.github.com> Date: Sat, 7 Mar 2026 09:59:54 +0100 Subject: [PATCH] feat(hooks): auto-reindex notification with cross-platform hardening (#205) Adds PostToolUse hook that detects stale GitNexus index after git mutations (commit, merge, rebase, cherry-pick, pull) and notifies the agent to reindex. Uses lightweight staleness check (git rev-parse HEAD vs meta.json) instead of running gitnexus analyze synchronously, avoiding KuzuDB corruption and 120s blocks. Security and cross-platform hardening: remove shell:true from all spawnSync calls, use .cmd extensions on Windows, add path.isAbsolute(cwd) guards, fix setup.ts path escaping with JSON.stringify, use sendHookResponse() consistently. Includes 73 regression tests. --- README.md | 4 +- gitnexus-claude-plugin/hooks/gitnexus-hook.js | 210 +++++-- gitnexus-claude-plugin/hooks/hooks.json | 13 + gitnexus/hooks/claude/gitnexus-hook.cjs | 225 ++++--- gitnexus/skills/gitnexus-cli.md | 2 +- gitnexus/src/cli/ai-context.ts | 18 + gitnexus/src/cli/analyze.ts | 8 + gitnexus/src/cli/setup.ts | 44 +- gitnexus/test/unit/hooks.test.ts | 561 ++++++++++++++++++ 9 files changed, 929 insertions(+), 156 deletions(-) create mode 100644 gitnexus/test/unit/hooks.test.ts diff --git a/README.md b/README.md index 6a236f47e..77d8e5967 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,12 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | Editor | MCP | Skills | Hooks (auto-augment) | Support | | --------------------- | --- | ------ | -------------------- | -------------- | -| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** | +| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | | **Cursor** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | -> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context. +> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits. ### Community Integrations diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 813db5571..4c45c92d9 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -2,8 +2,10 @@ /** * GitNexus Claude Code Plugin Hook * - * PreToolUse handler — intercepts Grep/Glob/Bash searches - * and augments with graph context from the GitNexus index. + * PreToolUse — intercepts Grep/Glob/Bash searches and augments + * with graph context from the GitNexus index. + * PostToolUse — detects stale index after git mutations and notifies + * the agent to reindex. * * NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576). * Session context is injected via CLAUDE.md / skills instead. @@ -26,19 +28,19 @@ function readInput() { } /** - * Check if a directory (or ancestor) has a .gitnexus index. + * Find the .gitnexus directory by walking up from startDir. + * Returns the path to .gitnexus/ or null if not found. */ -function findGitNexusIndex(startDir) { +function findGitNexusDir(startDir) { let dir = startDir || process.cwd(); for (let i = 0; i < 5; i++) { - if (fs.existsSync(path.join(dir, '.gitnexus'))) { - return true; - } + const candidate = path.join(dir, '.gitnexus'); + if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } - return false; + return null; } /** @@ -83,66 +85,146 @@ function extractPattern(toolName, toolInput) { return null; } +/** + * Spawn a gitnexus CLI command synchronously. + * Detects binary on PATH once, then runs exactly once. + * + * SECURITY: Never use shell: true with user-controlled arguments. + * On Windows, invoke gitnexus.cmd directly (no shell needed). + */ +function runGitNexusCli(args, cwd, timeout) { + const isWin = process.platform === 'win32'; + + // Detect whether 'gitnexus' is on PATH (cheap check, no execution) + let useDirectBinary = false; + try { + const which = spawnSync( + isWin ? 'where' : 'which', ['gitnexus'], + { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } + ); + useDirectBinary = which.status === 0; + } catch { /* not on PATH */ } + + if (useDirectBinary) { + return spawnSync( + isWin ? 'gitnexus.cmd' : 'gitnexus', args, + { encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ); + } + // npx fallback needs shell on Windows since npx is a .cmd script + return spawnSync( + isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], + { encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ); +} + +/** + * Emit a hook response with additional context for the agent. + */ +function sendHookResponse(hookEventName, message) { + console.log(JSON.stringify({ + hookSpecificOutput: { hookEventName, additionalContext: message } + })); +} + +/** + * PreToolUse handler — augment searches with graph context. + */ +function handlePreToolUse(input) { + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return; + if (!findGitNexusDir(cwd)) return; + + const toolName = input.tool_name || ''; + const toolInput = input.tool_input || {}; + + if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return; + + const pattern = extractPattern(toolName, toolInput); + if (!pattern || pattern.length < 3) return; + + let result = ''; + try { + const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000); + if (!child.error && child.status === 0) { + result = child.stderr || ''; + } + } catch { /* graceful failure */ } + + if (result && result.trim()) { + sendHookResponse('PreToolUse', result.trim()); + } +} + +/** + * PostToolUse handler — detect index staleness after git mutations. + * + * Instead of spawning a full `gitnexus analyze` synchronously (which blocks + * the agent for up to 120s and risks KuzuDB corruption on timeout), we do a + * lightweight staleness check: compare `git rev-parse HEAD` against the + * lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the + * agent so it can decide when to reindex. + */ +function handlePostToolUse(input) { + const toolName = input.tool_name || ''; + if (toolName !== 'Bash') return; + + const command = (input.tool_input || {}).command || ''; + if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) return; + + // Only proceed if the command succeeded + const toolOutput = input.tool_output || {}; + if (toolOutput.exit_code !== undefined && toolOutput.exit_code !== 0) return; + + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; + + // Compare HEAD against last indexed commit — skip if unchanged + let currentHead = ''; + try { + const headResult = spawnSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'], + }); + currentHead = (headResult.stdout || '').trim(); + } catch { return; } + + if (!currentHead) return; + + let lastCommit = ''; + let hadEmbeddings = false; + try { + const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); + lastCommit = meta.lastCommit || ''; + hadEmbeddings = (meta.stats && meta.stats.embeddings > 0); + } catch { /* no meta — treat as stale */ } + + // If HEAD matches last indexed commit, no reindex needed + if (currentHead && currentHead === lastCommit) return; + + const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + sendHookResponse('PostToolUse', + `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + + `Run \`${analyzeCmd}\` to update the knowledge graph.` + ); +} + +// Dispatch map for hook events +const handlers = { + PreToolUse: handlePreToolUse, + PostToolUse: handlePostToolUse, +}; + function main() { try { const input = readInput(); - const hookEvent = input.hook_event_name || ''; - - if (hookEvent !== 'PreToolUse') return; - - const cwd = input.cwd || process.cwd(); - if (!findGitNexusIndex(cwd)) return; - - const toolName = input.tool_name || ''; - const toolInput = input.tool_input || {}; - - if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return; - - const pattern = extractPattern(toolName, toolInput); - if (!pattern || pattern.length < 3) return; - - // augment CLI writes result to stderr (KuzuDB's native module captures - // stdout fd at OS level, making it unusable in subprocess contexts). - let result = ''; - - const isWin = process.platform === 'win32'; - - // Try direct gitnexus binary first (faster if globally installed) - try { - const child = spawnSync( - 'gitnexus', - ['augment', pattern], - { encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin } - ); - if (child.status === 0 && child.stderr && child.stderr.trim()) { - result = child.stderr; - } - } catch { /* not on PATH */ } - - // Fallback to npx if direct binary didn't produce output - if (!result || !result.trim()) { - try { - const child = spawnSync( - 'npx', - ['-y', 'gitnexus', 'augment', pattern], - { encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin } - ); - if (child.status === 0 && child.stderr && child.stderr.trim()) { - result = child.stderr; - } - } catch { /* graceful failure */ } + const handler = handlers[input.hook_event_name || '']; + if (handler) handler(input); + } catch (err) { + if (process.env.GITNEXUS_DEBUG) { + console.error('GitNexus hook error:', (err.message || '').slice(0, 200)); } - - if (result && result.trim()) { - console.log(JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: result.trim() - } - })); - } - } catch { - // Graceful failure } } diff --git a/gitnexus-claude-plugin/hooks/hooks.json b/gitnexus-claude-plugin/hooks/hooks.json index 2e9cb49b2..f9ed9f84a 100644 --- a/gitnexus-claude-plugin/hooks/hooks.json +++ b/gitnexus-claude-plugin/hooks/hooks.json @@ -12,6 +12,19 @@ } ] } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js", + "timeout": 10, + "statusMessage": "Checking GitNexus index freshness..." + } + ] + } ] } } diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 64f0112a0..55e694fa2 100644 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -2,8 +2,10 @@ /** * GitNexus Claude Code Hook * - * PreToolUse handler — intercepts Grep/Glob/Bash searches - * and augments with graph context from the GitNexus index. + * PreToolUse — intercepts Grep/Glob/Bash searches and augments + * with graph context from the GitNexus index. + * PostToolUse — detects stale index after git mutations and notifies + * the agent to reindex. * * NOTE: SessionStart hooks are broken on Windows (Claude Code bug). * Session context is injected via CLAUDE.md / skills instead. @@ -11,7 +13,7 @@ const fs = require('fs'); const path = require('path'); -const { execFileSync } = require('child_process'); +const { spawnSync } = require('child_process'); /** * Read JSON input from stdin synchronously. @@ -26,19 +28,19 @@ function readInput() { } /** - * Check if a directory (or ancestor) has a .gitnexus index. + * Find the .gitnexus directory by walking up from startDir. + * Returns the path to .gitnexus/ or null if not found. */ -function findGitNexusIndex(startDir) { +function findGitNexusDir(startDir) { let dir = startDir || process.cwd(); for (let i = 0; i < 5; i++) { - if (fs.existsSync(path.join(dir, '.gitnexus'))) { - return true; - } + const candidate = path.join(dir, '.gitnexus'); + if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } - return false; + return null; } /** @@ -83,72 +85,153 @@ function extractPattern(toolName, toolInput) { return null; } +/** + * Resolve the gitnexus CLI path. + * 1. Relative path (works when script is inside npm package) + * 2. require.resolve (works when gitnexus is globally installed) + * 3. Fall back to npx (returns empty string) + */ +function resolveCliPath() { + let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js'); + if (!fs.existsSync(cliPath)) { + try { + cliPath = require.resolve('gitnexus/dist/cli/index.js'); + } catch { + cliPath = ''; + } + } + return cliPath; +} + +/** + * Spawn a gitnexus CLI command synchronously. + * Returns the stderr output (KuzuDB captures stdout at OS level). + */ +function runGitNexusCli(cliPath, args, cwd, timeout) { + const isWin = process.platform === 'win32'; + if (cliPath) { + return spawnSync( + process.execPath, + [cliPath, ...args], + { encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ); + } + // On Windows, invoke npx.cmd directly (no shell needed) + return spawnSync( + isWin ? 'npx.cmd' : 'npx', + ['-y', 'gitnexus', ...args], + { encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ); +} + +/** + * PreToolUse handler — augment searches with graph context. + */ +function handlePreToolUse(input) { + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return; + if (!findGitNexusDir(cwd)) return; + + const toolName = input.tool_name || ''; + const toolInput = input.tool_input || {}; + + if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return; + + const pattern = extractPattern(toolName, toolInput); + if (!pattern || pattern.length < 3) return; + + const cliPath = resolveCliPath(); + let result = ''; + try { + const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); + if (!child.error && child.status === 0) { + result = child.stderr || ''; + } + } catch { /* graceful failure */ } + + if (result && result.trim()) { + sendHookResponse('PreToolUse', result.trim()); + } +} + +/** + * Emit a PostToolUse hook response with additional context for the agent. + */ +function sendHookResponse(hookEventName, message) { + console.log(JSON.stringify({ + hookSpecificOutput: { hookEventName, additionalContext: message } + })); +} + +/** + * PostToolUse handler — detect index staleness after git mutations. + * + * Instead of spawning a full `gitnexus analyze` synchronously (which blocks + * the agent for up to 120s and risks KuzuDB corruption on timeout), we do a + * lightweight staleness check: compare `git rev-parse HEAD` against the + * lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the + * agent so it can decide when to reindex. + */ +function handlePostToolUse(input) { + const toolName = input.tool_name || ''; + if (toolName !== 'Bash') return; + + const command = (input.tool_input || {}).command || ''; + if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) return; + + // Only proceed if the command succeeded + const toolOutput = input.tool_output || {}; + if (toolOutput.exit_code !== undefined && toolOutput.exit_code !== 0) return; + + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; + + // Compare HEAD against last indexed commit — skip if unchanged + let currentHead = ''; + try { + const headResult = spawnSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'], + }); + currentHead = (headResult.stdout || '').trim(); + } catch { return; } + + if (!currentHead) return; + + let lastCommit = ''; + let hadEmbeddings = false; + try { + const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); + lastCommit = meta.lastCommit || ''; + hadEmbeddings = (meta.stats && meta.stats.embeddings > 0); + } catch { /* no meta — treat as stale */ } + + // If HEAD matches last indexed commit, no reindex needed + if (currentHead && currentHead === lastCommit) return; + + const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + sendHookResponse('PostToolUse', + `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + + `Run \`${analyzeCmd}\` to update the knowledge graph.` + ); +} + +// Dispatch map for hook events +const handlers = { + PreToolUse: handlePreToolUse, + PostToolUse: handlePostToolUse, +}; + function main() { try { const input = readInput(); - const hookEvent = input.hook_event_name || ''; - - if (hookEvent !== 'PreToolUse') return; - - const cwd = input.cwd || process.cwd(); - if (!findGitNexusIndex(cwd)) return; - - const toolName = input.tool_name || ''; - const toolInput = input.tool_input || {}; - - if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return; - - const pattern = extractPattern(toolName, toolInput); - if (!pattern || pattern.length < 3) return; - - // Resolve CLI path — try multiple strategies: - // 1. Relative path (works when script is inside npm package) - // 2. require.resolve (works when gitnexus is globally installed) - // 3. Fall back to npx (works when neither is available) - let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js'); - if (!fs.existsSync(cliPath)) { - try { - cliPath = require.resolve('gitnexus/dist/cli/index.js'); - } catch { - cliPath = ''; // will use npx fallback - } - } - - // augment CLI writes result to stderr (KuzuDB's native module captures - // stdout fd at OS level, making it unusable in subprocess contexts). - const { spawnSync } = require('child_process'); - let result = ''; - try { - let child; - if (cliPath) { - child = spawnSync( - process.execPath, - [cliPath, 'augment', pattern], - { encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } - ); - } else { - // npx fallback - const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'; - child = spawnSync( - cmd, - ['-y', 'gitnexus', 'augment', pattern], - { encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } - ); - } - result = child.stderr || ''; - } catch { /* graceful failure */ } - - if (result && result.trim()) { - console.log(JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: result.trim() - } - })); - } + const handler = handlers[input.hook_event_name || '']; + if (handler) handler(input); } catch (err) { - // Graceful failure — log to stderr for debugging - console.error('GitNexus hook error:', err.message); + if (process.env.GITNEXUS_DEBUG) { + console.error('GitNexus hook error:', (err.message || '').slice(0, 200)); + } } } diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index 3ae9c18e5..c9e0af341 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -22,7 +22,7 @@ Run from the project root. This parses all source files, builds the knowledge gr | `--force` | Force full re-index even if up to date | | `--embeddings` | Enable embedding generation for semantic search (off by default) | -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. ### status — Check index freshness diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index e32676bfd..087320472 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -109,6 +109,24 @@ Before completing any code modification task, verify: 3. \`gitnexus_detect_changes()\` confirms changes match expected scope 4. All d=1 (WILL BREAK) dependents were updated +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +\`\`\`bash +npx gitnexus analyze +\`\`\` + +If the index previously included embeddings, preserve them by adding \`--embeddings\`: + +\`\`\`bash +npx gitnexus analyze --embeddings +\`\`\` + +To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`. + ## CLI - Re-index: \`npx gitnexus analyze\` diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 48421a603..c6a7c3b07 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -276,6 +276,13 @@ export const analyzeCommand = async ( // ── Phase 5: Finalize (98–100%) ─────────────────────────────────── updateBar(98, 'Saving metadata...'); + // Count embeddings in the index (cached + newly generated) + let embeddingCount = 0; + try { + const embResult = await executeQuery(`MATCH (e:CodeEmbedding) RETURN count(e) AS cnt`); + embeddingCount = embResult?.[0]?.cnt ?? 0; + } catch { /* table may not exist if embeddings never ran */ } + const meta = { repoPath, lastCommit: currentCommit, @@ -286,6 +293,7 @@ export const analyzeCommand = async ( edges: stats.edges, communities: pipelineResult.communityResult?.stats.totalCommunities, processes: pipelineResult.processResult?.stats.totalProcesses, + embeddings: embeddingCount, }, }; await saveMeta(storagePath, meta); diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 98d5fe7c6..79cd6eba5 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -168,16 +168,18 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // even when it's no longer inside the npm package tree const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js'); const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/'); + const jsonCli = JSON.stringify(normalizedCli); content = content.replace( "let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", - `let cliPath = '${normalizedCli}';` + `let cliPath = ${jsonCli};` ); await fs.writeFile(dest, content, 'utf-8'); } catch { // Script not found in source — skip } - const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/')}"`; + const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); + const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`; // Merge hook config into ~/.claude/settings.json const existing = await readJsonFile(settingsPath) || {}; @@ -186,25 +188,31 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576). // Session context is delivered via CLAUDE.md / skills instead. - // Add PreToolUse hook if not already present - if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = []; - const hasPreToolHook = existing.hooks.PreToolUse.some( - (h: any) => h.hooks?.some((hh: any) => hh.command?.includes('gitnexus')) - ); - if (!hasPreToolHook) { - existing.hooks.PreToolUse.push({ - matcher: 'Grep|Glob|Bash', - hooks: [{ - type: 'command', - command: hookCmd, - timeout: 8000, - statusMessage: 'Enriching with GitNexus graph context...', - }], - }); + // Helper: add a hook entry if one with 'gitnexus-hook' isn't already registered + interface HookEntry { hooks?: Array<{ command?: string }> } + function ensureHookEntry( + eventName: string, + matcher: string, + timeout: number, + statusMessage: string, + ) { + if (!existing.hooks[eventName]) existing.hooks[eventName] = []; + const hasHook = existing.hooks[eventName].some( + (h: HookEntry) => h.hooks?.some(hh => hh.command?.includes('gitnexus-hook')) + ); + if (!hasHook) { + existing.hooks[eventName].push({ + matcher, + hooks: [{ type: 'command', command: hookCmd, timeout, statusMessage }], + }); + } } + ensureHookEntry('PreToolUse', 'Grep|Glob|Bash', 10, 'Enriching with GitNexus graph context...'); + ensureHookEntry('PostToolUse', 'Bash', 10, 'Checking GitNexus index freshness...'); + await writeJsonFile(settingsPath, existing); - result.configured.push('Claude Code hooks (PreToolUse)'); + result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)'); } catch (err: any) { result.errors.push(`Claude Code hooks: ${err.message}`); } diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts new file mode 100644 index 000000000..0befa028a --- /dev/null +++ b/gitnexus/test/unit/hooks.test.ts @@ -0,0 +1,561 @@ +/** + * Regression Tests: Claude Code Hooks + * + * Tests the hook scripts (gitnexus-hook.cjs and gitnexus-hook.js) that run + * as PreToolUse and PostToolUse hooks in Claude Code. + * + * Covers: + * - extractPattern: pattern extraction from Grep/Glob/Bash tool inputs + * - findGitNexusDir: .gitnexus directory discovery + * - handlePostToolUse: staleness detection after git mutations + * - cwd validation: rejects relative paths (defense-in-depth) + * - shell injection: verifies no shell: true in spawnSync calls + * - dispatch map: correct handler routing + * - cross-platform: Windows .cmd extension handling + * + * Since the hooks are CJS scripts that call main() on load, we test them + * by spawning them as child processes with controlled stdin JSON. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// ─── Paths to both hook variants ──────────────────────────────────── + +const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs'); +const PLUGIN_HOOK = path.resolve(__dirname, '..', '..', '..', 'gitnexus-claude-plugin', 'hooks', 'gitnexus-hook.js'); + +// ─── Helper: run a hook script with JSON input on stdin ───────────── + +function runHook(hookPath: string, input: Record): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(process.execPath, [hookPath], { + input: JSON.stringify(input), + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + status: result.status, + }; +} + +function parseHookOutput(stdout: string): { hookEventName?: string; additionalContext?: string } | null { + if (!stdout.trim()) return null; + try { + const parsed = JSON.parse(stdout.trim()); + return parsed.hookSpecificOutput || null; + } catch { + return null; + } +} + +// ─── Test fixtures: temporary .gitnexus directory ─────────────────── + +let tmpDir: string; +let gitNexusDir: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-test-')); + gitNexusDir = path.join(tmpDir, '.gitnexus'); + 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' }); + fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello'); + spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Helper to get HEAD commit hash ───────────────────────────────── + +function getHeadCommit(): string { + const result = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }); + return (result.stdout || '').trim(); +} + +// ─── Both hook files should exist ─────────────────────────────────── + +describe('Hook files exist', () => { + it('CJS hook exists', () => { + expect(fs.existsSync(CJS_HOOK)).toBe(true); + }); + + it('Plugin hook exists', () => { + expect(fs.existsSync(PLUGIN_HOOK)).toBe(true); + }); +}); + +// ─── Source code regression: no shell: true ────────────────────────── + +describe('Shell injection regression', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook has no shell: true in spawnSync calls`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + // Match spawnSync calls with shell option set to true or a variable + // Allowed: comments mentioning shell: true, string literals + const lines = source.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Skip comments and string literals + if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue; + // Check for shell: true or shell: isWin in actual code + if (/shell:\s*(true|isWin)/.test(line)) { + throw new Error(`${label} hook line ${i + 1} has shell injection risk: ${line.trim()}`); + } + } + }); + } +}); + +// ─── Source code regression: .cmd extensions for Windows ───────────── + +describe('Windows .cmd extension handling', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook uses .cmd extensions for Windows npx`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain("npx.cmd"); + }); + } + + it('Plugin hook uses .cmd extension for Windows gitnexus binary', () => { + const source = fs.readFileSync(PLUGIN_HOOK, 'utf-8'); + expect(source).toContain("gitnexus.cmd"); + }); +}); + +// ─── Source code regression: cwd validation ───────────────────────── + +describe('cwd validation guards', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook validates cwd is absolute path`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + const cwdChecks = (source.match(/path\.isAbsolute\(cwd\)/g) || []).length; + // Should have at least 2 checks (one in PreToolUse, one in PostToolUse) + expect(cwdChecks).toBeGreaterThanOrEqual(2); + }); + } +}); + +// ─── Source code regression: sendHookResponse used consistently ────── + +describe('sendHookResponse consistency', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook uses sendHookResponse in both handlers`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + const calls = (source.match(/sendHookResponse\(/g) || []).length; + // At least 3: definition + PreToolUse call + PostToolUse call + expect(calls).toBeGreaterThanOrEqual(3); + }); + + it(`${label} hook does not inline hookSpecificOutput JSON in handlers`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + // Count inline hookSpecificOutput usage (should only be in sendHookResponse definition) + const inlineCount = (source.match(/hookSpecificOutput/g) || []).length; + // Exactly 1 occurrence: inside the sendHookResponse function body + expect(inlineCount).toBe(1); + }); + } +}); + +// ─── Source code regression: dispatch map pattern ──────────────────── + +describe('Dispatch map pattern', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook uses dispatch map instead of if/else`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('const handlers = {'); + expect(source).toContain('PreToolUse: handlePreToolUse'); + expect(source).toContain('PostToolUse: handlePostToolUse'); + // Should NOT have if/else dispatch in main() + expect(source).not.toMatch(/if\s*\(hookEvent\s*===\s*'PreToolUse'\)/); + }); + } +}); + +// ─── Source code regression: debug error truncation ────────────────── + +describe('Debug error message truncation', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook truncates error messages to 200 chars`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('.slice(0, 200)'); + }); + } +}); + +// ─── extractPattern regression (via source analysis) ──────────────── + +describe('extractPattern coverage', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook extracts pattern from Grep tool input`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain("toolName === 'Grep'"); + expect(source).toContain('toolInput.pattern'); + }); + + it(`${label} hook extracts pattern from Glob tool input`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain("toolName === 'Glob'"); + }); + + it(`${label} hook extracts pattern from Bash grep/rg commands`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toMatch(/\\brg\\b.*\\bgrep\\b/); + }); + + it(`${label} hook rejects patterns shorter than 3 chars`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('cleaned.length >= 3'); + }); + } +}); + +// ─── PostToolUse: git mutation regex coverage ─────────────────────── + +describe('Git mutation regex', () => { + const GIT_REGEX = /\\bgit\\s\+\(commit\|merge\|rebase\|cherry-pick\|pull\)/; + + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label} hook detects git commit`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('commit'); + }); + + it(`${label} hook detects git merge`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('merge'); + }); + + it(`${label} hook detects git rebase`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('rebase'); + }); + + it(`${label} hook detects git cherry-pick`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('cherry-pick'); + }); + + it(`${label} hook detects git pull`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + // 'pull' in the regex alternation + expect(source).toMatch(/commit\|merge\|rebase\|cherry-pick\|pull/); + }); + } +}); + +// ─── Integration: PostToolUse staleness detection ─────────────────── + +describe('PostToolUse staleness detection (integration)', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label}: emits stale notification when HEAD differs from meta`, () => { + // Write meta.json with a different commit + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'aaaaaaa0000000000000000000000000deadbeef', 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: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.hookEventName).toBe('PostToolUse'); + expect(output!.additionalContext).toContain('stale'); + expect(output!.additionalContext).toContain('aaaaaaa'); + }); + + it(`${label}: silent when HEAD matches meta lastCommit`, () => { + const head = getHeadCommit(); + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: head, 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: tmpDir, + }); + + expect(result.stdout.trim()).toBe(''); + }); + + it(`${label}: silent when tool is not Bash`, () => { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Grep', + tool_input: { command: 'git commit -m "test"' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + }); + + it(`${label}: silent when command is not a git mutation`, () => { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git status' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + }); + + it(`${label}: silent when exit code is non-zero`, () => { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "fail"' }, + tool_output: { exit_code: 1 }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + }); + + it(`${label}: includes --embeddings in suggestion when meta had embeddings`, () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 42 } }), + ); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git merge feature' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('--embeddings'); + }); + + it(`${label}: omits --embeddings when meta had no embeddings`, () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 0 } }), + ); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).not.toContain('--embeddings'); + }); + + it(`${label}: detects git rebase as a mutation`, () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'oldcommit', stats: {} }), + ); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git rebase main' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('stale'); + }); + + it(`${label}: detects git cherry-pick as a mutation`, () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'oldcommit', stats: {} }), + ); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git cherry-pick abc123' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + }); + + it(`${label}: detects git pull as a mutation`, () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'oldcommit', stats: {} }), + ); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git pull origin main' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + }); + } +}); + +// ─── Integration: cwd validation rejects relative paths ───────────── + +describe('cwd validation (integration)', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label}: PostToolUse silent when cwd is relative`, () => { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: 'relative/path', + }); + expect(result.stdout.trim()).toBe(''); + }); + + it(`${label}: PreToolUse silent when cwd is relative`, () => { + const result = runHook(hookPath, { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: 'relative/path', + }); + expect(result.stdout.trim()).toBe(''); + }); + } +}); + +// ─── Integration: dispatch map routes correctly ───────────────────── + +describe('Dispatch map routing (integration)', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label}: unknown hook_event_name produces no output`, () => { + const result = runHook(hookPath, { + hook_event_name: 'UnknownEvent', + tool_name: 'Bash', + tool_input: { command: 'echo hello' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it(`${label}: empty hook_event_name produces no output`, () => { + const result = runHook(hookPath, { + hook_event_name: '', + tool_name: 'Bash', + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it(`${label}: missing hook_event_name produces no output`, () => { + const result = runHook(hookPath, { + tool_name: 'Bash', + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it(`${label}: invalid JSON input exits cleanly`, () => { + const result = spawnSync(process.execPath, [hookPath], { + input: 'not json at all', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); + + it(`${label}: empty stdin exits cleanly`, () => { + const result = spawnSync(process.execPath, [hookPath], { + input: '', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + }); + } +}); + +// ─── Integration: PostToolUse with missing meta.json ──────────────── + +describe('PostToolUse with missing/corrupt meta.json', () => { + for (const [label, hookPath] of [['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK]] as const) { + it(`${label}: emits stale when meta.json does not exist`, () => { + const metaPath = path.join(gitNexusDir, 'meta.json'); + const hadMeta = fs.existsSync(metaPath); + if (hadMeta) fs.unlinkSync(metaPath); + + try { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('never'); + } finally { + // Restore meta.json for subsequent tests + fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} })); + } + }); + + it(`${label}: emits stale when meta.json is corrupt`, () => { + const metaPath = path.join(gitNexusDir, 'meta.json'); + fs.writeFileSync(metaPath, 'not valid json!!!'); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('never'); + + // Restore + fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} })); + }); + } +});