mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
* fix(hooks): silence MCP-owned-DB augment skip for strict hook runners
The PreToolUse augment-skip path wrote `[GitNexus] augment skipped: MCP
server owns DB` to stderr unconditionally on a normal (non-error) skip.
Strict hook runners that validate hook output (e.g. Codex `PreToolUse`)
treat that as noisy / "invalid pre-tool-use JSON output".
Gate the diagnostic behind GITNEXUS_DEBUG via a shared `isDebugEnabled()`
helper, so normal skips are silent by default (empty stdout AND stderr,
exit 0) and the reason stays recoverable with `GITNEXUS_DEBUG=1`. Applied
consistently to all three hand-maintained hook copies (claude,
antigravity, claude-plugin).
Tests:
- Unit (claude CJS + plugin): assert default-silent and debug-on behavior
for the MCP-owned-DB skip and for the fail-closed (lsof ETIMEDOUT) skip
that routes through the same gated line; the owner-detection tests run
with GITNEXUS_DEBUG=1 so the skip discriminator stays observable.
- e2e (antigravity): the antigravity adapter shares the identical gated
skip but only runs from its install dir, so cover it through the install
pipeline with a faked DB-owner probe (strict empty-stdout/stderr +
debug-on). Promote the fake-probe helpers (createHookToolDir / hookEnv,
plus a module-private writeExecutable) into shared hook-test-helpers so
unit + e2e reuse them.
Fixes #1913
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hooks): unify GITNEXUS_DEBUG gating in main() catch handlers
The main() catch-handler in all three hook copies still gated its crash
log on truthy `if (process.env.GITNEXUS_DEBUG)`, while the skip diagnostic
the #1913 fix added is gated on the strict `isDebugEnabled()` helper
(=== '1' || === 'true'). That split meant GITNEXUS_DEBUG=0 or =false
suppressed the skip line yet still enabled crash logging — two conflicting
contract signals in the same file.
Switch the three catch handlers to isDebugEnabled() so GITNEXUS_DEBUG has
one strict meaning everywhere: exactly '1' or 'true' enables all
diagnostics; everything else (incl. '0', 'false', empty, unset) is silent.
Add boundary tests asserting the MCP-owner skip stays silent with
GITNEXUS_DEBUG='0' and 'false' (CJS + Plugin), pinning the strict contract.
Refs #1913
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hooks): gate antigravity stale-index hint stderr behind GITNEXUS_DEBUG
The antigravity AfterTool handler mirrored the stale-index hint to stderr
unconditionally on a normal (non-error) success path — the last ungated
stderr write of the class issue #1913 targets, and a divergence from the
claude hook, which never mirrors this hint to stderr.
Gate the stderr mirror behind isDebugEnabled(). The hint still reaches the
agent via additionalContext (stdout JSON) — parts.push(hint) stays
unconditional — so there is no functional loss; only the by-default
terminal mirror moves behind GITNEXUS_DEBUG=1. This knowingly changes the
#1730 terminal-mirror behavior in favor of strict-runner cleanliness and
parity with the claude adapter.
Split the e2e assertion into a default-silent test (hint in
additionalContext, absent from stderr) and a GITNEXUS_DEBUG=1 test (hint
mirrored to stderr).
Refs #1913
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(hooks): document GITNEXUS_DEBUG=1 for hook diagnostics
GITNEXUS_DEBUG was documented only in the cursor integration README, so
the diagnostic escape hatch for the Claude Code / Antigravity hooks was
undiscoverable. Operators hitting a silent hook skip (MCP server owns the
DB, fail-closed probe timeout, or an already-current index) had no
documented way to surface the reason.
Add a Troubleshooting subsection explaining that the hooks stay silent on
normal skip paths for strict runners, that GITNEXUS_DEBUG=1 surfaces the
reason on stderr, and that only '1'/'true' enable diagnostics (stdout JSON
the agent consumes is unaffected).
Refs #1913
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(hooks): update setup-antigravity unit test for gated stale-index hint
U2 (7995e921) gated the antigravity stale-index hint stderr mirror behind
GITNEXUS_DEBUG, but a second test — setup-antigravity.test.ts's "AfterTool
emits stale-index hint" — also asserted the hint on stderr by default and
was missed (it lives outside the two files validated locally; the full CI
matrix caught it).
Update it to the U2 contract: assert the hint via additionalContext with
stderr silent by default, plus a GITNEXUS_DEBUG=1 run asserting the
terminal mirror reappears.
Refs #1913
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
6.9 KiB
TypeScript
183 lines
6.9 KiB
TypeScript
/**
|
|
* Shared helpers for hook test files (unit + integration).
|
|
*/
|
|
import { spawnSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
export function runHook(
|
|
hookPath: string,
|
|
input: Record<string, any>,
|
|
cwd?: string,
|
|
options: { env?: NodeJS.ProcessEnv } = {},
|
|
): { stdout: string; stderr: string; status: number | null } {
|
|
const result = spawnSync(process.execPath, [hookPath], {
|
|
input: JSON.stringify(input),
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
cwd,
|
|
// Used as-is when provided: every caller passes a full env (a spread of
|
|
// process.env plus overrides), so re-merging process.env here is redundant
|
|
// and, worse, on Windows it re-adds the original `Path` key alongside a
|
|
// replaced `PATH` — defeating envWithPath(), which deletes path variants so a
|
|
// scrubbed PATH is honored deterministically.
|
|
env: options.env ?? process.env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
return {
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
status: result.status,
|
|
};
|
|
}
|
|
|
|
export function parseHookOutput(
|
|
stdout: string,
|
|
): { hookEventName?: string; additionalContext?: string } | null {
|
|
if (!stdout.trim()) return null;
|
|
try {
|
|
const parsed = JSON.parse(stdout.trim());
|
|
return parsed.hookSpecificOutput || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Stale-index hint PATH-detection helpers (#1938) ────────────────
|
|
//
|
|
// The hooks emit `gitnexus analyze` (no npx) when a launcher is on PATH. These
|
|
// helpers let an e2e test fabricate that condition deterministically: scrub any
|
|
// ambient `gitnexus` off PATH, then prepend a synthetic launcher — so the test
|
|
// asserts the hook's real PATH auto-detection rather than env-var forcing.
|
|
|
|
/** Names a global `gitnexus` may take on each platform (for scrub + fabricate). */
|
|
function gitNexusLauncherNames(): string[] {
|
|
return process.platform === 'win32'
|
|
? ['gitnexus', 'gitnexus.cmd', 'gitnexus.bat', 'gitnexus.exe', 'gitnexus.ps1']
|
|
: ['gitnexus'];
|
|
}
|
|
|
|
/** True if `dir` holds a runnable `gitnexus` launcher (isFile + X_OK on POSIX). */
|
|
function hasGitNexusLauncher(dir: string): boolean {
|
|
return gitNexusLauncherNames().some((name) => {
|
|
const candidate = path.join(dir, name);
|
|
try {
|
|
if (!fs.statSync(candidate).isFile()) return false;
|
|
if (process.platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Fake tool dir for the DB-owner probe (shared by unit + e2e) ────
|
|
//
|
|
// Builds a temp bin dir holding fake `gitnexus`, `lsof`, and `ps` executables so
|
|
// a hook spawned with hookEnv(binDir) sees a deterministic DB-owner probe result
|
|
// (and a marker-writing fake CLI) without touching the real process table.
|
|
|
|
// Module-private: only createHookToolDir writes these fakes; callers use the
|
|
// higher-level createHookToolDir, never writeExecutable directly.
|
|
function writeExecutable(filePath: string, content: string) {
|
|
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
/** A full env that points a spawned hook at the fake tool dir from createHookToolDir. */
|
|
export 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'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The current PATH with every dir that contains a `gitnexus` launcher removed, so
|
|
* a test box that already has gitnexus installed cannot make the assertion pass
|
|
* (or fail) for the wrong reason. Mirrors the hook's own detection — isFile() +
|
|
* X_OK — rather than a bare existsSync.
|
|
*/
|
|
export function pathWithoutGitNexus(
|
|
pathValue: string = process.env.PATH || process.env.Path || process.env.path || '',
|
|
): string {
|
|
return pathValue
|
|
.split(path.delimiter)
|
|
.filter((dir) => dir && !hasGitNexusLauncher(dir))
|
|
.join(path.delimiter);
|
|
}
|
|
|
|
/** A full env copy with PATH replaced by `pathValue` and all case variants of the key removed. */
|
|
export function envWithPath(pathValue: string): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
for (const key of Object.keys(env)) {
|
|
if (key.toLowerCase() === 'path') delete env[key];
|
|
}
|
|
env.PATH = pathValue;
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Create a temp dir holding a runnable `gitnexus` launcher and return a PATH that
|
|
* puts it first (with all other gitnexus launchers scrubbed). Caller must invoke
|
|
* cleanup() to remove the temp dir.
|
|
*/
|
|
export function createGitNexusPathEntry(): { pathValue: string; cleanup: () => void } {
|
|
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-path-'));
|
|
const launcher = path.join(binDir, process.platform === 'win32' ? 'gitnexus.cmd' : 'gitnexus');
|
|
fs.writeFileSync(
|
|
launcher,
|
|
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
|
|
);
|
|
if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755);
|
|
|
|
return {
|
|
pathValue: [binDir, pathWithoutGitNexus()].filter(Boolean).join(path.delimiter),
|
|
cleanup: () => fs.rmSync(binDir, { recursive: true, force: true }),
|
|
};
|
|
}
|