Compare commits
2
Commits
v1.4.7
...
auto-analyze
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78b6541f5b | ||
|
|
9465c52ab9 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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\`
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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: {} }));
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user