mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
AGENTS.md and CLAUDE.md are the agent guides teams commit, and the injected
block carried live symbol/relationship/flow counts. Those counts move with any
code change, so every reindex rewrote a tracked file and produced a spurious
diff that had to be restored by hand before committing real work.
The write is now skipped when the volatile counts are the only delta. Counts are
substituted with placeholders — not deleted — before the comparison, so
--no-stats REMOVING the parenthetical is still a material change that writes
through; only a numbers-only difference is suppressed. Both the verbose path and
the gitnexus:keep path go through the same rule, and a project rename, a template
change, or a base_ref change still rewrites as before. Live counts remain
available from `gitnexus status` and `gitnexus://repo/{name}/context`.
Two smaller churn sources go with it:
- The file was CREATED without a trailing newline while every update path writes
`.trim() + '\n'`, so the analyze right after committing a freshly created
AGENTS.md dirtied it purely to append that newline.
- `--no-stats` left the per-cluster `(N symbols)` counts in the skills table,
which are exactly as volatile as the header parenthetical the flag removes.
The stale-index hook recommended plain `gitnexus analyze` — the variant that
rewrites those tracked docs — so an agent following the nudge verbatim reindexed
with the most invasive flags. `formatAnalyzeCommand` takes `indexOnly` and the
three hook call sites (Claude, plugin copy, Antigravity) pass it; the injected
"Index stale?" line and the MCP context resource's `re_index` hint name the same
`--index-only` form. Full `analyze` stays the documented way to refresh the docs
and skills.
Both resolve-analyze-cmd.cjs copies stay byte-identical.
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
/**
|
|
* Integration Tests: Claude Code Hooks End-to-End
|
|
*
|
|
* Tests the hook scripts with real git repos and .gitnexus directories.
|
|
* Unlike unit/hooks.test.ts which tests source code patterns and simple
|
|
* stdin/stdout, these tests verify actual behavior with filesystem state.
|
|
*/
|
|
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';
|
|
import {
|
|
runHook,
|
|
parseHookOutput,
|
|
createGitNexusPathEntry,
|
|
envWithPath,
|
|
} from '../utils/hook-test-helpers.js';
|
|
|
|
// ─── 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',
|
|
);
|
|
|
|
const HOOKS = [
|
|
{ name: 'CJS', path: CJS_HOOK },
|
|
...(fs.existsSync(PLUGIN_HOOK) ? [{ name: 'Plugin', path: PLUGIN_HOOK }] : []),
|
|
];
|
|
|
|
// ─── Temp git repo with .gitnexus ───────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
let gitNexusDir: string;
|
|
|
|
beforeAll(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-'));
|
|
gitNexusDir = path.join(tmpDir, '.gitnexus');
|
|
fs.mkdirSync(gitNexusDir, { recursive: true });
|
|
|
|
// Initialize a real git repo
|
|
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' });
|
|
|
|
// Create a file and commit so HEAD exists
|
|
fs.writeFileSync(path.join(tmpDir, 'hello.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 });
|
|
});
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────
|
|
|
|
describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => {
|
|
describe('PostToolUse staleness detection', () => {
|
|
it('detects stale index when meta.json lastCommit differs from HEAD', () => {
|
|
// Write meta.json with an old commit hash
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 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,
|
|
},
|
|
tmpDir,
|
|
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('stale');
|
|
expect(output!.additionalContext).toContain('npx gitnexus@latest analyze');
|
|
});
|
|
|
|
it('prefers pnpm dlx when GITNEXUS_INVOCATION=pnpm', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 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,
|
|
},
|
|
tmpDir,
|
|
{ env: { ...process.env, GITNEXUS_INVOCATION: 'pnpm' } },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('--allow-build=@ladybugdb/core');
|
|
expect(output!.additionalContext).toContain('gitnexus@latest analyze');
|
|
});
|
|
|
|
it('auto-detects a PATH-installed gitnexus and suggests `gitnexus analyze` (no npx)', () => {
|
|
// No GITNEXUS_INVOCATION forcing — this exercises the hook's real PATH probe
|
|
// (#1938): a launcher on PATH must yield `gitnexus analyze`, never the
|
|
// npm-11 npx crash path. createGitNexusPathEntry scrubs any ambient gitnexus
|
|
// first, so the result cannot pass for the wrong reason.
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'abababababababababababababababababababab', stats: {} }),
|
|
);
|
|
const gn = createGitNexusPathEntry();
|
|
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,
|
|
},
|
|
tmpDir,
|
|
{ env: envWithPath(gn.pathValue) },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('Run `gitnexus analyze --index-only`');
|
|
expect(output!.additionalContext).not.toContain('npx gitnexus');
|
|
} finally {
|
|
gn.cleanup();
|
|
}
|
|
});
|
|
|
|
it('appends --embeddings to the auto-detected `gitnexus analyze` when the index had embeddings', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({
|
|
lastCommit: 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd',
|
|
stats: { embeddings: 42 },
|
|
}),
|
|
);
|
|
const gn = createGitNexusPathEntry();
|
|
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,
|
|
},
|
|
tmpDir,
|
|
{ env: envWithPath(gn.pathValue) },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain(
|
|
'Run `gitnexus analyze --index-only --embeddings`',
|
|
);
|
|
expect(output!.additionalContext).not.toContain('npx gitnexus');
|
|
} finally {
|
|
gn.cleanup();
|
|
}
|
|
});
|
|
|
|
it('stays silent when meta.json lastCommit matches HEAD', () => {
|
|
// Get current HEAD
|
|
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
const head = headResult.stdout.trim();
|
|
|
|
// Write meta.json with matching commit
|
|
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,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
|
|
it('includes --embeddings flag when previous index had embeddings', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({
|
|
lastCommit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
stats: { embeddings: 42 },
|
|
}),
|
|
);
|
|
|
|
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,
|
|
},
|
|
tmpDir,
|
|
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain(
|
|
'npx gitnexus@latest analyze --index-only --embeddings',
|
|
);
|
|
});
|
|
|
|
it('treats missing meta.json as stale', () => {
|
|
// Remove meta.json
|
|
const metaPath = path.join(gitNexusDir, 'meta.json');
|
|
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
|
|
|
|
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('stale');
|
|
});
|
|
|
|
it('ignores failed git commands (exit_code !== 0)', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'cccccccccccccccccccccccccccccccccccccccc', stats: {} }),
|
|
);
|
|
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'git commit -m "test"' },
|
|
tool_output: { exit_code: 1 },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
|
|
it('ignores non-mutation git commands', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'dddddddddddddddddddddddddddddddddddddddd', stats: {} }),
|
|
);
|
|
|
|
const nonMutations = ['git status', 'git log', 'git diff', 'git branch', 'git stash'];
|
|
for (const cmd of nonMutations) {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: cmd },
|
|
tool_output: { exit_code: 0 },
|
|
cwd: tmpDir,
|
|
});
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('detects all 5 git mutation types', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', stats: {} }),
|
|
);
|
|
|
|
const mutations = [
|
|
'git commit -m "x"',
|
|
'git merge feature',
|
|
'git rebase main',
|
|
'git cherry-pick abc',
|
|
'git pull origin main',
|
|
];
|
|
for (const cmd of mutations) {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: cmd },
|
|
tool_output: { exit_code: 0 },
|
|
cwd: tmpDir,
|
|
});
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('stale');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('PreToolUse — silent without gitnexus CLI', () => {
|
|
// PreToolUse tries to spawn `gitnexus augment` which won't be available in CI.
|
|
// Verify it fails gracefully (no output, no crash).
|
|
|
|
it('handles Grep pattern gracefully when CLI is unavailable', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Grep',
|
|
tool_input: { pattern: 'handleRequest' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
// Should not crash — status is 0 if it exits cleanly, or null if the
|
|
// spawned `gitnexus augment` hangs and the 10s timeout kills the process.
|
|
expect(result.status === 0 || result.status === null).toBe(true);
|
|
});
|
|
|
|
it('ignores patterns shorter than 3 chars', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Grep',
|
|
tool_input: { pattern: 'ab' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
|
|
it('ignores non-search tools', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Read',
|
|
tool_input: { file_path: '/some/file.ts' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('cwd validation', () => {
|
|
it('rejects relative cwd silently for PostToolUse', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_output: { exit_code: 0 },
|
|
cwd: 'relative/path',
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
|
|
it('rejects relative cwd silently for PreToolUse', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Grep',
|
|
tool_input: { pattern: 'testPattern' },
|
|
cwd: 'relative/path',
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('unhappy paths', () => {
|
|
it('handles corrupted meta.json (invalid JSON) without crashing', () => {
|
|
fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), 'THIS IS NOT 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,
|
|
});
|
|
|
|
// Should not crash — either treats as stale or ignores
|
|
expect(result.status === 0 || result.status === null).toBe(true);
|
|
});
|
|
|
|
it('handles meta.json with missing lastCommit field', () => {
|
|
fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), JSON.stringify({ 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.status === 0 || result.status === null).toBe(true);
|
|
const output = parseHookOutput(result.stdout);
|
|
// Missing lastCommit should be treated as stale
|
|
if (output) {
|
|
expect(output.additionalContext).toContain('stale');
|
|
}
|
|
});
|
|
|
|
it('ignores unknown hook event name', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'UnknownEvent',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'git commit -m "test"' },
|
|
tool_output: { exit_code: 0 },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
|
|
it('handles empty tool_input for PostToolUse without crashing', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'aaaa', stats: {} }),
|
|
);
|
|
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: {},
|
|
tool_output: { exit_code: 0 },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status === 0 || result.status === null).toBe(true);
|
|
const output = parseHookOutput(result.stdout);
|
|
// No command means no git mutation detection — should be silent
|
|
expect(output).toBeNull();
|
|
});
|
|
|
|
it('ignores non-Bash tool for PostToolUse', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'aaaa', stats: {} }),
|
|
);
|
|
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Read',
|
|
tool_input: { file_path: '/some/file.ts' },
|
|
tool_output: {},
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('directory without .gitnexus', () => {
|
|
// The hook walks up 5 parent directories looking for .gitnexus.
|
|
// To guarantee none is found, create a deeply nested temp dir at the
|
|
// filesystem root where no .gitnexus could exist in any ancestor.
|
|
let noGitNexusDir: string;
|
|
|
|
beforeAll(() => {
|
|
// Use a root-level temp path so parent traversal can't find .gitnexus
|
|
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
|
|
const base = path.join(root, `no-gitnexus-${Date.now()}`);
|
|
// Nest 6 levels deep (hook walks up 5) to ensure isolation
|
|
noGitNexusDir = path.join(base, 'a', 'b', 'c', 'd', 'e', 'f');
|
|
fs.mkdirSync(noGitNexusDir, { recursive: true });
|
|
spawnSync('git', ['init'], { cwd: noGitNexusDir, stdio: 'pipe' });
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Clean up from the base directory
|
|
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
|
|
const base = path.join(
|
|
root,
|
|
path.basename(path.resolve(noGitNexusDir, '..', '..', '..', '..', '..', '..')),
|
|
);
|
|
fs.rmSync(base, { recursive: true, force: true });
|
|
});
|
|
|
|
it('ignores PostToolUse when no .gitnexus directory exists', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_output: { exit_code: 0 },
|
|
cwd: noGitNexusDir,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
|
|
it('ignores PreToolUse when no .gitnexus directory exists', () => {
|
|
const result = runHook(hookPath, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Grep',
|
|
tool_input: { pattern: 'somePattern' },
|
|
cwd: noGitNexusDir,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).toBeNull();
|
|
});
|
|
});
|
|
});
|