GitNexus/gitnexus/test/unit/hooks.test.ts
Gergő Magyar f885330b34
fix(cli): steer docs, skills, and hooks through a CLI-neutral project-local runner (#1939) (#1945)
* fix(cli): steer npm 11 users away from npx install crash (#1939)

Prefer global gitnexus or pnpm dlx in hooks and generated AI context, warn
when npm 11.x would use the broken npx path, and document workarounds for
the arborist node.target null failure mode.

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

* test(hooks): stage resolve-analyze-cmd.cjs for antigravity adapter; harden load checks

The antigravity adapter gained a top-level require('./resolve-analyze-cmd.cjs')
but stageAdapter() did not copy it, so the spawned adapter crashed with
MODULE_NOT_FOUND. Three load-sensitive tests failed; four silent-path tests
false-passed on empty stdout.

Stage the helper alongside the other sibling helpers, and assert status===0 and
no MODULE_NOT_FOUND on the four silent-path tests so a non-loading hook can never
pass green again. Force a deterministic invocation mode in the stale-index test
so the emitted analyze command no longer varies by CI-runner PATH.

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

* fix(cli): standardize invocation hints on gitnexus@latest; single-source CJS helper

NPX_REF becomes a literal `gitnexus@latest` in resolve-invocation.ts, dropping
the package.json require and the module-load throw (a malformed/absent version
can no longer crash any CLI command at import). The safety this PR delivers is
the install method steered to (global / pnpm dlx), not a pinned gitnexus
version, and the in-repo CJS mirror already degraded to `latest` once copied
outside the package.

Make the two resolve-analyze-cmd.cjs copies byte-identical and add a parity
test that fails on drift. The separate, version-pinned NPX_REF that setup.ts
writes into the MCP server registration is intentional and left unchanged.

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

* perf(cli): move npm-11 npx warning off module load; memoize invocation mode

warnIfNpm11NpxRisk() ran at index.ts module load, so every CLI invocation
(including the `gitnexus mcp` stdio hot path) paid which/where + npm --version
spawns — against the lazy-startup/MCP-stdout discipline (#207, #1383). Move the
call into analyzeCommand, after the ensureHeap() re-exec guard, so it fires once
in the working process and only for `analyze`.

Memoize the PATH-probe-derived invocation mode (the GITNEXUS_INVOCATION override
stays uncached) so repeated callers don't re-probe, and add a test-only reset so
the cache + once-only warning flag don't leak across the unit suite. Covers the
mode!=='npx', npm<11, and npm-absent suppression branches.

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

* fix(cli): detect .exe/extensionless global gitnexus shims on Windows

The winGitnexusWrapper branch only matched .cmd/.bat, so a global gitnexus
installed by Volta or scoop (a .exe or an extensionless shim) was missed and the
hint fell back to pnpm/npx. Accept .exe and treat any non-empty `where` hit as
on-PATH (the emitted hint is `gitnexus analyze` regardless of which shim
resolves it). Mirror the change into both resolve-analyze-cmd.cjs copies so the
TS source and the byte-identical hook mirrors stay in sync.

Add Windows-mocked test cases (.exe-only, extensionless, .cmd preference, CRLF
stripping) and register resolve-invocation.test.ts in cross-platform-tests.ts so
the windows-latest runner exercises the branch.

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

* fix(cli): emit fixed pnpm dlx analyze command in generated AGENTS.md/CLAUDE.md

ai-context baked a machine-resolved command (formatAnalyzeCommand) into
git-tracked AGENTS.md/CLAUDE.md, so the stale-index hint varied per machine and
churned across branches (the #1706 class). Emit the fixed string
`pnpm dlx gitnexus@latest analyze` instead: committed AI-context is the most
authoritative instruction an agent reads, so it must name an install-free,
crash-free method — never `npx`, the npm-11 path #1939 steers away from.

formatAnalyzeCommand stays exported and unit-tested in resolve-invocation.ts
(it still mirrors the two .cjs hook copies); ai-context just no longer calls it.

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

* refactor(cli): unify hook-helper copy into one non-silent routine

installClaudeCodeHooks copied its four hook helpers in separate try/catch blocks
that silently swallowed failures, while installAntigravityHooks recorded an
error per failed copy. Extract one copyHookHelpers(srcDir, destDir, label,
result) with a single canonical helper list (including resolve-analyze-cmd.cjs)
and the antigravity loop's error-reporting policy, and use it from both paths so
a missing helper surfaces as a setup error instead of a silent runtime crash.

Assert both the Claude and Antigravity install paths co-locate
resolve-analyze-cmd.cjs next to the adapter, and that a failed copy records an
error rather than passing silently.

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

* docs(cli): reattach installClaudeCodeHooks JSDoc after helper extraction

The extracted HOOK_HELPERS/copyHookHelpers block landed between the
installClaudeCodeHooks JSDoc and its function, leaving the doc reading as if it
described the helper list. Move the block above the doc so it documents the
function again. No behavior change.

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

* test(cli): enforce TS<->CJS invocation parity and guard CLI startup posture

Tier-2 review found two in-scope gaps in the #1945 follow-up:

- The "mirrors resolve-invocation.ts / test enforces parity" comments overclaimed:
  the parity test only compared the two .cjs copies to each other, so the TS
  source and the CJS hook copies could silently drift (NPX_REF, the per-mode
  command, and the Windows shim regex were hand-edited in all three this PR).
  Add TS<->CJS value parity (NPX_REF + formatAnalyzeCommand for every forced
  mode) and a source-level shim-regex parity check, and make the mirror comments
  accurately describe what is enforced.

- No test locked the R3/R4 startup posture, so re-adding warnIfNpm11NpxRisk()
  (or any resolve-invocation import) at index.ts module scope -- the #207/#1383
  lazy-startup regression -- would pass CI. Add a guard asserting index.ts has
  no module-load invocation probe and the warning is wired into analyzeCommand.

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

* refactor(cli): collapse npx-invocation resolver to one source of truth

PR #1945 carried the gitnexus/pnpm/npx selection in three hand-synced
places — the canonical hook helper, its byte-identical plugin copy, and a
full TypeScript re-implementation in resolve-invocation.ts — kept in lockstep
by per-mode-command and regex-extracted-by-regex parity tests. The TS
formatAnalyzeCommand had no production caller (ai-context emits a fixed
string), and the module memoized + exposed a test-only reset for a "repeated
callers" case that has exactly one caller.

Make hooks/claude/resolve-analyze-cmd.cjs the single source: extract the
Windows-shim line-picking into a pure, exported pickPathMatch() and add an
injectable probe to resolveInvocationMode() so the shipped logic is testable
without spawning or global mocks. resolve-invocation.ts (118 -> 59 lines) now
consumes that cjs via createRequire for resolveInvocationMode/NPX_REF and adds
only the CLI-only npm-version probe and warning; the relative path resolves
identically from src/cli/ (tsx, vitest) and dist/cli/ (shipped, hooks/ is a
published sibling of dist/). Tests exercise the real shipped artifact, the
NPX_REF/mode-command parity scaffolding is dropped (one implementation can't
drift), and parity narrows to the two cjs copies staying byte-identical.

No behavior change: hook stale-index hints and the analyze warning are
byte-identical; the pre-existing setup.ts resolveGitnexusBin is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): bound stale-index hook PATH probe under the hook budget (U1)

The PostToolUse stale-index hint calls formatAnalyzeCommand(), which probes which/where; named PROBE_TIMEOUT_MS=2000 keeps git rev-parse (~3s) + up to two probes well under Claude Code's 10s hook timeout while preserving the machine-correct hint. Byte-identical in the plugin copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): steer generated cross-repo group commands off npx (#1939) (U2)

The Cross-Repo Groups block in generated AGENTS.md/CLAUDE.md still emitted bare 'npx gitnexus group ...', funneling npm-11 users into the arborist crash; switch to fixed 'pnpm dlx gitnexus@latest group ...'. Export generateGitNexusContent and add a group-branch test asserting no 'npx gitnexus' literal survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: align steering guidance on pnpm dlx gitnexus@latest (U3)

README troubleshooting uses gitnexus@latest; the repo's own committed CLAUDE.md/AGENTS.md stale-index hint now matches the generated output (pnpm dlx gitnexus@latest analyze) so the repo dogfoods the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): assert exact @latest analyze command and pin invocation mode (U4)

Drop dead PKG_VERSION/NPX_REF version-pinned constants; the cjs always emits gitnexus@latest, so assert exact toContain(...) instead of the /@\\S+/ wildcard; pin GITNEXUS_INVOCATION in the --embeddings tests for host-independent determinism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): cover resolver warn/edge branches; document probe seam (U5)

Add coverage for the gitnexus-mode warn suppression, getNpmMajorVersion edge inputs (empty/pre-release/non-numeric), and the Windows non-wrapper pickPathMatch branch; widen the InvocationResolver interface to document the optional probe param.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): lower hook PATH-probe timeout to 1000ms (U1)

In a linked worktree the stale-index hook runs git rev-parse --git-common-dir (~2s) + rev-parse HEAD (~3s) before up to two PATH probes; PROBE_TIMEOUT_MS=1000 holds the worst case near ~7s under Claude Code's 10s hook budget (was 2000, ~1s headroom). Byte-identical in the plugin copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): fail closed in gitnexus setup on missing required hook helper/adapter (U2)

copyHookHelpers now returns the failed REQUIRED helpers (the .cjs trio; win-rm-list-json.ps1 stays best-effort since it fails open). Both install paths skip hook registration with an actionable error when a required helper failed; the Claude path also gains the adapter-existence guard the Antigravity path already had. Prevents registering a hook that crashes MODULE_NOT_FOUND on every tool event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skills): steer committed skill files off npx to pnpm dlx gitnexus@latest (U3)

