GitNexus/gitnexus/test/integration/hooks-e2e.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

560 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 as spawnHook,
parseHookOutput,
createGitNexusPathEntry,
envWithPath,
} from '../utils/hook-test-helpers.js';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.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;
let hookHome: 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
initGitRepo(tmpDir, { name: 'Test', email: 'test@test.com' });
// Create a file and commit so HEAD exists
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello');
commitAll(tmpDir, 'init');
hookHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-home-'));
fs.writeFileSync(
path.join(hookHome, 'registry.json'),
JSON.stringify([
{
name: 'hooks-e2e',
path: tmpDir,
storagePath: gitNexusDir,
},
]),
);
});
afterAll(() => {
fs.rmSync(hookHome, { recursive: true, force: true });
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function runHook(
hookPath: string,
input: Record<string, any>,
cwd?: string,
options: { env?: NodeJS.ProcessEnv } = {},
) {
return spawnHook(hookPath, input, cwd, {
...options,
env: { ...(options.env ?? process.env), GITNEXUS_HOME: hookHome },
});
}
// ─── 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();
});
});
});