mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
The stale-index hint must prefer a PATH-installed `gitnexus` over `npx gitnexus` (which crashes npm 11's arborist, #1939). #1945 already does this via `formatAnalyzeCommand`, but its `resolveOnPath` shelled out to `where`/`which` — which fails outright when the probe binary is itself unreachable (a sanitized hook PATH without System32 / `/usr/bin`), exactly the Windows case #1938 reports. Replace the subprocess (and a hand-rolled fallback) with a single spawn-free PATH scan — issue #1938's preferred "Option A". One code path, identical on every OS: - no dependency on `where`/`which` being reachable; - no shell-spawn surface (CVE-2024-27980) and no spawn timeout to tune; - Windows matches PATHEXT extensions only (mirrors `where`/cmd.exe), so neither a `.ps1`-only shim nor a bare extensionless file is a false positive; - `preferExecExt` keeps the `.cmd`/`.bat`/`.exe` wrapper preference. `resolveOnPath` is now pure (platform/env injectable) and exported; `pickPathMatch` and the interim `scanPathForCommand` are removed. Version detection still spawns `npm`/`pnpm --version` (unavoidable) and is unchanged. Tests: drive `resolveOnPath` directly across POSIX/Windows cases (PATHEXT, `.ps1`, extensionless, preferExecExt, X_OK) + a cross-platform `formatAnalyzeCommand` end-to-end case, plus the existing hook e2e auto-detection tests and new `pathWithoutGitNexus`/`envWithPath`/`createGitNexusPathEntry` helpers. Both `resolve-analyze-cmd.cjs` copies kept byte-identical. Validated: tsc clean, prettier clean, eslint 0 errors, 312 targeted tests pass (unit + hook e2e + the resolver's other consumers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
4.2 KiB
TypeScript
118 lines
4.2 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;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 }),
|
|
};
|
|
}
|