GitNexus/gitnexus/test/unit/hooks.test.ts
Minidoracat 0054496323
Some checks failed
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-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
fix(hooks): wrap the augment CLI child in the orphan guard (#2163) (#2169)
* fix(hooks): wrap the augment CLI child in the orphan guard (#2163)

Follow-up invited by the maintainer on #2165: the augment child
(7s local / 12s npx) was the longest-lived unwrapped subprocess, exposed
to the same SIGKILL-orphan mechanism fixed for lsof/ps.

- Export resolveUnixGuardTimeout from the probe module (both copies,
  byte-identical); adapters share the same module instance, so the memo
  and lazy self-test still run at most once per hook process.
- Wrap every CLI-executing branch of runGitNexusCli in the three
  probe-equipped adapters with the guard: budget ceil(inner/1000)+1
  seconds with -k 1, strictly above each branch's inner spawnSync
  timeout, so the supervised path is unchanged and the wrapper only
  matters once the hook itself is SIGKILLed. Windows and no-guard hosts
  keep byte-identical argv. The plugin adapter's PATH-direct gitnexus
  branch (its most common production path) is wrapped too; the cheap
  which/where probe is not.
- Cursor integration: debug-gated 'augment skipped: hook slots
  saturated' on the slot-starved early return. Its augment child stays
  unwrapped for now — that integration does not install the probe
  sibling (the 'cursor probe' item on the #2163 follow-up list).
- Reaping tests get a guard-availability precheck with an explicit
  failure message (assertion, not skipIf, so a coreutils-less Linux
  host fails diagnosably instead of going silently green).
- Tests: orphaned-augment reaping (CJS + Plugin, red without the wrap,
  ~9.1s reap measured), disabled-sentinel degradation equivalence,
  source pinning for all three adapters (exact per-branch budget-formula
  counts) + probe export + cursor debug line.

Note: pre-commit typecheck skipped; remaining tsc errors are
pre-existing on main (none in files touched here).

* fix(hooks): group-SIGKILL the npx arm, prove guard exit propagation (#2169 review)

Addresses the tri-review findings on #2169:

- [P2] npx-arm containment: the CLI is the guard's grandchild there —
  at budget expiry coreutils timeout TERMs the group, npx (the obedient
  direct child) dies, timeout returns, and -k never fires, so a
  SIGTERM-immune grandchild escaped unbounded. The npx arm's wrapper now
  uses -s KILL: an unignorable group SIGKILL at budget that reaps the
  grandchild (kept -k 1 as a harmless belt; direct-exec arms keep
  TERM-first). CHANGELOG, adapter docblocks, and the test comment now
  state the per-arm semantics honestly. New behavioral test: a staged
  hook with a PATH-injected fake npx spawning a SIGTERM-immune
  grandchild is SIGKILLed; the grandchild must be reaped (red without
  -s KILL), with a route self-proof marker pinning the npx arm.
- [P3] guard self-test now proves exit-status propagation
  (sh -c 'exit 42' must yield status 42), so an always-exit-0 stub like
  /bin/true is rejected and resolution falls through to the built-in
  candidates instead of silently killing the augment feature. New test:
  stub guard rejected, augment still emits context.
- [P3] cleanup SIGKILLs in the reaping tests re-check the
  /proc/<pid>/cmdline identity immediately before firing (PID-reuse
  guard), applied consistently to the two pre-existing #2165 spots and
  both new tests.
- Review notes: source pins now constrain wrapper argv order and exact
  per-arm counts; adapters degrade to unwrapped on probe version skew
  (typeof check) instead of a swallowed TypeError; export JSDoc wording
  fixed for relative env paths; debug-gated diagnostic when no guard is
  available (e.g. macOS without coreutils), with the CHANGELOG entry
  qualified accordingly.

Note: pre-commit typecheck skipped; remaining tsc errors are
pre-existing on main (none in files touched here).

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-12 15:17:30 +01:00

3200 lines
121 KiB
TypeScript

/**
* Regression Tests: Claude Code Hooks
*
* Tests the hook scripts (gitnexus-hook.cjs and gitnexus-hook.js) that run
* as PreToolUse and PostToolUse hooks in Claude Code.
*
* Covers:
* - extractPattern: pattern extraction from Grep/Glob/Bash tool inputs
* - findGitNexusDir: .gitnexus directory discovery
* - handlePostToolUse: staleness detection after git mutations
* - cwd validation: rejects relative paths (defense-in-depth)
* - shell injection: verifies no shell: true in spawnSync calls
* - dispatch map: correct handler routing
* - cross-platform: Windows .cmd extension handling
* - cross-platform: DB lock probe (Linux /proc, Unix lsof, Windows RM)
*
* Since the hooks are CJS scripts that call main() on load, we test them
* by spawning them as child processes with controlled stdin JSON.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawnSync } from 'child_process';
import { createRequire } from 'node:module';
import fs from 'fs';
import path from 'path';
import os from 'os';
import {
runHook,
parseHookOutput,
createHookToolDir,
hookEnv,
} from '../utils/hook-test-helpers.js';
// ─── Paths to both hook variants ────────────────────────────────────
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs');
const RESOLVE_CJS = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'resolve-analyze-cmd.cjs',
);
const RESOLVE_PLUGIN_CJS = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'resolve-analyze-cmd.cjs',
);
const PLUGIN_HOOK = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'gitnexus-hook.js',
);
const PLUGIN_HOOK_LOCK = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'hook-lock.js',
);
const CJS_HOOK_DB_PROBE = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'hook-db-lock-probe.cjs',
);
const PLUGIN_HOOK_DB_PROBE = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'hook-db-lock-probe.cjs',
);
// ─── Host guard precheck for orphan-reaping tests (#2163) ───────────
//
// The reaping lanes depend on a host coreutils `timeout`/`gtimeout` that
// passes the probe's `-k` self-test. Without one, the SIGTERM-immune fake
// child simply survives and the aliveness poll times out — a red that says
// nothing about WHY. Resolve the guard once through the probe's own exported
// resolver (the exact candidate list + self-test the hook child will use,
// with any dev-shell GITNEXUS_HOOK_TIMEOUT_PATH override cleared to mirror
// the `''` these tests pass to the hook) and assert on it with an explicit
// message. Chosen form: precheck ASSERTION, not skipIf — a skip would
// silently drop the incident-mechanism coverage on a misconfigured host
// (green-but-vacuous lane), while a red with a one-line actionable cause
// keeps the contract honest. GitHub ubuntu runners always ship coreutils,
// so CI behavior is unchanged.
const GUARD_PRECHECK_MSG =
'precheck: no self-test-passing coreutils timeout/gtimeout on this host — ' +
'orphan reaping cannot work here (the wrapper IS the reaping mechanism). ' +
'Install coreutils or expose one via GITNEXUS_HOOK_TIMEOUT_PATH.';
let hostGuardMemo: string | null | undefined;
function resolveHostGuardForReapingTests(): string | null {
if (hostGuardMemo !== undefined) return hostGuardMemo;
const saved = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
process.env.GITNEXUS_HOOK_TIMEOUT_PATH = '';
try {
// createRequire: the probe is a CJS module; this also exercises the real
// export surface the adapters consume (#2163 follow-up). Unix-only — the
// resolver's self-test spawns /bin/sh — and all callers below live in
// linux-gated describes.
const probe = createRequire(import.meta.url)(CJS_HOOK_DB_PROBE) as {
resolveUnixGuardTimeout: () => string | null;
};
hostGuardMemo = probe.resolveUnixGuardTimeout();
} finally {
if (saved === undefined) delete process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
else process.env.GITNEXUS_HOOK_TIMEOUT_PATH = saved;
}
return hostGuardMemo;
}
// ─── Test fixtures: temporary .gitnexus directory ───────────────────
let tmpDir: string;
let gitNexusDir: string;
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-test-'));
gitNexusDir = path.join(tmpDir, '.gitnexus');
fs.mkdirSync(gitNexusDir, { recursive: true });
// Initialize a bare git repo so git rev-parse HEAD works
runGit(tmpDir, ['init']);
runGit(tmpDir, ['config', 'user.email', 'test@test.com']);
runGit(tmpDir, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
runGit(tmpDir, ['add', '.']);
runGit(tmpDir, ['commit', '-m', 'init']);
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ─── Helper to get HEAD commit hash ─────────────────────────────────
function runGit(dir: string, args: string[]) {
const result = spawnSync('git', args, {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status !== 0) {
const message = result.stderr || result.stdout || result.error?.message || 'unknown error';
throw new Error(`git ${args.join(' ')} failed in ${dir}: ${message}`);
}
return result;
}
function getHeadCommit(): string {
const result = runGit(tmpDir, ['rev-parse', 'HEAD']);
return (result.stdout || '').trim();
}
function initGitRepo(dir: string) {
runGit(dir, ['init']);
runGit(dir, ['config', 'user.email', 'test@test.com']);
runGit(dir, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(dir, 'file.txt'), 'hello');
runGit(dir, ['add', '.']);
runGit(dir, ['commit', '-m', 'init']);
}
function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 'repos' = 'both') {
const registryDir = path.join(homeDir, '.gitnexus');
fs.mkdirSync(registryDir, { recursive: true });
if (marker === 'both' || marker === 'repos') {
fs.mkdirSync(path.join(registryDir, 'repos'), { recursive: true });
}
if (marker === 'both' || marker === 'registry') {
fs.writeFileSync(path.join(registryDir, 'registry.json'), JSON.stringify({ repos: [] }));
}
}
// createHookToolDir / hookEnv live in ../utils/hook-test-helpers so the antigravity
// e2e suite can reuse the same DB-owner-probe fakes.
// ─── Both hook files should exist ───────────────────────────────────
describe('Hook files exist', () => {
it('CJS hook exists', () => {
expect(fs.existsSync(CJS_HOOK)).toBe(true);
});
it('Plugin hook exists', () => {
expect(fs.existsSync(PLUGIN_HOOK)).toBe(true);
});
});
// ─── Source code regression: no shell: true ──────────────────────────
describe('Shell injection regression', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
['Resolve CJS', RESOLVE_CJS],
['Resolve Plugin', RESOLVE_PLUGIN_CJS],
] as const) {
it(`${label} hook has no shell: true in spawnSync calls`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
// Match spawnSync calls with shell option set to true or a variable
// Allowed: comments mentioning shell: true, string literals
const lines = source.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip comments and string literals
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
// Check for shell: true or shell: isWin in actual code
if (/shell:\s*(true|isWin)/.test(line)) {
throw new Error(`${label} hook line ${i + 1} has shell injection risk: ${line.trim()}`);
}
}
});
}
});
// ─── Source code regression: windowsHide:true on every spawn-family call ───
/**
* Every ``spawn`` / ``spawnSync`` / ``execFile`` / ``execFileSync`` /
* ``execFileAsync`` / ``execSync`` call in the hook layer **and the
* core/CLI/MCP/server source tree** must pass ``windowsHide: true``
* in its options object. Without it, Node's ``child_process`` module
* asks ``CreateProcess`` to use ``STARTF_USESHOWWINDOW`` with
* ``SW_SHOWDEFAULT`` and a black console window flashes onto the
* user's desktop for each call. Under active Claude Code / MCP /
* gitnexus-serve use that's a near-continuous stream of pop-ups —
* unusable in practice on Windows.
*
* ``windowsHide`` is a no-op on POSIX (silently dropped), so the
* flag is safe to require unconditionally. ``stdio: 'inherit'``
* callers (interactive editors etc.) are unaffected — windowsHide
* only suppresses NEW console allocation; an inherited parent
* console isn't touched.
*
* The check is source-level rather than behavioural because:
* - the flag's effect is observable only on Windows;
* - GitHub Actions runs vitest on Linux for these tests;
* - regressing this is easy (every new spawn site has to remember
* the flag), and a runtime check on a Windows-only CI leg would
* still let a PR land on the main branch first.
*
* The pre-existing fix at ``src/core/lbug/extension-loader.ts:96``
* established the convention. This test enforces it everywhere.
*/
describe('windowsHide regression', () => {
// Hook-layer files. Adding a new hook file MUST be reflected here.
const HOOK_FILES: Array<readonly [string, string]> = [
['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK],
['gitnexus/hooks/claude/resolve-analyze-cmd.cjs', RESOLVE_CJS],
['gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs', RESOLVE_PLUGIN_CJS],
[
'gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs',
path.resolve(__dirname, '..', '..', 'hooks', 'antigravity', 'gitnexus-antigravity-hook.cjs'),
],
[
'gitnexus/hooks/claude/hook-db-lock-probe.cjs',
path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs'),
],
['gitnexus-claude-plugin/hooks/gitnexus-hook.js', PLUGIN_HOOK],
[
'gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs',
path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'hook-db-lock-probe.cjs',
),
],
[
'gitnexus-cursor-integration/hooks/gitnexus-hook.cjs',
path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-cursor-integration',
'hooks',
'gitnexus-hook.cjs',
),
],
];
// Source-tree files. Every file that imports a spawn-family
// function from ``child_process`` belongs here. Discovered via
// grep -rn "from 'child_process'" -- gitnexus/src/
// plus the explicit ``await import('child_process')`` callers in
// local-backend.ts.
const SRC_FILES: Array<readonly [string, string]> = [
[
'gitnexus/src/cli/analyze.ts',
path.resolve(__dirname, '..', '..', 'src', 'cli', 'analyze.ts'),
],
['gitnexus/src/cli/setup.ts', path.resolve(__dirname, '..', '..', 'src', 'cli', 'setup.ts')],
['gitnexus/src/cli/wiki.ts', path.resolve(__dirname, '..', '..', 'src', 'cli', 'wiki.ts')],
[
'gitnexus/src/core/embeddings/embedder.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'embeddings', 'embedder.ts'),
],
[
'gitnexus/src/core/git-staleness.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'git-staleness.ts'),
],
[
'gitnexus/src/core/lbug/extension-loader.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'lbug', 'extension-loader.ts'),
],
[
'gitnexus/src/core/run-analyze.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'run-analyze.ts'),
],
[
'gitnexus/src/core/wiki/cursor-client.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'wiki', 'cursor-client.ts'),
],
[
'gitnexus/src/core/wiki/generator.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'wiki', 'generator.ts'),
],
[
'gitnexus/src/mcp/local/local-backend.ts',
path.resolve(__dirname, '..', '..', 'src', 'mcp', 'local', 'local-backend.ts'),
],
[
'gitnexus/src/server/git-clone.ts',
path.resolve(__dirname, '..', '..', 'src', 'server', 'git-clone.ts'),
],
// New post-upstream-merge (May 2026 sync):
[
'gitnexus/src/storage/git.ts',
path.resolve(__dirname, '..', '..', 'src', 'storage', 'git.ts'),
],
];
/**
* Strip pure-comment lines so prose mentions of ``spawn`` /
* ``exec`` don't inflate the call count.
*/
function stripComments(source: string): string {
return source
.split('\n')
.filter((l) => {
const t = l.trim();
return !t.startsWith('//') && !t.startsWith('*') && !t.startsWith('/*');
})
.join('\n');
}
/**
* Count spawn-family invocations. The regex matches ``spawn(``,
* ``spawnSync(``, ``execFile(``, ``execFileSync(``,
* ``execFileAsync(``, ``execSync(`` as function calls — not
* destructures (``const { spawn } = ...``), not method calls
* (``.exec(``), not bare ``exec()`` (which collides with regex
* ``.exec()``; we explicitly drop it).
*/
function countSpawnCalls(codeSource: string): number {
const re =
/(^|[^a-zA-Z0-9_$.])(spawn|spawnSync|execFile|execFileSync|execFileAsync|execSync)\s*\(/gm;
let count = 0;
while (re.exec(codeSource) !== null) {
count++;
}
return count;
}
for (const [label, file] of [...HOOK_FILES, ...SRC_FILES]) {
it(`${label}: every spawn-family options object contains windowsHide: true`, () => {
// The file must exist — silent-skip would mask a deletion.
expect(fs.existsSync(file)).toBe(true);
const source = fs.readFileSync(file, 'utf-8');
const codeSource = stripComments(source);
const spawnCount = countSpawnCalls(codeSource);
const hideCount = (codeSource.match(/windowsHide\s*:\s*true/g) ?? []).length;
// Sanity: catch a refactor that accidentally deletes every
// spawn call (which would otherwise make the equality below
// trivially true at 0 == 0).
expect(spawnCount).toBeGreaterThan(0);
// One windowsHide per spawn-family call. We don't try to
// match brace structure — a same-count proxy is sufficient
// because every spawn site in these files passes an options
// object literal (no helper indirection).
expect(hideCount).toBe(spawnCount);
});
}
});
// ─── Source code regression: .cmd extensions for Windows ─────────────
describe('Windows .cmd extension handling', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook uses .cmd extensions for Windows npx`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('npx.cmd');
});
}
it('Plugin hook uses .cmd extension for Windows gitnexus binary', () => {
const source = fs.readFileSync(PLUGIN_HOOK, 'utf-8');
expect(source).toContain('gitnexus.cmd');
});
});
// ─── Source code regression: cwd validation ─────────────────────────
describe('cwd validation guards', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook validates cwd is absolute path`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const cwdChecks = (source.match(/path\.isAbsolute\(cwd\)/g) || []).length;
// Should have at least 2 checks (one in PreToolUse, one in PostToolUse)
expect(cwdChecks).toBeGreaterThanOrEqual(2);
});
}
});
// ─── Source code regression: sendHookResponse used consistently ──────
describe('sendHookResponse consistency', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook uses sendHookResponse in both handlers`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const calls = (source.match(/sendHookResponse\(/g) || []).length;
// At least 3: definition + PreToolUse call + PostToolUse call
expect(calls).toBeGreaterThanOrEqual(3);
});
it(`${label} hook does not inline hookSpecificOutput JSON in handlers`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
// Count inline hookSpecificOutput usage (should only be in sendHookResponse definition)
const inlineCount = (source.match(/hookSpecificOutput/g) || []).length;
// Exactly 1 occurrence: inside the sendHookResponse function body
expect(inlineCount).toBe(1);
});
}
});
// ─── Source code regression: dispatch map pattern ────────────────────
describe('Dispatch map pattern', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook uses dispatch map instead of if/else`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('const handlers = {');
expect(source).toContain('PreToolUse: handlePreToolUse');
expect(source).toContain('PostToolUse: handlePostToolUse');
// Should NOT have if/else dispatch in main()
expect(source).not.toMatch(/if\s*\(hookEvent\s*===\s*'PreToolUse'\)/);
});
}
});
// ─── Source code regression: debug error truncation ──────────────────
describe('Debug error message truncation', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook truncates error messages to 200 chars`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('.slice(0, 200)');
});
}
});
// ─── extractPattern regression (via source analysis) ────────────────
describe('extractPattern coverage', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook extracts pattern from Grep tool input`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain("toolName === 'Grep'");
expect(source).toContain('toolInput.pattern');
});
it(`${label} hook extracts pattern from Glob tool input`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain("toolName === 'Glob'");
});
it(`${label} hook extracts pattern from Bash grep/rg commands`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toMatch(/\\brg\\b.*\\bgrep\\b/);
});
it(`${label} hook rejects patterns shorter than 3 chars`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('cleaned.length >= 3');
});
}
});
// ─── PostToolUse: git mutation regex coverage ───────────────────────
describe('Git mutation regex', () => {
const GIT_REGEX = /\\bgit\\s\+\(commit\|merge\|rebase\|cherry-pick\|pull\)/;
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook detects git commit`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('commit');
});
it(`${label} hook detects git merge`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('merge');
});
it(`${label} hook detects git rebase`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('rebase');
});
it(`${label} hook detects git cherry-pick`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('cherry-pick');
});
it(`${label} hook detects git pull`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
// 'pull' in the regex alternation
expect(source).toMatch(/commit\|merge\|rebase\|cherry-pick\|pull/);
});
}
});
// ─── Source code regression: PreToolUse concurrency guard (#1486) ──
describe('PreToolUse concurrency guard', () => {
for (const [label, hookPath, lockPath] of [
['CJS', CJS_HOOK, CJS_HOOK_LOCK],
['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_LOCK],
] as const) {
it(`${label} hook loads acquireHookSlot helper`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('acquireHookSlot');
expect(source).toContain('hook-lock');
});
it(`${label} helper defines acquireHookSlot`, () => {
const source = fs.readFileSync(lockPath, 'utf-8');
expect(source).toContain('function acquireHookSlot');
expect(source).toContain('HOOK_LOCK_MAX_INFLIGHT');
});
it(`${label} hook calls acquireHookSlot in handlePreToolUse`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const preBody = source.slice(
source.indexOf('function handlePreToolUse'),
source.indexOf('function handlePostToolUse'),
);
expect(preBody).toContain('acquireHookSlot(');
expect(preBody).toMatch(/release\(\)/);
});
it(`${label} hook uses atomic fixed-name slot files (hard cap)`, () => {
// Regression for the TOCTOU soft-cap: an earlier revision counted
// entries then wrote a per-pid lock, which let simultaneous bursts
// exceed MAX_INFLIGHT. The hard-cap version writes to fixed-name
// slot-N.lock paths so O_CREAT|O_EXCL is atomic across processes.
const source = fs.readFileSync(lockPath, 'utf-8');
expect(source).toMatch(/slot-\$\{slot\}\.lock|`slot-/);
// And no longer reads the lock dir to count active hooks.
const slotFn = source.slice(
source.indexOf('function acquireHookSlot'),
source.indexOf('function', source.indexOf('function acquireHookSlot') + 1),
);
expect(slotFn).not.toContain('readdirSync');
});
it(`${label} hook fails closed when lock dir cannot be created`, () => {
// Regression: an earlier revision returned `() => {}` (truthy no-op) on
// mkdirSync failure, which left callers — `if (!release) return;` — to
// proceed unguarded and reintroduce the #1486 fan-out on read-only or
// cross-user `.gitnexus/` setups. The guard must fail closed (null).
const source = fs.readFileSync(lockPath, 'utf-8');
const slotFn = source.slice(
source.indexOf('function acquireHookSlot'),
source.indexOf('function', source.indexOf('function acquireHookSlot') + 1),
);
const mkdirCatch = slotFn.slice(
slotFn.indexOf('fs.mkdirSync(lockDir'),
slotFn.indexOf('const myPidStr'),
);
expect(mkdirCatch).toContain('return null');
expect(mkdirCatch).not.toMatch(/return\s*\(\s*\)\s*=>\s*\{\s*\}/);
});
}
});
// ─── Integration: concurrency guard skips when slots are full ──────
describe('PreToolUse concurrency guard (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: hook exits silently when all MAX_INFLIGHT slots hold live pids`, async () => {
const { spawn } = await import('child_process');
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// Spawn 3 long-sleeping node child processes to use as live PIDs.
const sleepers = [0, 1, 2].map(() =>
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
stdio: 'ignore',
detached: false,
}),
);
const writtenLocks: string[] = [];
try {
for (let i = 0; i < sleepers.length; i++) {
// Slot files are named slot-N.lock; content is the owning PID.
const p = path.join(lockDir, `slot-${i}.lock`);
fs.writeFileSync(p, String(sleepers[i].pid));
writtenLocks.push(p);
}
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
// Sentinel slot files survive; the hook bailed before claiming any of them.
for (let i = 0; i < sleepers.length; i++) {
const p = path.join(lockDir, `slot-${i}.lock`);
expect(fs.existsSync(p)).toBe(true);
// Owner unchanged.
expect(fs.readFileSync(p, 'utf-8').trim()).toBe(String(sleepers[i].pid));
}
} finally {
for (const child of sleepers) {
try {
child.kill();
} catch {
/* ignore */
}
}
for (const p of writtenLocks) {
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
}
});
it(`${label}: hook reclaims a slot held by a dead pid`, () => {
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// PID 1 exists on every POSIX system (init); on Windows process.kill(1,0)
// throws. Use a definitely-dead PID instead: a very large number unlikely
// to be assigned.
const deadPid = 2_147_483_640;
const stalePath = path.join(lockDir, 'slot-0.lock');
try {
fs.writeFileSync(stalePath, String(deadPid));
expect(fs.readFileSync(stalePath, 'utf-8').trim()).toBe(String(deadPid));
runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
});
// The hook reclaimed and then released slot-0 — either the file is
// gone (released) or its content is something other than the dead PID.
if (fs.existsSync(stalePath)) {
expect(fs.readFileSync(stalePath, 'utf-8').trim()).not.toBe(String(deadPid));
}
} finally {
try {
fs.unlinkSync(stalePath);
} catch {
/* already pruned */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
}
});
it(`${label}: hook does not exceed MAX_INFLIGHT under simultaneous bursts (hard cap)`, async () => {
// Spawn many hook processes concurrently and assert that at most
// MAX_INFLIGHT (3) slot files end up populated by live pids. The
// O_CREAT|O_EXCL slot scheme makes this a hard cap, not the soft cap
// that the count-then-claim approach gives.
const { spawn } = await import('child_process');
const lockDir = path.join(gitNexusDir, '.hook-locks');
// Clean any leftover slot files.
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* dir may not exist yet */
}
fs.mkdirSync(lockDir, { recursive: true });
// We use child workers that just claim a slot via the same algorithm
// and then sleep, so we can observe the on-disk state under contention
// without spawning the real gitnexus augment CLI.
const claimerScript = `
const fs = require('fs'); const path = require('path');
const lockDir = ${JSON.stringify(lockDir)};
const MAX = 3;
const STALE = 30000;
const myPid = String(process.pid);
function tryAcquire() {
for (let slot = 0; slot < MAX; slot++) {
const p = path.join(lockDir, 'slot-' + slot + '.lock');
for (let a = 0; a < 2; a++) {
try { fs.writeFileSync(p, myPid, { flag: 'wx' }); return p; }
catch {
let stat; try { stat = fs.statSync(p); } catch { continue; }
let live = false;
try {
const s = fs.readFileSync(p, 'utf-8').trim();
if (s === '') live = true;
else { const o = Number.parseInt(s, 10);
if (Number.isFinite(o) && o > 0) { try { process.kill(o, 0); live = true; } catch {} }
}
} catch {}
if (live && Date.now() - stat.mtimeMs > STALE) live = false;
if (live) break;
try { fs.unlinkSync(p); } catch {}
}
}
}
return null;
}
const claimed = tryAcquire();
if (claimed) {
process.stdout.write('CLAIMED:' + claimed + '\\n');
setTimeout(() => {}, 5000);
} else {
process.stdout.write('SKIPPED\\n');
}
`;
const N = 10;
const claimers = Array.from({ length: N }, () =>
spawn(process.execPath, ['-e', claimerScript], {
stdio: ['ignore', 'pipe', 'ignore'],
detached: false,
}),
);
try {
// Wait until every claimer has printed its decision.
const decisions = await Promise.all(
claimers.map(
(c) =>
new Promise<string>((resolve) => {
let buf = '';
c.stdout!.on('data', (d) => {
buf += d.toString();
if (buf.includes('\n')) resolve(buf.split('\n')[0]);
});
c.on('exit', () => resolve(buf.split('\n')[0] || 'EXIT'));
}),
),
);
const claimedCount = decisions.filter((d) => d.startsWith('CLAIMED:')).length;
const skippedCount = decisions.filter((d) => d === 'SKIPPED').length;
// HARD CAP: never more than 3 winners, regardless of how many bursts.
expect(claimedCount).toBeLessThanOrEqual(3);
// And the remainder must have all explicitly skipped.
expect(claimedCount + skippedCount).toBe(N);
// On-disk state matches.
const liveSlots = fs
.readdirSync(lockDir)
.filter((f) => /^slot-\d+\.lock$/.test(f))
.filter((f) => {
try {
const o = Number.parseInt(fs.readFileSync(path.join(lockDir, f), 'utf-8').trim(), 10);
return Number.isFinite(o) && o > 0;
} catch {
return false;
}
});
expect(liveSlots.length).toBeLessThanOrEqual(3);
} finally {
for (const c of claimers) {
try {
c.kill();
} catch {
/* ignore */
}
}
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
}
});
}
});
// ─── Source: cross-platform DB lock probe module (#1493) ─────────────
describe('Cross-platform DB lock probe (source)', () => {
for (const [label, hookPath, probePath] of [
['CJS', CJS_HOOK, CJS_HOOK_DB_PROBE],
['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_DB_PROBE],
] as const) {
it(`${label} probe file exists`, () => {
expect(fs.existsSync(probePath)).toBe(true);
});
it(`${label} hook requires hook-db-lock-probe.cjs`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain("require('./hook-db-lock-probe.cjs')");
});
it(`${label} probe covers Linux /proc, Unix lsof, and Windows Restart Manager`, () => {
const p = fs.readFileSync(probePath, 'utf-8');
expect(p).toContain('win-rm-list-json.ps1');
expect(p).toContain('/proc/');
expect(p).toContain('linuxProcScanFindGitNexusServer');
expect(p).toContain('unixLsofPsFindGitNexusServer');
expect(p).toContain('hasGitNexusServerOwnerWindows');
expect(p).toContain('GITNEXUS_HOOK_LSOF_PATH');
expect(p).toContain('GITNEXUS_HOOK_POWERSHELL_PATH');
expect(p).toContain('GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS');
// #2163: lsof/ps orphan containment via a self-tested coreutils
// timeout/gtimeout wrapper.
expect(p).toContain('GITNEXUS_HOOK_TIMEOUT_PATH');
expect(p).toContain('resolveUnixGuardTimeout');
});
}
// T5 (#2163): the two probe copies were only kept in sync by convention
// (setup.ts copies the canonical gitnexus/hooks/claude/ file; the plugin
// ships its own). Enforce byte-parity in CI, mirroring the
// resolve-analyze-cmd.cjs parity test. `.gitattributes` pins `eol=lf`
// repo-wide, so the byte comparison is safe on the Windows lane too.
it('keeps the two hook-db-lock-probe.cjs copies byte-identical', () => {
expect(fs.readFileSync(CJS_HOOK_DB_PROBE, 'utf-8')).toBe(
fs.readFileSync(PLUGIN_HOOK_DB_PROBE, 'utf-8'),
);
});
});
// ─── Source: hook slot must gate the DB-owner probe (#2163) ──────────
describe('Hook slot gates the DB-owner probe (source order, #2163)', () => {
const ANTIGRAVITY_HOOK = path.resolve(
__dirname,
'..',
'..',
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
// T1: pin cheap guards → acquireHookSlot → probe. The probe spawns lsof/ps,
// so it must sit BEHIND the per-repo slot cap; and the acquire must stay
// AFTER the cheap gating (extractPattern), or every tool call churns slot
// files. The antigravity adapter splits the cheap gating (extractPattern in
// buildAfterToolContext) from probe+augment (runAugment), so its slice
// spans both functions to express the same call-order contract.
for (const [label, hookPath, sliceStart, sliceEnd] of [
['CJS', CJS_HOOK, 'function handlePreToolUse', 'function handlePostToolUse'],
['Plugin', PLUGIN_HOOK, 'function handlePreToolUse', 'function handlePostToolUse'],
[
'Antigravity',
ANTIGRAVITY_HOOK,
'function buildAfterToolContext',
'function buildStaleIndexHint',
],
] as const) {
it(`${label}: extractPattern → acquireHookSlot → hasGitNexusServerOwner`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const start = source.indexOf(sliceStart);
const end = source.indexOf(sliceEnd);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
const slice = source.slice(start, end);
const patternIdx = slice.indexOf('extractPattern(');
const acquireIdx = slice.indexOf('acquireHookSlot(');
const probeIdx = slice.indexOf('hasGitNexusServerOwner(');
expect(patternIdx).toBeGreaterThanOrEqual(0);
expect(acquireIdx).toBeGreaterThan(patternIdx);
expect(probeIdx).toBeGreaterThan(acquireIdx);
});
}
});
// ─── Behavior: slot-gated probe + wrapper-reaped orphans (#2163) ─────
describe.skipIf(process.platform === 'win32')(
'DB-owner probe is gated behind the hook slot (behavior, #2163)',
() => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: when all slots are full, the lsof probe never runs`, async () => {
const { spawn } = await import('child_process');
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// REQUIRED: the probe's first guard is
// `if (!fs.existsSync(dbPath)) return false;` — without a real lbug
// file the probe never reaches lsof even before the fix and this
// test would pass vacuously.
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const lsofMarkerPath = path.join(os.tmpdir(), `gn-hook-slotgate-${process.pid}-${label}`);
fs.rmSync(lsofMarkerPath, { force: true });
const binDir = createHookToolDir({
lsofMarkerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
// Fill all 3 slots with live sleeper PIDs (same pattern as the
// concurrency-guard integration tests above).
const sleepers = [0, 1, 2].map(() =>
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
stdio: 'ignore',
detached: false,
}),
);
const writtenLocks: string[] = [];
try {
for (let i = 0; i < sleepers.length; i++) {
const p = path.join(lockDir, `slot-${i}.lock`);
fs.writeFileSync(p, String(sleepers[i].pid));
writtenLocks.push(p);
}
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
// Force the Linux /proc scan to fall through to lsof
// immediately. Must be '1' — do NOT "simplify" to '0': the
// current parser (`Number(raw && String(raw).trim())`)
// treats '0' as falsy and falls back to the 1200ms default.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
expect(result.stdout.trim()).toBe('');
// Core assertion: the probe (and therefore its lsof child) never
// ran — the slot gate now sits in front of it. Before the fix the
// probe ran un-gated and the marker existed.
expect(fs.existsSync(lsofMarkerPath)).toBe(false);
} finally {
for (const child of sleepers) {
try {
child.kill();
} catch {
/* ignore */
}
}
for (const p of writtenLocks) {
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(lsofMarkerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
}
// F5-3 (#2165 review): behavior-level slot-gate coverage for the
// ANTIGRAVITY adapter (the loop above only covers CJS/Plugin; the
// antigravity copy was pinned at source level only). The source adapter
// requires sibling helpers that live in hooks/claude/ — it is designed to
// be installed by copy (see the antigravity e2e suite) — so stage adapter
// + helpers into a temp dir and spawn that copy directly.
it('Antigravity: when all slots are full, the lsof probe never runs', async () => {
const { spawn } = await import('child_process');
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-antigravity-stage-'));
const antigravitySrc = path.resolve(
__dirname,
'..',
'..',
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
const claudeHooksDir = path.resolve(__dirname, '..', '..', 'hooks', 'claude');
const stagedHook = path.join(stageDir, 'gitnexus-antigravity-hook.cjs');
fs.copyFileSync(antigravitySrc, stagedHook);
for (const helper of [
'hook-lock.cjs',
'hook-db-lock-probe.cjs',
'resolve-analyze-cmd.cjs',
'win-rm-list-json.ps1',
]) {
fs.copyFileSync(path.join(claudeHooksDir, helper), path.join(stageDir, helper));
}
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// REQUIRED: without a real lbug file the probe never reaches lsof even
// before the fix and this test would pass vacuously.
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const lsofMarkerPath = path.join(os.tmpdir(), `gn-hook-slotgate-${process.pid}-antigravity`);
fs.rmSync(lsofMarkerPath, { force: true });
const binDir = createHookToolDir({
lsofMarkerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
const sleepers = [0, 1, 2].map(() =>
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
stdio: 'ignore',
detached: false,
}),
);
const writtenLocks: string[] = [];
try {
for (let i = 0; i < sleepers.length; i++) {
const p = path.join(lockDir, `slot-${i}.lock`);
fs.writeFileSync(p, String(sleepers[i].pid));
writtenLocks.push(p);
}
const result = runHook(
stagedHook,
{
hook_event_name: 'AfterTool',
tool_name: 'search_file_content',
tool_input: { pattern: 'validateUser' },
tool_response: { llmContent: '...' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
// '1', NOT '0' — see the CJS/Plugin slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
// Guards against a vacuous pass: if the staged copy crashes (e.g. a
// future sibling require missing from the staging list), stdout is
// empty and the marker absent for the wrong reason.
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('');
// Core assertion: the probe (and therefore its lsof child) never ran —
// runAugment bailed at the slot gate before hasGitNexusServerOwner.
expect(fs.existsSync(lsofMarkerPath)).toBe(false);
} finally {
for (const child of sleepers) {
try {
child.kill();
} catch {
/* ignore */
}
}
for (const p of writtenLocks) {
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(lsofMarkerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(stageDir, { recursive: true, force: true });
}
});
},
);
describe.skipIf(process.platform !== 'linux')(
'Orphaned lsof is reaped by the timeout wrapper (#2163)',
() => {
// T3 — minimal reproduction of the incident mechanism: the hook process
// is SIGKILLed (modeling Claude Code's 10s hook timeout) while a slow,
// SIGTERM-immune lsof child is still running. Before the fix nothing can
// signal that child anymore (and spawnSync's own SIGTERM is ignored
// anyway), so it survives its full 30s sleep → test red regardless of
// race timing. After the fix the coreutils `timeout -k 1` wrapper
// outlives the hook and SIGKILLs the child within ~3s — making this also
// a direct regression test for the wrapper's `-k` capability.
it('CJS: SIGKILLed hook leaves no immortal lsof child', async () => {
// Guard-availability precheck — see resolveHostGuardForReapingTests.
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
const { spawn } = await import('child_process');
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const pidFile = path.join(os.tmpdir(), `gn-hook-lsofpid-${process.pid}`);
fs.rmSync(pidFile, { force: true });
const binDir = createHookToolDir({
lsofPidFile: pidFile,
lsofSleepMs: 30000,
lsofIgnoreSigterm: true,
});
let lsofPid = 0;
let hookChild: ReturnType<typeof spawn> | null = null;
const isFakeLsofAlive = () => {
try {
process.kill(lsofPid, 0);
} catch {
return false; // ESRCH — reaped
}
// PID-reuse guard: only count it alive while the cmdline still
// points at our fake lsof.
try {
return fs.readFileSync(`/proc/${lsofPid}/cmdline`, 'utf-8').includes(binDir);
} catch {
return false;
}
};
try {
hookChild = spawn(process.execPath, [CJS_HOOK], {
stdio: ['pipe', 'ignore', 'ignore'],
env: {
...hookEnv(binDir),
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
// Hermeticity: hookEnv() spreads process.env, so a stray
// GITNEXUS_HOOK_TIMEOUT_PATH=disabled left in a developer shell
// would turn the wrapper off and fake-red this test. Empty string
// falls through to the built-in candidates (the path under test).
GITNEXUS_HOOK_TIMEOUT_PATH: '',
},
});
hookChild.stdin!.end(
JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
}),
);
// The fake lsof writes its PID as its FIRST statement; poll tightly.
const spawnDeadline = Date.now() + 8000;
while (Date.now() < spawnDeadline) {
try {
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
if (raw) {
lsofPid = Number.parseInt(raw, 10);
break;
}
} catch {
/* not written yet */
}
await new Promise((r) => setTimeout(r, 10));
}
expect(lsofPid).toBeGreaterThan(0);
// Kill the hook while its lsof child is alive.
hookChild.kill('SIGKILL');
const reapDeadline = Date.now() + 5000;
let alive = isFakeLsofAlive();
while (alive && Date.now() < reapDeadline) {
await new Promise((r) => setTimeout(r, 100));
alive = isFakeLsofAlive();
}
expect(alive).toBe(false);
} finally {
// PID-reuse guard (#2169 review): re-run the detection loop's
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL, so
// a PID already reaped and recycled by the OS is never signalled.
if (lsofPid > 0 && isFakeLsofAlive()) {
try {
process.kill(lsofPid, 'SIGKILL');
} catch {
/* already gone */
}
}
try {
hookChild?.kill('SIGKILL');
} catch {
/* ignore */
}
// The hook claims a slot before probing now; it died holding it.
const lockDir = path.join(gitNexusDir, '.hook-locks');
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(pidFile, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
}, 30000);
// F3 (#2165 review): GITNEXUS_HOOK_TIMEOUT_PATH pointing at an EXISTING
// but unusable path (here: a directory) must not silently disable orphan
// containment. Before the fix, fs.existsSync() accepted the directory as
// THE candidate, its self-test failed, and the wrapper was memoized off —
// no fall-through — so the SIGTERM-immune lsof below survived its full
// 30s sleep. After the fix the env candidate merely goes first in the
// candidate list; failing its self-test falls through to the built-in
// coreutils guard, which still reaps the orphan within ~3s.
it('CJS: env guard pointing at a directory falls through to a working built-in guard', async () => {
// Guard-availability precheck — see resolveHostGuardForReapingTests.
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
const { spawn } = await import('child_process');
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const pidFile = path.join(os.tmpdir(), `gn-hook-lsofpid-dirguard-${process.pid}`);
fs.rmSync(pidFile, { force: true });
const guardDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-guard-dir-'));
const binDir = createHookToolDir({
lsofPidFile: pidFile,
lsofSleepMs: 30000,
lsofIgnoreSigterm: true,
});
let lsofPid = 0;
let hookChild: ReturnType<typeof spawn> | null = null;
const isFakeLsofAlive = () => {
try {
process.kill(lsofPid, 0);
} catch {
return false; // ESRCH — reaped
}
try {
return fs.readFileSync(`/proc/${lsofPid}/cmdline`, 'utf-8').includes(binDir);
} catch {
return false;
}
};
try {
hookChild = spawn(process.execPath, [CJS_HOOK], {
stdio: ['pipe', 'ignore', 'ignore'],
env: {
...hookEnv(binDir),
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
// Exists but is a directory — spawning it fails the lazy
// self-test, forcing the fall-through path under test.
GITNEXUS_HOOK_TIMEOUT_PATH: guardDir,
},
});
hookChild.stdin!.end(
JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
}),
);
const spawnDeadline = Date.now() + 8000;
while (Date.now() < spawnDeadline) {
try {
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
if (raw) {
lsofPid = Number.parseInt(raw, 10);
break;
}
} catch {
/* not written yet */
}
await new Promise((r) => setTimeout(r, 10));
}
expect(lsofPid).toBeGreaterThan(0);
// Kill the hook while its lsof child is alive (the incident topology).
hookChild.kill('SIGKILL');
const reapDeadline = Date.now() + 5000;
let alive = isFakeLsofAlive();
while (alive && Date.now() < reapDeadline) {
await new Promise((r) => setTimeout(r, 100));
alive = isFakeLsofAlive();
}
expect(alive).toBe(false);
} finally {
// PID-reuse guard (#2169 review): re-run the detection loop's
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL, so
// a PID already reaped and recycled by the OS is never signalled.
if (lsofPid > 0 && isFakeLsofAlive()) {
try {
process.kill(lsofPid, 'SIGKILL');
} catch {
/* already gone */
}
}
try {
hookChild?.kill('SIGKILL');
} catch {
/* ignore */
}
const lockDir = path.join(gitNexusDir, '.hook-locks');
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(pidFile, { force: true });
fs.rmSync(guardDir, { recursive: true, force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
}, 30000);
},
);
// ─── Behavior: SIGKILLed hook cannot strand the augment CLI (#2163 f-up) ──
describe.skipIf(process.platform !== 'linux')(
'Orphaned augment CLI is reaped by the timeout wrapper (#2163 follow-up)',
() => {
// Same incident mechanism as the lsof reaping suite above, one layer up:
// the augment CLI is the longest-lived hook child (7s local / 12s npx
// inner budgets), so a hook SIGKILLed mid-augment used to strand it with
// nothing left to signal it. These tests pin GITNEXUS_HOOK_CLI_PATH, so
// they exercise the DIRECT-EXEC branch only (the CLI is the guard's
// direct child): the fake CLI here is SIGTERM-immune and sleeps 30s;
// with the wrap in place the guard SIGTERMs it at 8s (= ceil(7000/1000)
// +1) and the `-k` escalation SIGKILLs it 1s later, so it must be gone
// well inside the 12s poll window. (The npx branch reaps differently —
// `-s KILL` group-kills at budget because the CLI is a grandchild there;
// see the staged npx suite below.) With runGitNexusCli's wrap reverted,
// nothing can reap it and the poll times out → red. The antigravity
// adapter shares the identical runGitNexusCli shape and is pinned at
// source level (see 'Augment CLI guard wrap (source)').
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: SIGKILLed hook leaves no immortal augment CLI child`, async () => {
// Guard-availability precheck — see resolveHostGuardForReapingTests.
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
const { spawn } = await import('child_process');
// REQUIRED: a real lbug file routes the probe through the fake lsof
// (empty output → no holder PIDs → probe false) so the augment runs
// through the same probe-then-spawn flow as production.
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const pidFile = path.join(os.tmpdir(), `gn-hook-clipid-${process.pid}-${label}`);
fs.rmSync(pidFile, { force: true });
const binDir = createHookToolDir({
gitnexusPidFile: pidFile,
gitnexusSleepMs: 30000,
gitnexusIgnoreSigterm: true,
lsofOutput: '',
psOutput: '',
});
let cliPid = 0;
let hookChild: ReturnType<typeof spawn> | null = null;
const isFakeCliAlive = () => {
try {
process.kill(cliPid, 0);
} catch {
return false; // ESRCH — reaped
}
// PID-reuse guard: only count it alive while the cmdline still
// points at our fake CLI.
try {
return fs.readFileSync(`/proc/${cliPid}/cmdline`, 'utf-8').includes(binDir);
} catch {
return false;
}
};
try {
hookChild = spawn(process.execPath, [hookPath], {
stdio: ['pipe', 'ignore', 'ignore'],
env: {
...hookEnv(binDir),
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
// Hermeticity: a dev-shell GITNEXUS_HOOK_TIMEOUT_PATH=disabled
// would unwrap the CLI and fake-red this test. Empty string
// falls through to the built-in candidates (the path under test).
GITNEXUS_HOOK_TIMEOUT_PATH: '',
},
});
hookChild.stdin!.end(
JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
}),
);
// The fake CLI writes its PID as its FIRST statement; poll tightly.
const spawnDeadline = Date.now() + 8000;
while (Date.now() < spawnDeadline) {
try {
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
if (raw) {
cliPid = Number.parseInt(raw, 10);
break;
}
} catch {
/* not written yet */
}
await new Promise((r) => setTimeout(r, 10));
}
expect(cliPid).toBeGreaterThan(0);
// Kill the hook while its augment CLI child is alive.
hookChild.kill('SIGKILL');
// Wrapper budget for the 7000ms call site is 8s, plus 1s `-k`
// grace — poll past that with margin, far short of the 30s sleep.
const reapDeadline = Date.now() + 12000;
let alive = isFakeCliAlive();
while (alive && Date.now() < reapDeadline) {
await new Promise((r) => setTimeout(r, 100));
alive = isFakeCliAlive();
}
expect(alive).toBe(false);
} finally {
// PID-reuse guard (#2169 review): re-run the detection loop's
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL,
// so a PID already reaped and recycled by the OS is never
// signalled.
if (cliPid > 0 && isFakeCliAlive()) {
try {
process.kill(cliPid, 'SIGKILL');
} catch {
/* already gone */
}
}
try {
hookChild?.kill('SIGKILL');
} catch {
/* ignore */
}
// The hook claims a slot before probing; it died holding it.
const lockDir = path.join(gitNexusDir, '.hook-locks');
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(pidFile, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
}, 30000);
}
},
);
// ─── Behavior: npx branch — SIGKILLed hook cannot strand the CLI grandchild ──
describe.skipIf(process.platform !== 'linux')(
'Orphaned npx-branch CLI grandchild is reaped by the -s KILL wrapper (#2163 follow-up)',
() => {
// The npx branch has a DEEPER topology than the direct-exec suite above:
// guard → npx → CLI, so the CLI is the guard's GRANDCHILD. Under the
// TERM-first `-k 1` guard the budget's group SIGTERM kills the obedient
// npx parent; `timeout` reaps its direct child and exits IMMEDIATELY, so
// its `-k` SIGKILL never fires — and a SIGTERM-immune CLI grandchild
// survives unbounded (reproduced on coreutils 9.x). The `-s KILL`
// wrapped arm instead SIGKILLs the whole process group at budget
// (13s = ceil((7000+5000)/1000)+1 here), which nothing can ignore.
// Reverting the npx arm to plain `-k 1` TERM-first makes this test red.
//
// Topology notes: the hook is STAGED into a bare temp dir together with
// its sibling helpers (the install-shaped copy, like the antigravity e2e
// suite uses), so resolveCliPath() finds no local dist/ and no
// resolvable gitnexus package; with GITNEXUS_HOOK_CLI_PATH cleared the
// npx fallback branch is the one that runs. A fake `npx` injected on
// PATH then spawns the SIGTERM-immune fake CLI as its own child and
// waits on it, mirroring the real npx process tree.
it('CJS (staged): SIGKILLed hook leaves no immortal CLI grandchild behind npx', async () => {
// Guard-availability precheck — see resolveHostGuardForReapingTests.
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
const { spawn } = await import('child_process');
// REQUIRED: a real lbug file routes the probe through the fake lsof
// (empty output → no holder PIDs → probe false) so the augment runs
// through the same probe-then-spawn flow as production.
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const pidFile = path.join(os.tmpdir(), `gn-hook-npxclipid-${process.pid}`);
fs.rmSync(pidFile, { force: true });
// Route self-proof (#2169 review): written by the fake npx as its first
// statement, so the test fails loudly if a future resolveCliPath /
// hookEnv change silently re-routes the augment to the direct arm.
const npxMarkerPath = path.join(os.tmpdir(), `gn-hook-npxmarker-${process.pid}`);
fs.rmSync(npxMarkerPath, { force: true });
const binDir = createHookToolDir({
gitnexusPidFile: pidFile,
gitnexusSleepMs: 30000,
gitnexusIgnoreSigterm: true,
lsofOutput: '',
psOutput: '',
});
// Fake npx: spawns the fake CLI as the guard's grandchild and waits on
// it like real npx; npx itself stays SIGTERM-obedient (Node default).
fs.writeFileSync(
path.join(binDir, 'npx'),
`#!/usr/bin/env node\n` +
`require('fs').writeFileSync(${JSON.stringify(npxMarkerPath)}, String(process.pid));\n` +
`const { spawn } = require('child_process');\n` +
`const child = spawn(process.execPath, [${JSON.stringify(
path.join(binDir, 'gitnexus-cli.js'),
)}], { stdio: 'ignore' });\n` +
`child.on('exit', (code) => process.exit(code === null ? 1 : code));\n`,
{ mode: 0o755 },
);
// Stage the hook + its sibling helpers into a bare dir with no dist/
// and no reachable node_modules/gitnexus, so resolveCliPath() → ''.
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-staged-hook-'));
const hookSrcDir = path.dirname(CJS_HOOK);
for (const f of [
'gitnexus-hook.cjs',
'hook-lock.cjs',
'hook-db-lock-probe.cjs',
'resolve-analyze-cmd.cjs',
]) {
fs.copyFileSync(path.join(hookSrcDir, f), path.join(stagedDir, f));
}
const stagedHook = path.join(stagedDir, 'gitnexus-hook.cjs');
let cliPid = 0;
let hookChild: ReturnType<typeof spawn> | null = null;
const isFakeCliAlive = () => {
try {
process.kill(cliPid, 0);
} catch {
return false; // ESRCH — reaped
}
// PID-reuse guard: only count it alive while the cmdline still
// points at our fake CLI.
try {
return fs.readFileSync(`/proc/${cliPid}/cmdline`, 'utf-8').includes(binDir);
} catch {
return false;
}
};
try {
hookChild = spawn(process.execPath, [stagedHook], {
stdio: ['pipe', 'ignore', 'ignore'],
env: {
...hookEnv(binDir),
// Force the npx fallback: no CLI-path override (empty string
// fails resolveCliPath's trim check), and nothing for the staged
// copy's require.resolve to find via NODE_PATH.
GITNEXUS_HOOK_CLI_PATH: '',
NODE_PATH: '',
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
// Hermeticity: fall through to the built-in guard candidates.
GITNEXUS_HOOK_TIMEOUT_PATH: '',
},
});
hookChild.stdin!.end(
JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
}),
);
// The fake CLI writes its PID as its FIRST statement; poll tightly.
const spawnDeadline = Date.now() + 8000;
while (Date.now() < spawnDeadline) {
try {
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
if (raw) {
cliPid = Number.parseInt(raw, 10);
break;
}
} catch {
/* not written yet */
}
await new Promise((r) => setTimeout(r, 10));
}
expect(cliPid).toBeGreaterThan(0);
// The augment really took the npx fallback arm, not the direct arm.
expect(fs.existsSync(npxMarkerPath)).toBe(true);
// Kill the hook while the npx → CLI chain is alive (orphan topology).
hookChild.kill('SIGKILL');
// The npx call site's wrapper budget is 13s (= ceil((7000+5000)/
// 1000)+1) from the guard's start; the group SIGKILL lands then.
// Poll past it with margin, far short of the CLI's 30s sleep.
const reapDeadline = Date.now() + 18000;
let alive = isFakeCliAlive();
while (alive && Date.now() < reapDeadline) {
await new Promise((r) => setTimeout(r, 100));
alive = isFakeCliAlive();
}
expect(alive).toBe(false);
} finally {
// PID-reuse guard (#2169 review): re-run the detection loop's
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL, so
// a PID already reaped and recycled by the OS is never signalled.
if (cliPid > 0 && isFakeCliAlive()) {
try {
process.kill(cliPid, 'SIGKILL');
} catch {
/* already gone */
}
}
try {
hookChild?.kill('SIGKILL');
} catch {
/* ignore */
}
// The hook claims a slot before probing; it died holding it.
const lockDir = path.join(gitNexusDir, '.hook-locks');
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(pidFile, { force: true });
fs.rmSync(npxMarkerPath, { force: true });
fs.rmSync(stagedDir, { recursive: true, force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
}, 45000);
},
);
// ─── Wrapping equivalence: disabled guard ⇒ pre-wrap augment behavior ──
describe.skipIf(process.platform === 'win32')(
'Augment CLI guard wrap degrades cleanly when disabled (#2163 follow-up)',
() => {
// T6-style equivalence pin for the AUGMENT path: with the wrapper
// explicitly off (the `disabled` sentinel, NOT a bogus path — invalid
// values fall through to the real candidate list by design) the augment
// path must behave exactly as it did before the wrap existed: the probe
// fails open on an idle DB, the CLI runs unwrapped, and its context is
// emitted verbatim. (The wrapped arm's equivalence is covered by the
// whole existing augment suite, which now runs under the host's real
// guard on the Linux/macOS lanes.)
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: augment runs and emits context with the wrapper disabled`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-nowrap-aug-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled',
},
},
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
}
},
);
// ─── Source: augment CLI guard wrap present in every adapter (#2163 f-up) ──
describe('Augment CLI guard wrap (source, #2163 follow-up)', () => {
const ANTIGRAVITY_HOOK = path.resolve(
__dirname,
'..',
'..',
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
// The cursor integration is deliberately ABSENT from this list: it does
// not install hook-db-lock-probe.cjs (see gitnexus-cursor-integration/
// README.md "What's installed manually vs. automated"), so there is no
// resolver sibling to require — wrapping its augment child is the
// "cursor probe" item on the #2163 follow-up list.
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
['Antigravity', ANTIGRAVITY_HOOK],
] as const) {
it(`${label}: runGitNexusCli wraps via resolveUnixGuardTimeout with a ceil(ms/1000)+1 budget`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const start = source.indexOf('function runGitNexusCli');
expect(start).toBeGreaterThanOrEqual(0);
const end = source.indexOf('\nfunction ', start + 1);
const fn = source.slice(start, end === -1 ? undefined : end);
// Consults the probe's exported resolver (memo shared with the probe),
// and never on Windows — the npx.cmd / gitnexus.cmd argv stay exactly
// as before the wrap. The typeof check is the probe version-skew guard
// (#2169 review): an old probe without the resolveUnixGuardTimeout
// export must degrade to the unwrapped argv, not throw a TypeError
// that the caller's catch swallows into a silently dead augment.
expect(fn).toMatch(
/isWin \|\| typeof resolveUnixGuardTimeout !== 'function'\s*\?\s*null\s*:\s*resolveUnixGuardTimeout\(\)/,
);
// Coreutils `-k 1` escalation…
expect(fn).toContain("'-k',");
// …with a budget STRICTLY above each branch's inner spawnSync timeout:
// ceil(inner/1000)+1 for both the direct (timeout) and npx
// (timeout + 5000) call sites. The direct-budget formula is counted
// exactly — once per wrapped direct-exec branch (the Plugin adapter has
// two: GITNEXUS_HOOK_CLI_PATH and the PATH-direct `gitnexus` branch,
// its most common production path) — so a partial revert of any single
// branch cannot pass unnoticed.
const directBudgetCount = (fn.match(/Math\.ceil\(timeout \/ 1000\) \+ 1/g) ?? []).length;
expect(directBudgetCount).toBe(label === 'Plugin' ? 2 : 1);
expect(fn).toMatch(/Math\.ceil\(\(timeout \+ 5000\) \/ 1000\) \+ 1/);
// Argv-order pin (#2169 review): the budget token must appear BEFORE
// the command word — `timeout … <budget> <cmd>` — or coreutils would
// parse the command word as its DURATION argument. Token presence and
// the counts above alone would let a transposed argv pass. Every
// direct-exec budget must be immediately followed by its command token
// (process.execPath, or the PATH-direct 'gitnexus' on Plugin), and the
// npx budget by 'npx'.
const directOrderCount = (
fn.match(
/String\(Math\.ceil\(timeout \/ 1000\) \+ 1\),\s*(?:process\.execPath|'gitnexus')/g,
) ?? []
).length;
expect(directOrderCount).toBe(label === 'Plugin' ? 2 : 1);
expect(fn).toMatch(/String\(Math\.ceil\(\(timeout \+ 5000\) \/ 1000\) \+ 1\),\s*'npx'/);
// npx-branch grandchild containment (#2169 review): the npx wrapped
// arm must SIGKILL the process group at budget (`-s KILL`) — a group
// SIGTERM there kills only the obedient npx parent, `timeout` returns
// before its `-k` escalation fires, and a SIGTERM-immune CLI
// grandchild escapes unbounded.
expect(fn).toMatch(
/'-s',\s*'KILL',\s*'-k',\s*'1',\s*String\(Math\.ceil\(\(timeout \+ 5000\)/,
);
// …and the direct-exec arm(s) must NOT lead with `-s KILL`: TERM-first
// is gentler and sufficient there (the CLI is the guard's direct
// child), so `-s` appears exactly once — in the npx arm.
expect((fn.match(/'-s',/g) ?? []).length).toBe(1);
});
}
for (const [label, probePath] of [
['CJS', CJS_HOOK_DB_PROBE],
['Plugin', PLUGIN_HOOK_DB_PROBE],
] as const) {
it(`${label} probe exports resolveUnixGuardTimeout`, () => {
const source = fs.readFileSync(probePath, 'utf-8');
const exportsSlice = source.slice(source.indexOf('module.exports'));
expect(exportsSlice).toContain('resolveUnixGuardTimeout');
});
}
});
// ─── Source: cursor hook slot-skip diagnostic (#2163 follow-up) ─────
describe('Cursor hook slot-skip diagnostic (source, #2163 follow-up)', () => {
const CURSOR_HOOK = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-cursor-integration',
'hooks',
'gitnexus-hook.cjs',
);
it('debug-gates the slot-saturated skip under the cursor truthy convention', () => {
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
const idx = source.indexOf('augment skipped: hook slots saturated');
expect(idx).toBeGreaterThanOrEqual(0);
// Must sit inside the cursor hook's own debug gate (truthy
// `process.env.GITNEXUS_DEBUG`, unlike the claude adapters' strict
// '1'/'true' gate) so the default path stays silent.
const before = source.slice(Math.max(0, idx - 600), idx);
expect(before).toContain('process.env.GITNEXUS_DEBUG');
});
});
// ─── Integration: PreToolUse augmentation filtering (#1492) ─────────
describe('PreToolUse augmentation filtering (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: emits valid GitNexus augmentation context`, () => {
const binDir = createHookToolDir({
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.hookEventName).toBe('PreToolUse');
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
} finally {
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: suppresses LadybugDB lock warnings from augment stderr`, () => {
const markerPath = path.join(os.tmpdir(), 'gn-hook-lockwarn-' + process.pid + '-' + label);
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr:
'GitNexus: FTS extension load failed: IO exception: Could not set lock on file : /tmp/repo/.gitnexus/lbug\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
expect(result.stdout.trim()).toBe('');
expect(fs.existsSync(markerPath)).toBe(true);
// Finding #18: when GITNEXUS_DEBUG=1 is set, the discarded prefix is
// recoverable on the hook's stderr (not silently dropped).
const debugResult = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(debugResult.stderr).toContain('augment stderr discarded prefix');
expect(debugResult.stderr).toContain('Could not set lock on file');
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// Issue #1913: the MCP-owned-DB skip is a NORMAL (non-error) path, so by
// default it must stay completely silent — empty stdout AND empty stderr,
// exit 0 — so strict hook runners (e.g. Codex `PreToolUse`) never see
// unexpected output. GITNEXUS_DEBUG is forced off to keep the assertion
// deterministic regardless of the ambient environment.
it.skipIf(process.platform === 'win32')(
`${label}: skips augment SILENTLY when a GitNexus MCP process owns the repo DB`,
() => {
const markerPath = path.join(os.tmpdir(), `gitnexus-hook-called-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.stderr.trim()).toBe('');
expect(result.status).toBe(0);
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
},
);
// Issue #1913: the skip reason remains recoverable for operators who opt in
// via GITNEXUS_DEBUG=1 — stdout stays empty (no augment ran), the diagnostic
// appears on stderr.
it.skipIf(process.platform === 'win32')(
`${label}: surfaces the MCP-owner skip reason only under GITNEXUS_DEBUG`,
() => {
const markerPath = path.join(os.tmpdir(), `gitnexus-hook-dbg-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
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 });
}
},
);
// #1913: the GITNEXUS_DEBUG contract is strict — ONLY '1' and 'true' enable
// diagnostics. Pin that non-canonical truthy-looking values ('0', 'false')
// are treated as OFF, so the skip stays silent. A truthy-gated reader would
// have emitted on these; this guards the unified strict gate (incl. the
// main() catch handler) across the claude/plugin copies.
for (const debugValue of ['0', 'false']) {
it.skipIf(process.platform === 'win32')(
`${label}: MCP-owner skip stays SILENT with GITNEXUS_DEBUG='${debugValue}' (strict contract)`,
() => {
const markerPath = path.join(
os.tmpdir(),
`gitnexus-hook-dbg-${debugValue}-${process.pid}-${label}`,
);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: debugValue } },
);
expect(result.stdout.trim()).toBe('');
expect(result.stderr.trim()).toBe('');
expect(result.status).toBe(0);
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.skipIf(process.platform === 'win32')(
'Ladybug DB owner guard — production-shaped ps + failure modes (#1493)',
() => {
// These tests assert owner *detection*: a positive skip is signalled by the
// `[GitNexus] augment skipped` diagnostic. Since #1913 made that diagnostic
// debug-gated (silent by default for strict hook runners), they run with
// GITNEXUS_DEBUG=1 so the discriminator remains observable. Default-silence
// itself is covered by the 'augmentation filtering' describe above.
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: skips augment for real node_modules/gitnexus ps line (npx child)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-prodps-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '99901\n',
psOutput: 'node /tmp/node_modules/gitnexus/dist/cli/index.js mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: npx parent command line is NOT treated as GitNexus server owner`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-npx-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '99902\n',
psOutput: 'npx -y gitnexus@latest mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: skips augment for gitnexus serve child`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-serve-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '99903\n',
psOutput: 'node /repo/node_modules/gitnexus/dist/cli/index.js serve\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: ENOENT lsof → augment still runs (fail-open)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-enoent-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
try {
const env = {
...hookEnv(binDir),
GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, '__missing_lsof__'),
};
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: ETIMEDOUT lsof → augment skipped (fail-closed)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-etime-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// #1913: the fail-closed (probe-timeout) skip routes through the SAME gated
// line as the MCP-owner skip, so it too must be silent by default. Symmetric
// counterpart to the debug-on test above, so a regression that ungated the
// ETIMEDOUT path specifically would still be caught.
it(`${label}: ETIMEDOUT lsof → augment skipped SILENTLY by default`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-etime-silent-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.stderr.trim()).toBe('');
expect(result.status).toBe(0);
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// T6 (#2163): with the timeout wrapper explicitly disabled the probe must
// degrade to EXACTLY the pre-wrapper behavior — lsof ETIMEDOUT stays
// fail-closed and the augment is silently skipped. Uses the `disabled`
// sentinel, NOT a bogus path: an invalid GITNEXUS_HOOK_TIMEOUT_PATH falls
// through to the real candidate list by design. CI lane note: the sibling
// wrapped ETIMEDOUT tests above exercise GNU /usr/bin/timeout on the
// Linux lane, and on macos-latest hit BSD /usr/bin/timeout (macOS ≥13,
// `-k`-compatible) or Homebrew gtimeout — a de-facto BSD-wrapper
// regression test.
it(`${label}: ETIMEDOUT lsof with wrapper disabled → identical fail-closed skip`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-nowrap-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled',
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// T7 (#2163): a wrapper that fails the `-k` self-test (busybox <1.34,
// toybox, broken symlink…) must be REJECTED — resolution falls through
// to the built-in candidates (or, with none usable, to the unwrapped
// status quo); either way ETIMEDOUT stays fail-closed. Adopted blindly,
// the bad wrapper would exit with a usage error before ever running
// lsof — empty stdout, status≠0, no ETIMEDOUT — silently flipping the
// fail-closed contract to fail-open.
it(`${label}: bad wrapper (no -k support) is rejected by the self-test → still fail-closed`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-badwrap-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
// Models busybox <1.34: `-k` unsupported → usage error, exit 2.
const badTimeout = path.join(binDir, 'bad-timeout');
fs.writeFileSync(
badTimeout,
`#!/usr/bin/env node\nprocess.stderr.write('usage: timeout [-t SECS] [-s SIG] PROG ARGS\\n');\nprocess.exit(2);\n`,
{ mode: 0o755 },
);
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: badTimeout,
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// F5-1 (#2165 review): pin the LIVE arm of the 124 mapping — a guard
// that passes the `-k` self-test and then reports coreutils budget
// expiry (exit 124) must map to "unresponsive holder" → fail-closed
// skip. The fake guard distinguishes the self-test invocation
// (`-k 1 1 /bin/sh -c 'exit 42'`) from a real wrap by the `exit 42`
// argv token, and PROPAGATES the requested status — the self-test now
// demands exit-status propagation (status 42), not just exit 0
// (#2169 review).
it(`${label}: guard exit 124 (budget expiry) → fail-closed skip`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-guard124-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
const fakeGuard = path.join(binDir, 'guard-exit-124');
fs.writeFileSync(
fakeGuard,
`#!/usr/bin/env node\nif (process.argv.includes('exit 42')) process.exit(42);\nprocess.exit(124);\n`,
{ mode: 0o755 },
);
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: fakeGuard,
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// F5-2 (#2165 review): a guard-wrapped probe that dies BY SIGNAL with
// no spawnSync .error must fail closed. coreutils timeout SELF-RAISES
// the signal when `-k` escalates to SIGKILL, so spawnSync sees
// {status: null, signal: 'SIGKILL'} — NOT exit 137. The same shape
// appears when the hook is frozen >2s (SIGSTOP / laptop suspend) and
// resumes after the guard expired. Before the F1 patch this shape fell
// through every check → empty stdout → fail-open, silently inverting
// this call's fail-closed contract.
it(`${label}: guard signal-death (status null + signal, no error) → fail-closed skip`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-guardsig-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
const fakeGuard = path.join(binDir, 'guard-sigkill');
fs.writeFileSync(
fakeGuard,
`#!/usr/bin/env node\nif (process.argv.includes('exit 42')) process.exit(42);\nprocess.kill(process.pid, 'SIGKILL');\n`,
{ mode: 0o755 },
);
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: fakeGuard,
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// F5-4 (#2169 review; F5-3 was taken by the #2165 slot-gate test
// above): an always-exit-0 stub (/bin/true shape) at
// GITNEXUS_HOOK_TIMEOUT_PATH must be REJECTED by the self-test. The
// old self-test only demanded exit 0, which such a stub satisfies
// without ever RUNNING the wrapped command — once adopted it instantly
// "succeeds" every wrapped spawn with empty output, turning the probe
// into a constant no-owner answer and, worse, the augment into a
// silent no-op (status 0 + empty stderr passes the success check with
// no context, so the feature dies without a trace). The propagation
// self-test (`sh -c 'exit 42'` must yield 42) rejects the stub;
// resolution falls through to the built-in candidates (or, with none
// usable, to the unwrapped status quo) and the augment runs for real.
it(`${label}: always-exit-0 stub guard is rejected → augment still runs and emits context`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-stubguard-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
// Models /bin/true: exits 0 for ANY argv without running anything.
const stubGuard = path.join(binDir, 'true-stub');
fs.writeFileSync(stubGuard, `#!/usr/bin/env node\nprocess.exit(0);\n`, { mode: 0o755 });
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_HOOK_TIMEOUT_PATH: stubGuard,
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: non-GitNexus ps line → augment runs`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-other-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '99904\n',
psOutput: '/usr/bin/bash -l\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: multiple PIDs — skip if any ps line is GitNexus MCP`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-multi-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutputLines: ['111', '222'],
psOutputByPid: {
'111': 'vim /tmp/x\n',
'222': 'node /x/node_modules/gitnexus/dist/cli/index.js mcp\n',
},
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: ps ENOENT → augment runs (ignore that PID)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-pseno-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '99905\n',
psOutput: '',
});
try {
const env = {
...hookEnv(binDir),
GITNEXUS_HOOK_PS_PATH: path.join(binDir, '__missing_ps__'),
};
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
}
},
);
// ─── Integration: PostToolUse staleness detection ───────────────────
describe('PostToolUse staleness detection (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: emits stale notification when HEAD differs from meta`, () => {
// Write meta.json with a different commit
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'aaaaaaa0000000000000000000000000deadbeef', 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!.hookEventName).toBe('PostToolUse');
expect(output!.additionalContext).toContain('stale');
expect(output!.additionalContext).toContain('aaaaaaa');
});
it(`${label}: silent when HEAD matches meta lastCommit`, () => {
const head = getHeadCommit();
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,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: silent when tool is not Bash`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Grep',
tool_input: { command: 'git commit -m "test"' },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: silent when command is not a git mutation`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git status' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: silent when exit code is non-zero`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "fail"' },
tool_output: { exit_code: 1 },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: includes --embeddings in suggestion when meta had embeddings`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 42 } }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git merge feature' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('--embeddings');
});
it(`${label}: omits --embeddings when meta had no embeddings`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 0 } }),
);
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).not.toContain('--embeddings');
});
it(`${label}: detects git rebase as a mutation`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git rebase main' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
});
it(`${label}: detects git cherry-pick as a mutation`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git cherry-pick abc123' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
});
it(`${label}: detects git pull as a mutation`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git pull origin main' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
});
}
});
// ─── Integration: cwd validation rejects relative paths ─────────────
describe('cwd validation (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: PostToolUse silent when cwd is relative`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: 'relative/path',
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: PreToolUse silent when cwd is relative`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: 'relative/path',
});
expect(result.stdout.trim()).toBe('');
});
}
});
// ─── Integration: global registry lookup ────────────────────────────
describe('Global registry lookup', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: PostToolUse stays silent for unindexed repo under global registry`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', 'unindexed');
try {
createGlobalRegistry(homeDir);
fs.mkdirSync(repoDir, { recursive: true });
initGitRepo(repoDir);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: repoDir,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
it(`${label}: PreToolUse stays silent for unindexed repo under global registry`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', 'unindexed');
try {
createGlobalRegistry(homeDir);
fs.mkdirSync(repoDir, { recursive: true });
initGitRepo(repoDir);
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: repoDir,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
it(`${label}: PostToolUse emits stale for indexed repo under parent global registry`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', 'indexed-repo');
try {
createGlobalRegistry(homeDir);
fs.mkdirSync(path.join(repoDir, '.gitnexus'), { recursive: true });
initGitRepo(repoDir);
fs.writeFileSync(
path.join(repoDir, '.gitnexus', 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', 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: repoDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
for (const marker of ['registry', 'repos'] as const) {
it(`${label}: PostToolUse skips global registry with only ${marker} marker`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', `unindexed-${marker}`);
try {
createGlobalRegistry(homeDir, marker);
fs.mkdirSync(repoDir, { recursive: true });
initGitRepo(repoDir);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: repoDir,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
}
}
});
// ─── Integration: linked-worktree resolution (#1224) ───────────────
describe('Linked git worktree resolution', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: PostToolUse emits stale from a linked worktree pointing at an indexed canonical repo`, () => {
// Layout mirrors `git worktree add ../<repo>-worktrees/feature-x`:
// <root>/main-repo/.git (canonical)
// <root>/main-repo/.gitnexus/ (only here)
// <root>/main-repo-worktrees/feat/ (linked worktree, no .gitnexus)
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
const mainRepo = path.join(root, 'main-repo');
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
try {
fs.mkdirSync(mainRepo, { recursive: true });
initGitRepo(mainRepo);
fs.mkdirSync(path.join(mainRepo, '.gitnexus'), { recursive: true });
fs.writeFileSync(
path.join(mainRepo, '.gitnexus', 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
// Create the linked worktree on a new branch.
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);
// Sanity: walking up from the worktree never reaches `.gitnexus`.
expect(fs.existsSync(path.join(worktreePath, '.gitnexus'))).toBe(false);
expect(fs.existsSync(path.join(path.dirname(worktreePath), '.gitnexus'))).toBe(false);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: worktreePath,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it(`${label}: PostToolUse silent from a linked worktree when canonical repo has no .gitnexus`, () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
const mainRepo = path.join(root, 'main-repo');
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
try {
fs.mkdirSync(mainRepo, { recursive: true });
initGitRepo(mainRepo);
// Note: NO .gitnexus/ in the canonical repo.
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: worktreePath,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
// ─── Integration: dispatch map routes correctly ─────────────────────
describe('Dispatch map routing (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: unknown hook_event_name produces no output`, () => {
const result = runHook(hookPath, {
hook_event_name: 'UnknownEvent',
tool_name: 'Bash',
tool_input: { command: 'echo hello' },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
});
it(`${label}: empty hook_event_name produces no output`, () => {
const result = runHook(hookPath, {
hook_event_name: '',
tool_name: 'Bash',
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
});
it(`${label}: missing hook_event_name produces no output`, () => {
const result = runHook(hookPath, {
tool_name: 'Bash',
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
});
it(`${label}: invalid JSON input exits cleanly`, () => {
const result = spawnSync(process.execPath, [hookPath], {
input: 'not json at all',
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('');
});
it(`${label}: empty stdin exits cleanly`, () => {
const result = spawnSync(process.execPath, [hookPath], {
input: '',
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(result.status).toBe(0);
});
}
});
// ─── Integration: PostToolUse with missing meta.json ────────────────
describe('PostToolUse with missing/corrupt meta.json', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: emits stale when meta.json does not exist`, () => {
const metaPath = path.join(gitNexusDir, 'meta.json');
const hadMeta = fs.existsSync(metaPath);
if (hadMeta) fs.unlinkSync(metaPath);
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,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('never');
} finally {
// Restore meta.json for subsequent tests
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
}
});
it(`${label}: emits stale when meta.json is corrupt`, () => {
const metaPath = path.join(gitNexusDir, 'meta.json');
fs.writeFileSync(metaPath, 'not valid 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,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('never');
// Restore
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
});
}
});