mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
* perf(hooks): cmdline-first Linux db-lock scan, drop the lsof fallback (#2180) The probe's Linux scan was O(processes × fds) — stat every fd of every process — so on a busy host it blew its budget and fell through to lsof, which then timed out (~2 s) and fail-closed. Every Grep/Glob/Bash hook spent ~2 s of CPU to conclude 'couldn't tell'. Rewrite linuxProcScanFindGitNexusServer (name kept; return type now tri-state 'owned' | 'not-owned' | 'timeout') as three phases: 0. /proc/<pid>/comm prefilter — kernel task->comm, never touches the target's memory maps; truncation-safe whitelist match (comm is capped at 15 visible chars). Calibrated to what a real server reports: @ladybugdb/core's worker_threads rename the main thread to 'MainThread', so that is whitelisted alongside the launcher basenames — omitting it would blind the probe to every server. 1. bounded /proc/<pid>/cmdline read (openSync+readSync, default 16 KiB with a floor of 4 KiB and a bounded escalation up to a hard ceiling) so a D-state holder cannot stall the hook and the mcp/serve mode token is never clipped off a long interpreter path. 2. dev+ino fd match for the 0–2 survivors only. Dispatch: 'owned' and 'timeout' both map to true. Timeout is now fail-closed (overload self-throttle) instead of falling through to lsof; the Linux lsof fallback is removed entirely. End-to-end semantics on busy hosts are unchanged (the old lsof arm also fail-closed there) — the ~2 s of wasted work and the orphan-spawning lsof are what's gone. macOS lsof+ps and Windows Restart Manager paths are untouched. Also: fix the budget parse bug (Number(raw && trim()) treated '0' as 1200; now parseInt-then-validate, with <= 0 an explicit immediate timeout) and add GITNEXUS_HOOK_PROC_ROOT so the Linux scan can be unit tested against a fixture procfs instead of the host's real /proc. Measured on a 583-process host with 6 background gitnexus mcp servers: owner detection 6–12 ms (was ~1216 ms + lsof timeout), ~100x. Tests: new hook-db-lock-probe.test.ts drives all three phases against a fake procfs (comm-truncation safety, Phase 0 trap, 4 KiB-boundary owner-miss guard, budget=0 immediate timeout, EACCES fail-closed) plus a live-/proc e2e that pins the fd-visible lbug-handle property against a real subprocess holder. The lsof/ps owner-detection suites are relaned to macOS (Linux no longer takes that path); the lsof orphan-reaping suite is removed (no lsof is spawned on Linux now) with a rationale note. Note: pre-commit typecheck skipped; remaining tsc errors are pre-existing on main (none in files touched here). * fix(hooks): honest EACCES verdict + real escalation coverage (#2183 review) Addresses the tri-review (maintainer + Codex): - [P2] Phase-2 fd-dir EACCES no longer claims 'owned'. /proc/<pid>/fd is owner-only (0500), so a cross-user/root gitnexus server serving ANY repo cleared Phase 0+1 and hit EACCES here, and the old catch returned 'owned' — falsely claiming it locks THIS repo's lbug (dev+ino never compared) and permanently suppressing augment. Split the failure shapes: ENOENT -> continue (raced away); EACCES/EPERM and transient EIO/ESTALE -> 'timeout' (unverifiable -> fail-closed, but honest, not a false ownership claim); ENOTDIR/other structural errors -> continue (not a real fd dir). Same fail-closed dispatcher outcome, no false 'owned', plus a GITNEXUS_DEBUG diagnostic so an operator can tell this skip path from a real owner. - [P2] The escalation test now actually iterates the escalation loop: the gitnexus token sits under 4 KB while the mode token is padded past GITNEXUS_HOOK_PROC_CMDLINE_MAX=4096, and a readSync spy asserts >1 read (the old 9 KB-under-16 KB-cap shape read once and never escalated). - escalation loop now re-checks the budget each iteration and returns a distinct timeout sentinel (never '' — an empty string would read as 'not a candidate' and could drop a real owner -> fail-open); the caller maps it to 'timeout'. - GITNEXUS_HOOK_PROC_ROOT is gated to test context so a stray production env export can't disable Linux owner detection (fail-open). - New uid-agnostic spy tests pin every fd-readdir errno branch (EACCES/EPERM/EIO/ESTALE -> timeout, ENOTDIR -> not-owned) regardless of the runner's uid (the disk chmod-000 tests no-op under root). Note: pre-commit typecheck skipped; remaining tsc errors are pre-existing on main (none in files touched here). * fix(hooks): drop the always-true outOfBudget presence guard (CodeQL #2183) CodeQL flagged `typeof outOfBudget === 'function' && outOfBudget()` as unneeded defensive code: readLinuxCmdline has a single caller (linuxProcScanFindGitNexusServer) that always passes the callback, so the typeof guard is dead. Drop it, leaving `if (outOfBudget())`, and note the invariant in the comment. Mirrored in the byte-identical plugin copy. * fix(hooks): parse numeric hook env with Number() so scientific notation works (#2183 review) getCmdlineMaxBytes and resolveLinuxProcBudgetMs parsed their env via Number.parseInt(raw, 10), so a value like "16e3" silently became 16 (parseInt stops at 'e') instead of 16000. Switch both to Number(String(raw).trim()), which honors scientific notation and is stricter on trailing garbage ("123abc" -> NaN -> default) — matching the repo-majority Number()+isFinite env idiom (src/cli/analyze.ts, src/core/embeddings/hf-env.ts). The two functions had DIFFERENT guard skeletons, so a verbatim swap would regress the budget: resolveLinuxProcBudgetMs used `raw != null ?` with no empty-string short-circuit, and Number("")===0 (vs parseInt("")===NaN) would make a set-but-empty GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS="" resolve to budget 0 => immediate fail-CLOSED timeout => augment permanently skipped. Added the `&& String(raw).trim()` guard so ''/whitespace fall to the 1200 default while "0" still parses to the deliberate #2180 immediate-timeout vector. Exported both helpers for white-box tests (the values are otherwise only observable indirectly through scan timing) and added platform-independent coverage: "16e3"->16000, ""/whitespace->1200 (the regression guard), "0"->0, "123abc"/unset->1200, cmdline "8e3"->8000, "2e3"/""/unset->16384. Both byte-identical hook-db-lock-probe.cjs copies updated together. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): allocUnsafe the per-chunk cmdline read buffer (#2183 review) readLinuxCmdline allocated each per-chunk read buffer with Buffer.alloc(chunkCap), zero-filling memory that readSync immediately and fully overwrites. Switch the hot read buffer to Buffer.allocUnsafe — safe because readSync initializes exactly [0, bytes), only buf.subarray(0, bytes) is consumed, and Buffer.concat deep-copies that slice into `collected`, so the uninitialized tail can never reach the decoded cmdline. The zero-length `collected = Buffer.alloc(0)` is left unchanged (allocUnsafe gains nothing on a 0-length buffer). The existing D3 multi-chunk decode tests cover the read path and stay green. Both byte-identical hook-db-lock-probe.cjs copies updated together. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(hooks): harden the live /proc owner-detection e2e against CI flake (#2183 review) Two flake mechanisms, fixed without weakening what the e2e proves: - Holder readiness (the genuine false-FAIL): the pid-file poll was 200x25ms=5s; a loaded runner can be slow to spawn the child, tripping expect(holderPid).toBeGreaterThan(0). Widened to ~10s and raised the per-test timeout 20s -> 40s. - Scan budget (kept the assertion honest): the live scan ran at the default 1200ms. Because the dispatcher maps a budget 'timeout' to owned=TRUE, a busy host exhausting 1200ms before reaching the holder would make the assertion pass for the WRONG reason (a hollow timeout, not real fd-visible detection). Set a generous explicit 10000ms budget via the existing setEnv() helper so the module afterEach restores it (replacing the raw `delete process.env...` that bypassed env tracking). Raised the coarse timing regression guard to sit ABOVE the budget (5000 -> 15000) so a legitimately-slow-but-correct scan can't trip it. The load-bearing asserts (dev+ino fd-visibility precheck, owned===true for our own lbug) are unchanged. Verified the e2e executes (not skipped) on Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(changelog): empty the root CHANGELOG [Unreleased] section Per maintainer request, nothing should sit under [Unreleased] in the root CHANGELOG.md (the release-owned changelog is gitnexus/CHANGELOG.md, whose [Unreleased] is already empty). Removes all three accumulated blocks — Fixed (#2163), Performance (#2180), Changed (KuzuDB->LadybugDB) — leaving only the [Unreleased] header above [1.5.3]. Pure removal; no release sections touched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
604 lines
22 KiB
TypeScript
604 lines
22 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('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();
|
|
});
|
|
});
|
|
|
|
// Issue #1913: when a GitNexus MCP server owns the repo DB, runAugment() must
|
|
// SKIP — silently by default so strict hook runners never see unexpected
|
|
// output, and surface the reason only under GITNEXUS_DEBUG=1. The Claude/Plugin
|
|
// copies are covered in test/unit/hooks.test.ts; the antigravity adapter shares
|
|
// the identical gated skip 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 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 — augment skipped when MCP server owns the DB (#1913)',
|
|
() => {
|
|
const OWNER_PROBE = {
|
|
lsofOutput: '12345\n',
|
|
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
|
};
|
|
|
|
it('stays SILENT by default (no augment ran, no stderr noise, exit 0)', () => {
|
|
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);
|
|
// Strict-runner contract: completely silent — empty stdout AND stderr
|
|
// (matches the unit suite's assertion strength for the claude/plugin copies).
|
|
expect(result.stdout.trim()).toBe('');
|
|
expect(result.stderr.trim()).toBe('');
|
|
// Marker absent ⇒ the CLI never ran (augment short-circuited at the owner
|
|
// check). The paired GITNEXUS_DEBUG=1 test below positively proves the skip
|
|
// 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);
|
|
expect(parseHookOutput(result.stdout)).toBeNull();
|
|
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();
|
|
});
|
|
});
|
|
});
|