Compare commits

...
2 Commits
Author SHA1 Message Date
Gergo Magyar 78b6541f5b fix(hooks): harden cross-platform safety and add regression tests
- Remove shell:true from CJS hook npx fallback, use npx.cmd on Windows
- Use sendHookResponse() consistently in both hook variants
- Fix setup.ts path escaping with JSON.stringify for safe interpolation
- Add path.isAbsolute(cwd) guards against crafted stdin input
- Reduce PreToolUse CLI timeout from 8s to 7s
- Truncate debug error messages to 200 chars
- Add 73 regression tests covering shell injection, cwd validation,
  dispatch routing, staleness detection, and cross-platform spawning
2026-03-07 08:46:07 +00:00
Linus BeckhausandClaude Opus 4.6 9465c52ab9 feat(hooks): auto-reindex after git commit with embeddings preservation
feat(hooks): auto-reindex after git commit with embeddings preservation

Add PostToolUse hook that re-runs `gitnexus analyze` after git commit/merge,
automatically detecting and preserving embeddings via meta.json stats.

- Persist embeddings count in meta.json stats.embeddings field
- Add PostToolUse handler to both hook variants (cjs + plugin)
- Register PostToolUse hook in setup.ts for Claude Code
- Add "Keeping the Index Fresh" section to generated CLAUDE.md/AGENTS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

revert running gitanalyz

fix: address code review findings for auto-reindex hooks

- Fix hook timeout units: seconds not milliseconds (8000->8, 120000->120)
- Remove unused execFileSync import from gitnexus-hook.cjs
- Remove unused `output` variable in PostToolUse handler
- Remove spurious template interpolation in ai-context.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

refactor: clean up gitnexus-hook.cjs per review feedback

- Hoist spawnSync import to module scope
- Add shell: isWin for npx fallback on Windows
- Extract findGitNexusDir helper, reuse in both PreToolUse and PostToolUse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

fix(hooks): stricter git regex, proper spawnSync error handling, embeddings in recovery commands

- Tighten commit/merge regex to not match git merge-base (require \s|$ after subcommand)
- Replace try/catch with child.error/signal inspection for spawnSync timeout detection
- Include --embeddings in manual recovery commands when embeddings were detected
- Extract emitPostToolContext helper to reduce duplication
- Apply all fixes to both hook variants (cjs + plugin)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

fix(hooks): single-launch CLI resolution, guard PreToolUse stderr on failure

- Plugin: detect gitnexus binary via which/where once, then run exactly once
  (prevents double execution when binary exists but command fails)
- Both hooks: only forward augment stderr as additionalContext when exit code
  is 0, preventing CLI error output from leaking into agent context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

docs: update README and CLI skill for PostToolUse auto-reindex

- README: editor support table now shows PreToolUse + PostToolUse
- README: description mentions auto-reindex after commits
- gitnexus-cli skill: document auto-reindex in "When to run" section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:07:17 +01:00
9 changed files with 929 additions and 156 deletions
+2 -2
View File
@@ -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
+146 -64
View File
@@ -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
}
}
+13
View File
@@ -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..."
}
]
}
]
}
}
+154 -71
View File
@@ -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));
}
}
}
+1 -1
View File
@@ -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
+18
View File
@@ -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\`
+8
View File
@@ -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);
+26 -18
View File
@@ -168,16 +168,18 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
// 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<void> {
// 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}`);
}
+561
View File
@@ -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<string, any>): { 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: {} }));
});
}
});