All 26 committed skill-file copies (gitnexus/skills, .claude, plugin, cursor) used 'npx gitnexus analyze', contradicting the generated freshness line and funneling npm-11 users into the arborist crash. Replace with 'pnpm dlx gitnexus@latest analyze'; add a regression guard (skills-steering.test.ts) that globs all four locations and fails if any reintroduces it. The cli skill's non-analyze npx subcommands (status/clean/list/wiki) are left as-is (out of the analyze-funnel scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): guard resolver import shape; assert group-impact steering (U4)

Add a load-time guard on the createRequire(resolve-analyze-cmd.cjs) cast so a drifted/renamed cjs export fails loudly at module load instead of as a late TypeError in warnIfNpm11NpxRisk. Add the missing 'group impact' assertion to the ai-context Cross-Repo Groups test, and a resolver-contract test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): auto-select invocation path with pnpm --allow-build (#1939)

Probe npm/pnpm versions and PATH to pick a working analyze command without
user configuration: global gitnexus first, pnpm dlx with --allow-build on
npm 11+ (Ladybug native scripts), npx on npm 10 and earlier. Update docs,
skills, and tests to match the canonical install-free command.

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

* fix(cli): place pnpm --allow-build before dlx, repair version-injection seam (#1939)

The auto-selected install command emitted `pnpm dlx --allow-build=… analyze`,
but pnpm < 10.14 keeps `dlx` in its argv escape list, so flags placed *after*
`dlx` are parsed as package specs and rejected (ERR_PNPM_SPEC_NOT_SUPPORTED) on
pnpm 10.2–10.13.x — strictly worse than the bare command. Move the flags before
`dlx` (the position pnpm has honored since 10.2.0) in both byte-identical hook
copies, the committed AGENTS.md / CLAUDE.md, and every skill tree.

Also repairs the CI-red resolveInvocationMode seam: injecting `{ npmMajor: null }`
to simulate an absent npm fell through `??` to the host's real `npm --version`
(npm 10.x on the CI runners → routed 'npx' instead of 'pnpm'). Use an
`'npmMajor' in deps` sentinel so an injected null is honored, drop the dead
parseMajorVersion guard, and gate the flags on pnpm >= 10.2 via a single
minor-aware probeVersion spawn (skipped for committed docs). Align the TS
getNpmMajorVersion timeout to the 1s hook budget and strengthen the
skills-steering guard with a pre-dlx positive assertion plus a post-dlx
regression check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add npm-11 pnpm caveat to README Quick Starts (#1939)

The root, package, and cursor-integration README Quick Starts still steered
first-contact users to bare `npx gitnexus analyze` — the exact npm 11.x
arborist install crash issue #1939 names as a funnel. Add a one-line pnpm
`--allow-build … dlx` caveat (keeping the simple npx default for npm<=10 /
pnpm / yarn users); the package README points to its existing npm-11
workaround section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): route every gitnexus-cli command off npx to pnpm dlx (#1939)

The gitnexus-cli skill demonstrated analyze via `pnpm --allow-build … dlx`
but still showed status/clean/wiki/list via bare `npx gitnexus` — the same
package, the same npm-11 crash-prone install path — and its header claimed
"all commands work via npx". Convert every subcommand to the pnpm form across
all three skill copies and reconcile the header. Broaden the skills-steering
guard to forbid any `npx gitnexus` command in the cli-skill copies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(hook): probe pnpm once on the stale-index path (#1939)

The stale-index hook resolved pnpm twice — `which pnpm` for mode selection
then `pnpm --version` for the allow-build gate — two spawns for one tool in a
~9s/10s budget. Capture the version once in formatAnalyzeCommand and thread it
through the existing deps seam (a successful `pnpm --version` proves presence),
sharing a memoized PATH probe with resolveInvocationMode. Add explicit pnpm
10.0-suppress / 10.2-emit boundary tests and relabel the unknown-minor case.
Both byte-identical cjs copies updated together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setup): single-quote POSIX hook command + assert cliPath patch applied (#1939)

The hook `command` written into editor settings is shell-evaluated; the
double-quoted `node "<path>"` form left `$`, backtick, and other metacharacters
live in an adversarial $HOME. Single-quote the path on POSIX (Windows keeps the
double-quoted form — those chars are illegal in Windows filenames). Also assert
the cliPath source-literal replace() actually matched, recording an actionable
error on drift instead of silently shipping a hook with an unresolved relative
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(setup): normalize expected hook path for the Windows runner (#1939)

The new POSIX-escaping test built its expected hook path with path.join,
which emits backslashes on the Windows runner, while setup.ts forward-slash-
normalizes the path before quoting — so `expect(cmd).toBe(node '<path>')`
mismatched on tests/windows-latest. Normalize the expected path the same way.
Production code was already correct; only the test's expected value was
platform-fragile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): steer docs/skills via a project-local runner, not a pnpm default (#1939)

The prior approach hardcoded `pnpm --allow-build=… dlx gitnexus@latest <cmd>`
into every committed skill + the generated AGENTS.md/CLAUDE.md, which assumes
pnpm is installed. Replace it with a CLI-neutral project-local runner:

- `gitnexus analyze` drops `.gitnexus/run.cjs` (a copy of the canonical
  `resolve-analyze-cmd.cjs`, which gains `buildRunnerArgv` + a `require.main`
  exec tail) next to the index. Docs/skills reference `node .gitnexus/run.cjs
  <cmd>`, which auto-selects the runner (global `gitnexus` → `pnpm dlx` → `npx`)
  at call time — no package-manager assumption. README first-run + an inline
  bootstrap note stay universal `npx gitnexus analyze`.
- The exec tail uses `shell` on Windows so `.cmd`/`.ps1`/`.exe` shims resolve
  (execFileSync can't otherwise; Node blocks `.cmd` without a shell,
  CVE-2024-27980), and prints a diagnostic instead of a silent exit 1.

Tests: runner exec-tail (real spawn, exit-code propagation + ENOENT diagnostic),
copy-failure graceful degradation, and per-subcommand routing + pnpm-fallback
vacuity guards. The generated CLAUDE.md block stays under the #856 token budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): resolve Windows .cmd version probes so pnpm steering fires (#1939)

probeVersion (and the TS getNpmMajorVersion mirror) spawned npm/pnpm
--version via execFileSync with no shell, so on Windows the .cmd shims
ENOENT'd, the probe reported a present tool as absent, and the stale-index
hook recommended the npx crash path #1939 exists to avoid. Add
shell: process.platform === 'win32' to the version probes (the exec tail
already does this). Parse the first version-shaped line so a Corepack/notice
banner on stdout no longer defeats the parse. Carry pnpm presence separately
from version so a present-but-unparseable pnpm still selects pnpm. Drop the
dead probe ?? resolveOnPath coalesce. Cover resolve-analyze-cmd.cjs (+ plugin
twin) with the shell-injection and windowsHide source-regression guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): widen pnpm allow-build for the --embeddings=N equals form (#1945)

buildRunnerArgv detected embeddings via gitnexusArgs.includes('--embeddings'),
which missed the equals form (--embeddings=5000) that Commander also accepts,
dropping --allow-build=onnxruntime-node on pnpm 10.2+. Match both forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): cover the runner exec-tail Windows shell branch on CI (#1945)

runner-exec-tail.test.ts was POSIX-only and unregistered in
cross-platform-tests.ts, so the run.cjs Windows shell:true exec branch ran on
no platform despite the file comment claiming windows-latest covered it. Add a
.cmd-shim it.skipIf(onPosix) case and register the file in SPAWN_CLI so the
windows-latest job runs it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix broken troubleshooting anchor in gitnexus README (#1945)

The npm-11 quick-start note linked to #npx-gitnexus-crashes-with-nodetarget-is-null-npm-11,
which matches no heading; the actual troubleshooting heading slugifies to
#cannot-destructure-property-package-of-nodetarget-as-it-is-null. Repoint the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): guard resolve-analyze-cmd.cjs in antigravity e2e sanity check (#1945)

The antigravity adapter top-level require()s resolve-analyze-cmd.cjs, but the
beforeAll helper-presence loop did not check for it — a failed copy would
surface as noisy MODULE_NOT_FOUND in downstream tests instead of the intended
actionable 'Helper not installed' error. Add it to the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): tie a missing-runner Cannot-find-module error to recovery (#1945)

Generated CLAUDE.md/AGENTS.md make `node .gitnexus/run.cjs` the primary
command, but the runner is gitignored, so a fresh clone or git clean leaves an
agent facing a raw MODULE_NOT_FOUND. The CLAUDE.md block is token-budget-capped
(#856), so the recovery guidance lives in the cli skill (its documented home):
the bootstrap note now names the `Cannot find module` error and points at
`npx gitnexus analyze` to (re)generate the runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): disambiguate the MCP-pinned ref from the @latest hint (#1945)

setup.ts and resolve-analyze-cmd.cjs both exported a constant named NPX_REF
with different values (version-pinned for the persisted MCP entry vs.
gitnexus@latest for hints). Rename setup.ts's module-private constant to
MCP_PINNED_REF (value and behavior unchanged — the MCP pin stays pinned),
leaving the cjs hint ref and its re-export alone. Also route the createRequire
cast through 'unknown' so it reads as an explicit narrowing to the subset this
module uses rather than a claim about the cjs's full export shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:00:34 +01:00

1785 lines
63 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 fs from 'fs';
import path from 'path';
import os from 'os';
import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js';
// ─── Paths to both hook variants ────────────────────────────────────
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
const 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',
);
// ─── 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: [] }));
}
}
function writeExecutable(filePath: string, content: string) {
fs.writeFileSync(filePath, content, { mode: 0o755 });
}
function createHookToolDir(options: {
gitnexusStderr?: string;
gitnexusMarkerPath?: string;
lsofOutput?: string;
lsofOutputLines?: string[];
psOutput?: string;
psOutputByPid?: Record<string, string>;
lsofSleepMs?: number;
}) {
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-bin-'));
const gitnexusStderr = JSON.stringify(options.gitnexusStderr ?? '');
const markerPath = JSON.stringify(options.gitnexusMarkerPath ?? '');
const fakeGitNexus = `#!/usr/bin/env node\nconst fs = require('fs');\nconst marker = ${markerPath};\nif (marker) fs.writeFileSync(marker, 'called');\nprocess.stderr.write(${gitnexusStderr});\n`;
writeExecutable(path.join(binDir, 'gitnexus'), fakeGitNexus);
writeExecutable(path.join(binDir, 'gitnexus-cli.js'), fakeGitNexus);
const lsofOutput =
options.lsofOutputLines != null
? options.lsofOutputLines.join('\n') + (options.lsofOutputLines.length ? '\n' : '')
: (options.lsofOutput ?? '');
const lsofBody =
options.lsofSleepMs != null
? `#!/usr/bin/env node\nsetTimeout(() => {}, ${Number(options.lsofSleepMs)});\n`
: `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(lsofOutput)});\nprocess.exit(0);\n`;
writeExecutable(path.join(binDir, 'lsof'), lsofBody);
const psBody =
options.psOutputByPid != null
? `#!/usr/bin/env node
const byPid = ${JSON.stringify(options.psOutputByPid)};
const args = process.argv;
const p = args[args.indexOf('-p') + 1];
process.stdout.write(byPid[p] ?? '');
process.exit(0);
`
: `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.psOutput ?? '')});\nprocess.exit(0);\n`;
writeExecutable(path.join(binDir, 'ps'), psBody);
return binDir;
}
function hookEnv(binDir: string) {
return {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`,
GITNEXUS_HOOK_CLI_PATH: path.join(binDir, 'gitnexus-cli.js'),
GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, 'lsof'),
GITNEXUS_HOOK_PS_PATH: path.join(binDir, 'ps'),
};
}
// ─── 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');
});
}
});
// ─── 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 });
}
});
it.skipIf(process.platform === 'win32')(
`${label}: skips augment 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) },
);
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 });
}
},
);
}
});
describe.skipIf(process.platform === 'win32')(
'Ladybug DB owner guard — production-shaped ps + failure modes (#1493)',
() => {
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) },
);
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) },
);
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) },
);
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}: 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) },
);
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: {} }));
});
}
});