mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
* fix(hook): emit MCP query hint when server owns DB lock (#2396) When the GitNexus MCP server holds the lbug write lock, the PreToolUse hook's CLI `augment` cannot run (LadybugDB is single-writer) and previously skipped silently — disabling graph augmentation in the most common deployment (server online). Since the same session already has the MCP `query` tool live, the owner branch now emits an additionalContext hint pointing the agent at mcp__gitnexus__query for that pattern, via the same sanctioned stdout channel the augment-success path uses (Codex-safe, #2369). Rejected the alternative of having the hook query the server: it runs over stdio (no port/pipe from the separate hook process) and cross-process read-only access can't coexist with the write lock — both are large architecture changes. Applied to all three gated hook copies (claude .cjs, claude-plugin .js, antigravity .cjs); the cursor hook has no owner gate and is untouched. The stderr `augment skipped: MCP server owns DB` diagnostic stays GITNEXUS_DEBUG-gated (#1913). Owner-path tests flipped from stdout-empty to hint-present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hook): reword MCP-query hint to be conditionally truthful (#2396) The #2396 owner branch emits the hint on every DB-owner path — a confirmed `gitnexus mcp` owner, a `gitnexus serve` owner, and the fail-closed/timeout paths (the probe collapses timeout and owned to one boolean). The old text claimed "Knowledge graph is live via the MCP server" and named mcp__gitnexus__query unconditionally, which is untrue on a fail-closed probe where no server is confirmed and misdirecting for a serve-only owner (review C2/C4). Reword the hint (byte-identical across all three hook copies) to state that local augment is unavailable and to condition the MCP call on the tools actually being live ("if the GitNexus MCP tools are live in this session"). This is truthful on every owner path; the needles the assertions rely on (mcp__gitnexus__query, query, search_query, the pattern) are preserved. Fix the 10 stale owner/fail-closed unit tests that still asserted empty stdout (review C1, the macOS platform-sensitive 2/3 blocker): flip them to assert the hint via parseHookOutput, keep their stderr/GITNEXUS_DEBUG expectations, and rename the two 'SILENTLY' titles. The GITNEXUS_DEBUG='' owner-hint case is restored (the PR's new loop only covered '0'/'false'). Probe and its white-box tests untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hook): de-orphan the JSDoc in the claude hook copy (#2396) The #2396 change inserted buildMcpQueryHint between the pre-existing "PreToolUse handler" JSDoc and handlePreToolUse, orphaning that doc onto the helper and leaving handlePreToolUse undocumented (review C5). Move the helper (with its own doc) above the handler doc so the "PreToolUse handler" comment again precedes handlePreToolUse, matching the clean plugin copy. Pure move; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hook): throttle the MCP-owner hint to once per repo per window (#2396) Previously the hint emitted on every qualifying search while a GitNexus process owned the DB, so an owner-locked session (the common deploy) was nudged toward the MCP query tool on every Grep/Glob/Bash — context bloat and ~2x query amplification (review C3). Add shouldEmitMcpHint(gitNexusDir) to all three hook copies: a per-repo .gitnexus/.mcp-hint-shown mtime marker emits the hint at most once per window. Window via GITNEXUS_MCP_HINT_THROTTLE_MS (default 10min; 0/invalid disables). Best-effort — any fs error falls back to emitting, so the hint is never lost to a marker failure. The stderr skip diagnostic still fires regardless (only the hint is throttled). Tests: hookEnv disables the throttle by default (gitNexusDir is shared across the suite, so a marker would otherwise throttle sibling owner tests); a dedicated macOS-lane test sets a real window and asserts emit-then-throttle with the marker gating it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(hook): README reflects the MCP-owner query hint, not a silent skip (#2396) The 'Hook augmentation/notifications are silently skipped' section still described the MCP-server-owns-DB path as a silent augmentation skip (review docs finding). That path now hands the agent a conditional MCP-query hint via additionalContext (throttled per repo). Reword the section to describe the hint and its GITNEXUS_MCP_HINT_THROTTLE_MS throttle, and keep the GITNEXUS_DEBUG stderr-diagnostic guidance. No CHANGELOG edit (owned at release time). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(hook): guard hint-copy drift + pattern JSON-escaping (#2396) Two gaps the review flagged (R7): - Drift guard: buildMcpQueryHint and shouldEmitMcpHint are triplicated across the three hook copies with no shared module. A source-level byte-identity check (runs on every platform, unlike the macOS-only owner tests) fails if any copy diverges — the institutional pattern the repo already uses for mirrored hook metadata. - Escaping: an adversarial Grep pattern (embedded quote + newline) must not break the additionalContext JSON envelope. A macOS-lane owner test drives the real hook with such a pattern and asserts parseHookOutput still yields valid JSON containing the literal characters (JSON.stringify escapes them). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
639 lines
24 KiB
TypeScript
639 lines
24 KiB
TypeScript
/**
|
|
* Integration Tests: Antigravity hook adapter end-to-end
|
|
*
|
|
* Runs the FULL install + execute flow: invokes the real `setupCommand()`
|
|
* to lay down the Antigravity hook adapter + helpers + win-rm-list-json.ps1
|
|
* into a temp HOME, then spawns the installed adapter as a real child
|
|
* process against a temp git repo + .gitnexus/ directory.
|
|
*
|
|
* Why install-then-spawn rather than spawning the source adapter directly:
|
|
* the source `hooks/antigravity/gitnexus-antigravity-hook.cjs` requires
|
|
* sibling .cjs helpers (`./hook-lock.cjs`, `./hook-db-lock-probe.cjs`)
|
|
* that only live in `hooks/claude/`. The adapter is designed to be COPIED
|
|
* to its install location alongside those helpers — running it from its
|
|
* source dir fails with MODULE_NOT_FOUND. Exercising the install pipeline
|
|
* verifies the complete contract documented at
|
|
* https://geminicli.com/docs/hooks/reference/.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { spawnSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import fsp from 'fs/promises';
|
|
import path from 'path';
|
|
import { cleanupTempDir, cleanupTempDirSync } from '../helpers/test-db.js';
|
|
import os from 'os';
|
|
import {
|
|
runHook,
|
|
parseHookOutput,
|
|
createGitNexusPathEntry,
|
|
createHookToolDir,
|
|
hookEnv,
|
|
envWithPath,
|
|
} from '../utils/hook-test-helpers.js';
|
|
import { setupCommand } from '../../src/cli/setup.js';
|
|
|
|
let tempHome: string;
|
|
let installedHook: string;
|
|
let tmpDir: string;
|
|
let gitNexusDir: string;
|
|
const originalHome = process.env.HOME;
|
|
const originalUserProfile = process.env.USERPROFILE;
|
|
|
|
beforeAll(async () => {
|
|
// Stage a temp HOME with the Antigravity marker dir present so
|
|
// setupCommand installs the adapter + helpers.
|
|
tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'antigravity-hook-e2e-home-'));
|
|
process.env.HOME = tempHome;
|
|
process.env.USERPROFILE = tempHome;
|
|
await fsp.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
|
|
|
|
// Suppress setupCommand's console.log so test output stays readable.
|
|
const origLog = console.log;
|
|
console.log = () => {};
|
|
try {
|
|
await setupCommand();
|
|
} finally {
|
|
console.log = origLog;
|
|
}
|
|
|
|
installedHook = path.join(
|
|
tempHome,
|
|
'.gemini',
|
|
'config',
|
|
'hooks',
|
|
'gitnexus',
|
|
'gitnexus-antigravity-hook.cjs',
|
|
);
|
|
|
|
// Sanity-check the install. If this fails every downstream test would
|
|
// produce noisy MODULE_NOT_FOUND output that obscures the real cause.
|
|
if (!fs.existsSync(installedHook)) {
|
|
throw new Error(`Antigravity adapter was not installed at ${installedHook}`);
|
|
}
|
|
for (const helper of [
|
|
'hook-lock.cjs',
|
|
'hook-db-lock-probe.cjs',
|
|
'win-rm-list-json.ps1',
|
|
'resolve-analyze-cmd.cjs',
|
|
]) {
|
|
const helperPath = path.join(path.dirname(installedHook), helper);
|
|
if (!fs.existsSync(helperPath)) {
|
|
throw new Error(`Helper not installed: ${helperPath}`);
|
|
}
|
|
}
|
|
|
|
// Set up a temp git repo with .gitnexus/ for staleness tests.
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-hook-e2e-repo-'));
|
|
gitNexusDir = path.join(tmpDir, '.gitnexus');
|
|
fs.mkdirSync(gitNexusDir, { recursive: true });
|
|
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, 'hello.txt'), 'hello');
|
|
spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' });
|
|
spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
process.env.HOME = originalHome;
|
|
process.env.USERPROFILE = originalUserProfile;
|
|
if (tempHome) await cleanupTempDir(tempHome);
|
|
if (tmpDir) cleanupTempDirSync(tmpDir);
|
|
});
|
|
|
|
describe('antigravity hook adapter e2e', () => {
|
|
describe('AfterTool — stale-index hint after git mutations', () => {
|
|
// #1913: by default the hint reaches the agent via additionalContext (stdout
|
|
// JSON) but is NOT mirrored to stderr, so strict hook runners see no
|
|
// unexpected output on this normal (non-error) path.
|
|
it('emits the hint via additionalContext and stays silent on stderr by default', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }),
|
|
);
|
|
|
|
const result = runHook(
|
|
installedHook,
|
|
{
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "test"' },
|
|
tool_response: { llmContent: '[committed]' },
|
|
cwd: tmpDir,
|
|
},
|
|
tmpDir,
|
|
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx', GITNEXUS_DEBUG: '' } },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.hookEventName).toBe('AfterTool');
|
|
expect(output!.additionalContext).toContain('index is stale');
|
|
expect(output!.additionalContext).toContain('npx gitnexus@latest analyze');
|
|
// Strict-runner contract: the hint is NOT mirrored to stderr by default.
|
|
expect(result.stderr).not.toContain('[GitNexus] index is stale');
|
|
});
|
|
|
|
// #1913: the terminal-mirror remains available for operators who opt in.
|
|
it('mirrors the hint to stderr for terminal users only under GITNEXUS_DEBUG=1', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }),
|
|
);
|
|
|
|
const result = runHook(
|
|
installedHook,
|
|
{
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "test"' },
|
|
tool_response: { llmContent: '[committed]' },
|
|
cwd: tmpDir,
|
|
},
|
|
tmpDir,
|
|
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx', GITNEXUS_DEBUG: '1' } },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('index is stale');
|
|
expect(result.stderr).toContain('[GitNexus] index is stale');
|
|
});
|
|
|
|
it('auto-detects a PATH-installed gitnexus and suggests `gitnexus analyze` (no npx)', () => {
|
|
// No GITNEXUS_INVOCATION forcing — exercises the installed hook's real PATH
|
|
// probe (#1938). The installed adapter resolves the analyze command through
|
|
// the copied resolve-analyze-cmd.cjs, so a launcher on PATH yields
|
|
// `gitnexus analyze` rather than the npm-11 npx crash path.
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'a'.repeat(39) + 'b', stats: {} }),
|
|
);
|
|
const gn = createGitNexusPathEntry();
|
|
try {
|
|
const result = runHook(
|
|
installedHook,
|
|
{
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "test"' },
|
|
tool_response: { llmContent: '[committed]' },
|
|
cwd: tmpDir,
|
|
},
|
|
tmpDir,
|
|
{ env: envWithPath(gn.pathValue) },
|
|
);
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('Run `gitnexus analyze`');
|
|
expect(output!.additionalContext).not.toContain('npx gitnexus');
|
|
} finally {
|
|
gn.cleanup();
|
|
}
|
|
});
|
|
|
|
it('stays silent when meta.json lastCommit matches HEAD', () => {
|
|
const head = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
}).stdout.trim();
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: head, stats: {} }),
|
|
);
|
|
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "test"' },
|
|
tool_response: { llmContent: '[committed]' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
expect(result.stderr).not.toContain('[GitNexus] index is stale');
|
|
});
|
|
|
|
it('includes --embeddings flag when the previous index had embeddings', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({
|
|
lastCommit: 'b'.repeat(40),
|
|
stats: { embeddings: 42 },
|
|
}),
|
|
);
|
|
|
|
const result = runHook(
|
|
installedHook,
|
|
{
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
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 --embeddings');
|
|
});
|
|
|
|
it('prefers gitnexus.json over meta.json when both are present (dual-write steady state)', () => {
|
|
const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json');
|
|
const metaJsonPath = path.join(gitNexusDir, 'meta.json');
|
|
fs.writeFileSync(gitnexusJsonPath, JSON.stringify({ lastCommit: 'f'.repeat(40), stats: {} }));
|
|
fs.writeFileSync(
|
|
metaJsonPath,
|
|
JSON.stringify({ lastCommit: 'stale'.padEnd(40, '0'), stats: {} }),
|
|
);
|
|
|
|
try {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "test"' },
|
|
tool_response: { llmContent: '[committed]' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
// Reports staleness against gitnexus.json's commit — proves it's consulted first.
|
|
expect(output!.additionalContext).toContain('fffffff');
|
|
} finally {
|
|
fs.rmSync(gitnexusJsonPath, { force: true });
|
|
fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
|
|
}
|
|
});
|
|
|
|
it('treats missing meta.json as stale', () => {
|
|
const metaPath = path.join(gitNexusDir, 'meta.json');
|
|
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
|
|
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('stale');
|
|
});
|
|
|
|
it('skips augment + hint when tool_response carries an error', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'c'.repeat(40), stats: {} }),
|
|
);
|
|
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { error: 'boom' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
|
|
it('skips augment + hint when tool_response.exit_code !== 0', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'd'.repeat(40), stats: {} }),
|
|
);
|
|
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '...', exit_code: 1 },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
|
|
it('detects all five documented git mutation types', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'e'.repeat(40), stats: {} }),
|
|
);
|
|
|
|
const mutations = [
|
|
'git commit -m "x"',
|
|
'git merge feature',
|
|
'git rebase main',
|
|
'git cherry-pick abc123',
|
|
'git pull origin main',
|
|
];
|
|
for (const cmd of mutations) {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: cmd },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: tmpDir,
|
|
});
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output, `mutation: ${cmd}`).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('stale');
|
|
}
|
|
});
|
|
|
|
it('ignores non-mutation git commands', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: 'f'.repeat(40), stats: {} }),
|
|
);
|
|
|
|
const nonMutations = ['git status', 'git log', 'git diff', 'git branch', 'git stash'];
|
|
for (const cmd of nonMutations) {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: cmd },
|
|
tool_response: { llmContent: '...' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(parseHookOutput(result.stdout), `cmd: ${cmd}`).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('AfterTool — augment branch (silent without gitnexus CLI)', () => {
|
|
it('does not crash on search_file_content with a real pattern', () => {
|
|
fs.writeFileSync(
|
|
path.join(gitNexusDir, 'meta.json'),
|
|
JSON.stringify({ lastCommit: '1'.repeat(40), stats: {} }),
|
|
);
|
|
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'search_file_content',
|
|
tool_input: { pattern: 'handleRequest' },
|
|
tool_response: { llmContent: '...' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
// Either exits cleanly (no augment found) or gets killed by the 10s
|
|
// hook timeout when spawned gitnexus CLI hangs in CI.
|
|
expect(result.status === 0 || result.status === null).toBe(true);
|
|
});
|
|
|
|
it('ignores patterns shorter than 3 chars', () => {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'search_file_content',
|
|
tool_input: { pattern: 'ab' },
|
|
tool_response: { llmContent: '...' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
|
|
it('ignores tool names not in the registered matcher', () => {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'read_file',
|
|
tool_input: { path: '/some/file.ts' },
|
|
tool_response: { llmContent: '...' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// #2396: when a GitNexus MCP server owns the repo DB, runAugment() cannot run
|
|
// the CLI augment (LadybugDB is single-writer), so it returns an MCP-query hint
|
|
// that reaches the agent via additionalContext instead of dropping the
|
|
// augmentation. #1913: the stderr skip diagnostic stays gated behind
|
|
// GITNEXUS_DEBUG=1. The Claude/Plugin copies are covered in
|
|
// test/unit/hooks.test.ts; the antigravity adapter shares the identical path
|
|
// and is exercised here through the install pipeline (its lock/probe helpers
|
|
// only resolve from the install dir). A faked lsof/ps + an empty `lbug` lock
|
|
// force hasGitNexusServerOwner() => true; a marker-writing fake CLI proves the
|
|
// CLI augment never ran.
|
|
//
|
|
// #2180: skipped on Linux too — the probe's Linux backend no longer uses
|
|
// lsof/ps, so the faked lsof/ps can't force owner=true there. This stays as the
|
|
// macOS/other-Unix lsof-path lane; the antigravity adapter shares the identical
|
|
// gated owner-skip with the claude/plugin copies, whose Linux owner detection
|
|
// is covered against a fake /proc in test/unit/hook-db-lock-probe.test.ts.
|
|
describe.skipIf(process.platform === 'win32' || process.platform === 'linux')(
|
|
'AfterTool — MCP-query hint when MCP server owns the DB (#2396)',
|
|
() => {
|
|
const OWNER_PROBE = {
|
|
lsofOutput: '12345\n',
|
|
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
|
};
|
|
|
|
it('emits the MCP-query hint on stdout, no stderr noise, exit 0 (CLI augment never ran)', () => {
|
|
const markerPath = path.join(os.tmpdir(), `antigravity-skip-silent-${process.pid}`);
|
|
const lbugPath = path.join(gitNexusDir, 'lbug');
|
|
fs.writeFileSync(lbugPath, '');
|
|
fs.rmSync(markerPath, { force: true });
|
|
const binDir = createHookToolDir({ ...OWNER_PROBE, gitnexusMarkerPath: markerPath });
|
|
try {
|
|
const result = runHook(
|
|
installedHook,
|
|
{
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'search_file_content',
|
|
tool_input: { pattern: 'validateUser' },
|
|
tool_response: { llmContent: '...' },
|
|
cwd: tmpDir,
|
|
},
|
|
tmpDir,
|
|
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
// #2396: the augmentation is handed to the agent as an MCP-query hint on
|
|
// stdout; stderr stays silent (strict-runner contract, #1913).
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output!.additionalContext).toContain('mcp__gitnexus__query');
|
|
expect(output!.additionalContext).toContain('validateUser');
|
|
expect(result.stderr.trim()).toBe('');
|
|
// Marker absent ⇒ the CLI never ran (short-circuited at the owner check).
|
|
// The paired GITNEXUS_DEBUG=1 test below positively proves the path was
|
|
// the owner path (it asserts the owner-skip diagnostic on stderr).
|
|
expect(fs.existsSync(markerPath)).toBe(false);
|
|
} finally {
|
|
fs.rmSync(lbugPath, { force: true });
|
|
fs.rmSync(markerPath, { force: true });
|
|
fs.rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('surfaces the skip reason on stderr only under GITNEXUS_DEBUG=1', () => {
|
|
const markerPath = path.join(os.tmpdir(), `antigravity-skip-debug-${process.pid}`);
|
|
const lbugPath = path.join(gitNexusDir, 'lbug');
|
|
fs.writeFileSync(lbugPath, '');
|
|
fs.rmSync(markerPath, { force: true });
|
|
const binDir = createHookToolDir({ ...OWNER_PROBE, gitnexusMarkerPath: markerPath });
|
|
try {
|
|
const result = runHook(
|
|
installedHook,
|
|
{
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'search_file_content',
|
|
tool_input: { pattern: 'validateUser' },
|
|
tool_response: { llmContent: '...' },
|
|
cwd: tmpDir,
|
|
},
|
|
tmpDir,
|
|
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
// The hint still rides stdout; GITNEXUS_DEBUG only adds the stderr reason.
|
|
expect(parseHookOutput(result.stdout)!.additionalContext).toContain(
|
|
'mcp__gitnexus__query',
|
|
);
|
|
expect(result.stderr).toContain('[GitNexus] augment skipped: MCP server owns DB');
|
|
expect(fs.existsSync(markerPath)).toBe(false);
|
|
} finally {
|
|
fs.rmSync(lbugPath, { force: true });
|
|
fs.rmSync(markerPath, { force: true });
|
|
fs.rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
describe('cwd validation', () => {
|
|
it('rejects relative cwd silently', () => {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: 'relative/path',
|
|
});
|
|
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('unhappy paths', () => {
|
|
it('handles corrupted meta.json without crashing', () => {
|
|
fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), 'THIS IS NOT JSON {{{');
|
|
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status === 0 || result.status === null).toBe(true);
|
|
});
|
|
|
|
it('treats meta.json without lastCommit as stale', () => {
|
|
fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), JSON.stringify({ stats: {} }));
|
|
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
const output = parseHookOutput(result.stdout);
|
|
expect(output).not.toBeNull();
|
|
expect(output!.additionalContext).toContain('stale');
|
|
});
|
|
|
|
it('ignores unknown hook_event_name', () => {
|
|
// PreToolUse is the Claude hook event; the Antigravity adapter has no
|
|
// handler for it and should exit silently.
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: tmpDir,
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
|
|
it('does not crash on empty stdin', () => {
|
|
const result = spawnSync(process.execPath, [installedHook], {
|
|
input: '',
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it('does not crash on missing hook_event_name', () => {
|
|
const result = runHook(installedHook, {
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: tmpDir,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('directory without .gitnexus', () => {
|
|
// Nest the test repo deeply at the filesystem root so parent traversal
|
|
// (5 levels) cannot accidentally pick up a .gitnexus from an ancestor.
|
|
let noGitNexusDir: string;
|
|
let cleanupRoot: string;
|
|
|
|
beforeAll(() => {
|
|
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
|
|
cleanupRoot = path.join(root, `no-gitnexus-antigravity-${Date.now()}-${process.pid}`);
|
|
noGitNexusDir = path.join(cleanupRoot, 'a', 'b', 'c', 'd', 'e', 'f');
|
|
fs.mkdirSync(noGitNexusDir, { recursive: true });
|
|
spawnSync('git', ['init'], { cwd: noGitNexusDir, stdio: 'pipe' });
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTempDirSync(cleanupRoot);
|
|
});
|
|
|
|
it('ignores AfterTool when no .gitnexus exists in cwd or any ancestor', () => {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'run_shell_command',
|
|
tool_input: { command: 'git commit -m "x"' },
|
|
tool_response: { llmContent: '[ok]' },
|
|
cwd: noGitNexusDir,
|
|
});
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
|
|
it('ignores AfterTool search_file_content when no .gitnexus exists', () => {
|
|
const result = runHook(installedHook, {
|
|
hook_event_name: 'AfterTool',
|
|
tool_name: 'search_file_content',
|
|
tool_input: { pattern: 'handleRequest' },
|
|
tool_response: { llmContent: '...' },
|
|
cwd: noGitNexusDir,
|
|
});
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
});
|
|
});
|
|
});
|