mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* feat: configure prettier with pre-commit hook integration Add prettier, lint-staged, and prettier-plugin-tailwindcss at the repo root with husky pre-commit hook integration. Moves husky from gitnexus/ to root package.json for reliable hook installation. - Root package.json with prepare/format/format:check scripts - .prettierrc with endOfLine:lf and tailwindStylesheet for TW v4 - .prettierignore excluding fixtures, vendor, generated, *.d.ts, *.md - .gitattributes enforcing LF line endings for Windows consistency - Pre-commit hook uses direct node_modules/.bin/ paths (no npx) * style: apply prettier formatting to entire codebase One-time bulk format. No logic changes. Use .git-blame-ignore-revs to skip this commit in git blame. * chore: add .git-blame-ignore-revs for prettier format commit * perf: pre-commit hook runs only tests related to staged files Use vitest --related to scope test execution to tests that import the changed files, instead of running the full suite on every commit. * perf: remove vitest from pre-commit hook, keep in CI only Pre-commit now runs lint-staged + tsc only. Tests run in CI (ci-tests.yml) where they belong — keeps commits fast. * ci: add prettier format check to quality workflow PRs will now fail if code isn't formatted with prettier.
427 lines
14 KiB
TypeScript
427 lines
14 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 } 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,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('stale');
|
|
expect(output!.additionalContext).toContain('npx gitnexus analyze');
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('--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();
|
|
});
|
|
});
|
|
});
|