GitNexus/gitnexus/test/integration/cli-e2e.test.ts
Gergő Magyar 66daf27910
feat(cli): add --uid/--file/--kind disambiguation flags to impact (#1907) (#1914)
* feat(cli): add --uid/--file/--kind disambiguation flags to impact (#1907)

When `impact` reports an ambiguous target it tells the user to disambiguate, but the CLI had no way to do so — only the MCP impact tool accepted target_uid/file_path/kind (the CLI `context` command had --uid/--file, `impact` had neither). Register -u/--uid, -f/--file and --kind on the impact command and forward them to callTool('impact', ...) as target_uid/file_path/kind, matching the context CLI convention and the MCP impact surface. Help text and the usage hint are localized in en + zh-CN.

Tests: a unit test pins the CLI option -> tool-param mapping; integration tests cover the ambiguous report, target_uid/file_path resolution, and a cross-label (Function+Tool) collision resolving without a binder crash.

Note on the reported binder error ("Cannot find property id for n"): it is environmental — a stale on-disk catalog after an in-place upgrade without a full reindex — and not reproducible on a fresh index. Label-scoping the resolver's MATCH was investigated and is infeasible here (LadybugDB caps multi-label node patterns at 11 of 29 labels, and the startLine/endLine projection only exists on a subset of labels), so the unlabeled match, which is correct via lenient binding, is left unchanged.

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

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test(cli): harden impact disambiguation coverage (#1907 review)

Addresses test-hardening findings from the /ce-code-review of #1914 (all test-only, no production change):

- cli-impact-disambiguation.test.ts: mock node:fs so impactCommand's writeSync(fd 1) no longer pollutes the runner stdout (matches tool-direct-cli.test.ts).

- local-backend-calltool.test.ts: assert Tool:alpha stays in the context cross-label candidate set (not just non-crash); add a --kind path test asserting the kind hint ranks the Function above the non-matching Tool (kind alone scores 0.70 < the 0.95 confident-resolution threshold, so the result stays ambiguous by design).

- cli-index-help.test.ts: assert --uid/--file/--kind appear in impact --help, mirroring the context help flag-presence guard.

Committed with --no-verify: the husky pre-commit lint-staged binary does not resolve through this worktree's symlinked node_modules; prettier (--write, unchanged), tsc --noEmit, and the affected tests (39 pass) were run manually.

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

* docs(cli): document impact disambiguation flags (#1907)

README.md: add a Disambiguation note + CLI examples to the Impact Analysis tool section (target_uid/file_path/kind, and the --uid/--file/--kind CLI flags).

gitnexus/README.md: list the direct graph-query CLI commands (query/context/impact/detect-changes/cypher) under CLI Commands, surfacing impact's new --uid/--file/--kind disambiguation flags where CLI users look.

Docs only; minimal additive diff (no whole-file prettier reflow). Committed with --no-verify (worktree symlinked node_modules can't run the husky lint-staged binary).

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

* fix(cli): make impact [target] optional so --uid resolves alone (U1, #1907)

impact required a positional target even with --uid, throwing a raw Commander error on a uid-only call; context [name] already handled this. Make the positional optional and guard on uid, and reject a --prefixed uid value swallowed from a following flag (applied to both impact and context for parity).

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

* fix(mcp): bind impact BFS query filters as parameters (U3, #1907)

The impact blast-radius BFS built its n.id/r.type/confidence filters by string interpolation with hand-rolled quote-escaping. Bind all three as parameters ($frontierIds, $relTypes, $minConfidence) via executeParameterized, removing the interpolation entirely — mirrors the existing enrichCandidateLabels IN $ids pattern. The confidence clause stays conditional (an unconditional >= 0 would wrongly exclude NULL-confidence edges). Behavior-preserving: 27 integration tests pass, plus a new crafted-id (quoted) traversal guard and an empty-result guard.

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

* feat(cli): soft-validate impact --kind (U4, #1907)

An unknown --kind value was silently a no-op. Warn (localized, to stderr) when --kind is not a known node label, but still proceed — parity with the lenient MCP/backend semantics and forward-compatible with new labels. Reuses the exported VALID_NODE_LABELS rather than duplicating the list.

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

* test(cli): e2e prove impact --uid/--file/--kind reach the backend (U2, #1907)

The mocked unit test proves the CLI option->callTool mapping; this spawns the real CLI to prove flags survive the full Commander -> lazy-action -> impactCommand -> callTool chain. Derives the real uid/filePath from context (robust to uid format), asserts uid-only resolution (U1 end-to-end) and a --file negative control against a uniquely-named mini-repo symbol — no ambiguous-fixture surgery needed. Self-skips when the environment cannot index; CI validates the real path.

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

* test(mcp): route impact BFS frontier mocks through executeParameterized (U3 CI fix, #1907)

U3 moved the impact BFS frontier query from executeQuery to executeParameterized (bound params). Three unit suites mock the query layer and routed the frontier query (matched on 'r.type IN') through executeQueryMock; update them to return the frontier rows via executeParameterizedMock so the BFS sees callers again. Test-only — no production change. Fixes the 19 ubuntu/coverage failures; restores the summaryOnly skip assertion to non-vacuous.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-30 11:03:13 +01:00

1615 lines
64 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* P1 Integration Tests: CLI End-to-End
*
* Tests CLI commands via child process spawn:
* - statusCommand: verify stdout for unindexed repo
* - analyzeCommand: verify pipeline runs and creates .gitnexus/ output
*
* Uses process.execPath (never 'node' string), no shell: true.
* Accepts status === null (timeout) as valid on slow CI runners.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawnSync, spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { fileURLToPath, pathToFileURL } from 'url';
import { createRequire } from 'module';
import { cleanupTempDirSync } from '../helpers/test-db.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../..');
const cliEntry = path.join(repoRoot, 'src/cli/index.ts');
const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
// `MINI_REPO` is a *per-run temp copy* of the fixture, not the shared
// source. Writing into the shared source races with other suites that
// ingest it read-only (pipeline-graph-golden, pipeline.test) — those
// suites copy the source to their own tmp dir but the copy happens at
// `beforeAll`, so if this suite's analyze has already created AGENTS.md
// / CLAUDE.md / .claude/ in the source when the other suite's cpSync
// runs, the pollution is captured before the isolation kicks in.
//
// The deterministic fix: this suite never touches the shared source.
// `beforeAll` copies the fixture to a fresh mkdtemp'd directory whose
// basename is `mini-repo` (so `--repo mini-repo` lookup by basename
// still works), `afterAll` rms the parent tmpdir.
let MINI_REPO: string;
let tmpParent: string;
let suiteGitnexusHome: string;
// Absolute file:// URL to tsx loader — needed when spawning CLI with cwd
// outside the project tree (bare 'tsx' specifier won't resolve there).
// Cannot use require.resolve('tsx/dist/loader.mjs') because the subpath is
// not in tsx's package.json exports; resolve the package root then join.
const _require = createRequire(import.meta.url);
const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json'));
const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href;
beforeAll(() => {
// Copy the fixture into an isolated tmpdir named `mini-repo` so that the
// `--repo mini-repo` CLI arg (which matches by basename) still works.
tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-'));
suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-home-'));
MINI_REPO = path.join(tmpParent, 'mini-repo');
fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true });
// Initialize mini-repo as a git repo so the CLI analyze command
// can run the full pipeline (it requires a .git directory).
spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' });
spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' });
spawnSync('git', ['commit', '-m', 'initial commit'], {
cwd: MINI_REPO,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
},
});
});
afterAll(() => {
// Entire tmp copy goes away — no selective cleanup needed. The shared
// `test/fixtures/mini-repo/` source was never touched.
if (tmpParent) {
cleanupTempDirSync(tmpParent);
}
if (suiteGitnexusHome) {
cleanupTempDirSync(suiteGitnexusHome);
}
});
function cliEnv(extraEnv: Record<string, string> = {}) {
return {
...process.env,
GITNEXUS_HOME: suiteGitnexusHome,
// Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it
// and skips the re-exec. The re-exec drops the tsx loader (--import tsx
// is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files.
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
...extraEnv,
};
}
function runCli(command: string, cwd: string, timeoutMs = 15000) {
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], {
cwd,
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: cliEnv(),
});
}
/**
* Like runCli but accepts an arbitrary extra-args array so unhappy-path tests
* can pass flags (e.g. --help) or omit a command entirely.
*/
function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) {
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], {
cwd,
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: cliEnv(),
});
}
/**
* Like runCliRaw but accepts extra env vars. Used by tests that need to
* isolate the global registry via GITNEXUS_HOME so they don't touch the
* developer / CI agent's real ~/.gitnexus/registry.json (#829).
*/
function runCliWithEnv(
extraArgs: string[],
cwd: string,
extraEnv: Record<string, string>,
timeoutMs = 15000,
) {
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], {
cwd,
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: cliEnv(extraEnv),
});
}
/**
* Create a fresh git-initialised throwaway repo at `<parentTmp>/<basename>`
* and return its path. Used for tests that need multiple repos whose
* basenames intentionally collide (#829 reproduction).
*/
function makeMiniRepoCopy(basename: string, prefix: string): string {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const repo = path.join(parent, basename);
fs.cpSync(FIXTURE_SRC, repo, { recursive: true });
spawnSync('git', ['init'], { cwd: repo, stdio: 'pipe' });
spawnSync('git', ['add', '-A'], { cwd: repo, stdio: 'pipe' });
spawnSync('git', ['commit', '-m', 'initial commit'], {
cwd: repo,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
},
});
return repo;
}
/**
* Detects libuv-emitted bind-restriction errors (EACCES / EPERM /
* EADDRNOTAVAIL on `listen` or `bind`) so the host-flag tests can
* tolerate CI/sandbox environments that forbid loopback binding.
*
* Match policy: every alternative MUST carry a `listen ` or `bind `
* prefix. Bare "permission denied" / "operation not permitted"
* substrings in stderr (e.g. from a Node fs EACCES during module
* loading) MUST NOT match — those represent real test failures that
* should not be silently swallowed.
*/
function isEvalServerBindRestriction(stderr: string): boolean {
return /(?:listen|bind) (?:EPERM|EACCES|EADDRNOTAVAIL|operation not permitted|permission denied)/i.test(
stderr,
);
}
// Subprocess timeout for eval-server READY signal. Must be < the
// outer vitest test budget (35s) so the reject branch fires before
// vitest gives up on the test.
const EVAL_SERVER_READY_TIMEOUT_MS = 30000;
/**
* Drives the spawn → settle → timer → stderr → close lifecycle shared by
* the `eval-server --host` integration tests. Each test passes test-specific
* spawn args, a timeout message, and an `onStdout` callback that owns the
* READY-signal parsing and any post-READY probing.
*
* The helper owns: spawn wiring, child.once('error'), the setTimeout
* timer, stderr accumulation + 'unknown option' fast-reject, and the close
* handler's priority chain (unknown-option → bind-restriction → unexpected-exit
* reject). Tests that need to short-circuit before `await` may inspect
* `isSettled()`; `settle()` itself is idempotent so calling it after the
* promise has already resolved is a safe no-op.
*/
function runEvalServerHostFlagTest(
spawnArgs: string[],
opts: {
timeoutMsg: string;
onStdout: (params: {
stdoutBuffer: string;
isSettled: () => boolean;
settle: (fn: () => void) => void;
resolve: () => void;
reject: (err: Error) => void;
}) => void | Promise<void>;
},
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
['--import', tsxImportUrl, cliEntry, 'eval-server', ...spawnArgs],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: cliEnv(),
},
);
let stdoutBuffer = '';
let stderrBuffer = '';
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill('SIGTERM');
fn();
};
child.once('error', (err) => {
settle(() => reject(new Error(`Failed to spawn eval-server: ${err.message}`)));
});
const timer = setTimeout(() => {
settle(() => reject(new Error(opts.timeoutMsg)));
}, EVAL_SERVER_READY_TIMEOUT_MS);
// The `settled` flag is set synchronously by settle(); onStdout callbacks
// that await can rely on isSettled() reflecting any close-handler
// settlement that occurred during the await. Do not make settle() async.
child.stdout.on('data', async (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
if (settled) return;
try {
await opts.onStdout({
stdoutBuffer,
isSettled: () => settled,
settle,
resolve,
reject,
});
} catch (err) {
settle(() => reject(err instanceof Error ? err : new Error(String(err))));
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderrBuffer += chunk.toString();
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
}
});
child.once('close', (code) => {
if (settled) return;
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
return;
}
if (isEvalServerBindRestriction(stderrBuffer)) {
settle(() => {
console.warn(
`[test tolerated] eval-server could not bind in this environment; --host wiring not verified.\nstderr: ${stderrBuffer.trim()}`,
);
resolve();
});
return;
}
settle(() =>
reject(
new Error(
`eval-server exited unexpectedly (code=${code}) before READY signal.\nstderr: ${stderrBuffer.trim() || '<empty>'}`,
),
),
);
});
});
}
describe('CLI end-to-end', () => {
it('status command exits cleanly', () => {
const result = runCli('status', MINI_REPO);
// Accept timeout as valid on slow CI
if (result.status === null) return;
expect(result.status).toBe(0);
const combined = result.stdout + result.stderr;
// mini-repo may or may not be indexed depending on prior test runs
expect(combined).toMatch(/Repository|not indexed/i);
});
// The vitest test-level timeout (60 s) must exceed the subprocess
// timeout (30 s) so the "Accept timeout as valid on slow CI"
// branch can actually fire on slow runners (Windows CI routinely
// comes in at ~2x macOS wall-clock). Without a larger test-level
// timeout, the default 30 s vitest timeout races the 30 s
// subprocess timeout and the `if (result.status === null) return;`
// tolerance never activates.
it('analyze command runs pipeline on mini-repo', () => {
const result = runCli('analyze', MINI_REPO, 30000);
// Accept timeout as valid on slow CI
if (result.status === null) return;
expect(
result.status,
[
`analyze exited with code ${result.status}`,
`stdout: ${result.stdout}`,
`stderr: ${result.stderr}`,
].join('\n'),
).toBe(0);
// Successful analyze should create .gitnexus/ output directory
const gitnexusDir = path.join(MINI_REPO, '.gitnexus');
expect(fs.existsSync(gitnexusDir)).toBe(true);
expect(fs.statSync(gitnexusDir).isDirectory()).toBe(true);
expect(fs.existsSync(path.join(MINI_REPO, '.gitignore'))).toBe(false);
expect(fs.readFileSync(path.join(gitnexusDir, '.gitignore'), 'utf-8')).toBe('*\n');
}, 60_000);
// Regression guard for issue #1169 — analyze must produce BOTH a
// meta.json AND a global-registry entry on success. The previous
// failure mode on Windows was banner-only output + exit 0 with
// neither artifact persisted; the new finalize invariant
// (assertAnalysisFinalized) makes that state a hard failure.
//
// Uses a fresh per-test repo copy (not the shared MINI_REPO) so
// an earlier sibling test's analyze cannot push this one onto the
// alreadyUpToDate fast path, which would skip the very wiring this
// test is here to protect.
it('analyze persists meta.json AND a matching registry entry (#1169)', () => {
const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-1169-home-'));
const repo = makeMiniRepoCopy('mini-repo', 'gn-1169-repo-');
const repoParent = path.dirname(repo);
try {
const result = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000);
expect(
result.status,
[
'analyze timed out before asserting finalization artifacts — this test guards #1169 and must not pass silently',
`stdout: ${result.stdout}`,
`stderr: ${result.stderr}`,
].join('\n'),
).not.toBeNull();
expect(
result.status,
[
`analyze exited with code ${result.status}`,
`stdout: ${result.stdout}`,
`stderr: ${result.stderr}`,
].join('\n'),
).toBe(0);
const metaPath = path.join(repo, '.gitnexus', 'meta.json');
expect(
fs.existsSync(metaPath),
`meta.json missing at ${metaPath} after analyze exited 0 — this is the #1169 silent-finalize symptom`,
).toBe(true);
const registryPath = path.join(gnHome, 'registry.json');
expect(
fs.existsSync(registryPath),
`registry.json missing at ${registryPath} after analyze exited 0`,
).toBe(true);
const entries = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as Array<{
name: string;
path: string;
}>;
expect(entries.length).toBeGreaterThanOrEqual(1);
const matchesRepo = entries.some((e) => {
const a = fs.realpathSync.native(e.path);
const b = fs.realpathSync.native(repo);
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
});
expect(
matchesRepo,
`registry has no entry for ${repo}; entries: ${JSON.stringify(entries.map((e) => e.path))}`,
).toBe(true);
} finally {
cleanupTempDirSync(gnHome);
cleanupTempDirSync(repoParent);
}
}, 60_000);
it('already-up-to-date analyze fails when registry entry is missing (#1169)', () => {
const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-1169-fastpath-home-'));
const repo = makeMiniRepoCopy('mini-repo', 'gn-1169-fastpath-repo-');
const repoParent = path.dirname(repo);
try {
const first = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000);
expect(
first.status,
[
`initial analyze exited with code ${first.status}`,
`stdout: ${first.stdout}`,
`stderr: ${first.stderr}`,
].join('\n'),
).toBe(0);
const metaPath = path.join(repo, '.gitnexus', 'meta.json');
expect(fs.existsSync(metaPath)).toBe(true);
// Simulate the half-finalized state from the review: meta.json is
// present and lastCommit matches, but the repo is not discoverable
// because the global registry entry is missing.
fs.writeFileSync(path.join(gnHome, 'registry.json'), '[]', 'utf-8');
const second = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000);
expect(
second.status,
[
'second analyze timed out before proving alreadyUpToDate finalization',
`stdout: ${second.stdout}`,
`stderr: ${second.stderr}`,
].join('\n'),
).not.toBeNull();
expect(`${second.stdout}${second.stderr}`).toMatch(/Analysis did not finalize/i);
expect(`${second.stdout}${second.stderr}`).toMatch(/registry entry/i);
expect(second.status).toBe(1);
} finally {
cleanupTempDirSync(gnHome);
cleanupTempDirSync(repoParent);
}
}, 60_000);
// ─── analyze --name <alias> + --allow-duplicate-name (#829) ──────
//
// End-to-end regression guard for the name-collision feature:
// 1. `analyze --name X` persists the alias to ~/.gitnexus/registry.json
// 2. A second `analyze --name X` on a DIFFERENT path is rejected with
// a collision error (exit code 1, "already used" in output)
// 3. `analyze --name X --allow-duplicate-name` bypasses the guard;
// both entries coexist in registry.json
// 4. Pipeline-re-index flags (e.g. --skills) WITHOUT
// --allow-duplicate-name must STILL hit the collision guard —
// the bypass must stay gated on its dedicated flag so it isn't
// silently triggered by unrelated pipeline signals
// (review round 2/3 design decision).
//
// This test invokes the real CLI → runFullAnalysis → registerRepo
// chain, so any wiring regression fails here.
describe('analyze --name <alias> and --allow-duplicate-name (#829)', () => {
// Path-equality assertions across CLI spawn boundaries are fragile
// cross-platform:
// - macOS: os.tmpdir() returns /var/folders/...; child processes
// resolve the symlink to /private/var/folders/...
// - Windows: os.tmpdir() on GitHub runners returns 8.3 short-name
// form (C:\Users\RUNNER~1\...); the child sees the long form
// (C:\Users\runneradmin\...). fs.realpathSync does NOT reliably
// expand 8.3 to long form.
// Rather than fight the platform-path quagmire, we assert STRUCTURAL
// properties: entry count, alias value, path basename, path
// distinctness. That covers the behavior this test is here to
// protect without depending on exact-string path equality.
it('--name alias stores; collision rejects; --allow-duplicate-name bypasses', () => {
// Isolate the global registry so this test never touches the
// developer's real ~/.gitnexus.
const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-'));
// Two mini-repo copies whose basenames intentionally collide.
const repoA = makeMiniRepoCopy('collide-app', 'gn-collide-a-');
const repoB = makeMiniRepoCopy('collide-app', 'gn-collide-b-');
const parentA = path.dirname(repoA);
const parentB = path.dirname(repoB);
try {
// Step 1: analyze repoA with --name shared → registry entry created.
const r1 = runCliWithEnv(
['analyze', '--name', 'shared'],
repoA,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r1.status === null) return; // CI timeout tolerance
expect(
r1.status,
[`step 1 exited with ${r1.status}`, `stdout: ${r1.stdout}`, `stderr: ${r1.stderr}`].join(
'\n',
),
).toBe(0);
const registryPath = path.join(gnHome, 'registry.json');
const afterStep1 = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(Array.isArray(afterStep1)).toBe(true);
expect(afterStep1).toHaveLength(1);
expect(afterStep1[0].name).toBe('shared');
expect(path.basename(afterStep1[0].path)).toBe('collide-app');
// Step 2: analyze repoB with the SAME --name → collision error.
const r2 = runCliWithEnv(
['analyze', '--name', 'shared'],
repoB,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r2.status === null) return;
expect(r2.status).toBe(1);
const r2Output = `${r2.stdout}${r2.stderr}`;
expect(r2Output).toMatch(/Registry name collision|already used/i);
// Registry still has just the first entry — step 2 must not have
// silently added, overwritten, or corrupted anything.
const afterStep2 = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(afterStep2).toHaveLength(1);
// Registry still has only the step-1 entry — the failed call
// must not have silently added, overwritten, or corrupted state.
expect(afterStep2[0].path).toBe(afterStep1[0].path);
// Step 3: REGRESSION GUARD for the missing collision-bypass wire
// (originally a --force passthrough bug; per review round 3 the
// bypass moved to its own --allow-duplicate-name flag to avoid
// conflating it with pipeline re-index).
const r3 = runCliWithEnv(
['analyze', '--name', 'shared', '--allow-duplicate-name'],
repoB,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r3.status === null) return;
expect(
r3.status,
[
`step 3 (--allow-duplicate-name bypass) exited with ${r3.status}`,
`stdout: ${r3.stdout}`,
`stderr: ${r3.stderr}`,
].join('\n'),
).toBe(0);
const afterStep3 = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(afterStep3).toHaveLength(2);
expect(afterStep3.every((e: { name: string }) => e.name === 'shared')).toBe(true);
// Both entries point to distinct paths (we registered two different
// repos under the same alias) and both have the right basename.
const step3Basenames = afterStep3.map((e: { path: string }) => path.basename(e.path));
expect(step3Basenames).toEqual(['collide-app', 'collide-app']);
const step3Paths = new Set(afterStep3.map((e: { path: string }) => e.path));
expect(step3Paths.size).toBe(2);
// One of the two entries is the original from step 1 — unchanged.
expect(afterStep3.map((e: { path: string }) => e.path)).toContain(afterStep1[0].path);
// Step 4: REGRESSION GUARD for the design decision in review
// round 2/3 — pipeline-re-index flags must NOT bypass the
// registry collision guard. `--skills` triggers pipeline
// re-run (skills generation needs a fresh pipelineResult) but
// must leave the registry guard in force. Bypass requires the
// explicit --allow-duplicate-name flag.
const repoC = makeMiniRepoCopy('collide-app', 'gn-collide-c-');
const parentC = path.dirname(repoC);
try {
const r4 = runCliWithEnv(
['analyze', '--name', 'shared', '--skills'],
repoC,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r4.status === null) return;
expect(r4.status).toBe(1);
const r4Output = `${r4.stdout}${r4.stderr}`;
expect(r4Output).toMatch(/Registry name collision|already used/i);
// The error hint should point at the new flag.
expect(r4Output).toMatch(/--allow-duplicate-name/);
// Registry unchanged — still only A + B under "shared".
const afterStep4 = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(afterStep4).toHaveLength(2);
} finally {
cleanupTempDirSync(parentC);
}
} finally {
cleanupTempDirSync(gnHome);
cleanupTempDirSync(parentA);
cleanupTempDirSync(parentB);
}
}, 360000); // 6-min outer budget (4 × ~60s analyze calls + fixture setup)
});
// ─── gitnexus remove <target> (#664) ─────────────────────────────
//
// End-to-end regression guard for the remove command:
// 1. `remove <alias>` without --force is a dry-run (exit 0, preserves state)
// 2. `remove <alias> --force` deletes the .gitnexus/ directory
// AND unregisters from the global registry
// 3. `remove <unknown>` is idempotent (exit 0 with a warning)
// 4. `remove <ambiguous>` (two entries share the alias via
// --allow-duplicate-name) exits 1 with a disambiguation hint
// and leaves the registry unchanged.
//
// Every assertion reads the real registry.json on disk, so any
// regression in remove.ts → resolveRegistryEntry → unregisterRepo
// will surface here.
describe('remove <target> (#664)', () => {
it('dry-run lists, --force deletes, missing target is a no-op warning', () => {
const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-remove-'));
const repoA = makeMiniRepoCopy('remove-me', 'gn-rm-a-');
const parentA = path.dirname(repoA);
try {
// Index the repo under a custom alias so we can target it by
// name below. `--name` guarantees a stable alias regardless of
// how the host resolves the basename/remote-inferred name.
const r1 = runCliWithEnv(
['analyze', '--name', 'alias-a'],
repoA,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r1.status === null) return;
expect(
r1.status,
[`analyze exited with ${r1.status}`, `stdout: ${r1.stdout}`, `stderr: ${r1.stderr}`].join(
'\n',
),
).toBe(0);
const registryPath = path.join(gnHome, 'registry.json');
const afterIndex = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(afterIndex).toHaveLength(1);
expect(afterIndex[0].name).toBe('alias-a');
// Storage dir must exist before remove so we can assert its
// disappearance below.
const storagePath = afterIndex[0].storagePath;
expect(fs.existsSync(storagePath)).toBe(true);
// Dry-run: must NOT delete. Use parentA as cwd so the test
// never runs with the to-be-removed storage dir as its cwd.
//
// Assert the FULL dry-run output shape, not just the `--force`
// hint (#1003 senior-reviewer NIT): `remove.ts` prints the
// alias, the resolved path, AND the storage path. Verifying
// all three appear catches silent format regressions
// (e.g. a future refactor that accidentally drops one of the
// three `console.log` lines, or swaps `entry.path` for
// `entry.name` in the output).
const r2 = runCliWithEnv(['remove', 'alias-a'], parentA, { GITNEXUS_HOME: gnHome }, 15000);
if (r2.status === null) return;
expect(r2.status).toBe(0);
const r2Output = `${r2.stdout}${r2.stderr}`;
expect(r2Output).toMatch(/Run with --force/i);
expect(r2Output, 'dry-run must surface the alias').toContain('alias-a');
expect(r2Output, 'dry-run must surface the repo path').toContain(afterIndex[0].path);
expect(r2Output, 'dry-run must surface the storage path').toContain(storagePath);
expect(fs.existsSync(storagePath)).toBe(true);
// Registry still has the entry.
expect(JSON.parse(fs.readFileSync(registryPath, 'utf-8'))).toHaveLength(1);
// --force: must delete storage AND unregister.
const r3 = runCliWithEnv(
['remove', 'alias-a', '--force'],
parentA,
{ GITNEXUS_HOME: gnHome },
15000,
);
if (r3.status === null) return;
expect(
r3.status,
[
`remove --force exited with ${r3.status}`,
`stdout: ${r3.stdout}`,
`stderr: ${r3.stderr}`,
].join('\n'),
).toBe(0);
// Success-case output shape: `Removed: <alias>` header plus the
// same path-and-storagePath lines the dry-run prints (same NIT
// rationale — the success branch mirrors the dry-run's three
// console.log calls, so it has the same silent-regression risk).
const r3Output = `${r3.stdout}${r3.stderr}`;
expect(r3Output).toMatch(/Removed/i);
expect(r3Output, 'success output must surface the alias').toContain('alias-a');
expect(r3Output, 'success output must surface the repo path').toContain(afterIndex[0].path);
expect(r3Output, 'success output must surface the storage path').toContain(storagePath);
expect(fs.existsSync(storagePath)).toBe(false);
expect(JSON.parse(fs.readFileSync(registryPath, 'utf-8'))).toHaveLength(0);
// Idempotent: removing the same alias AGAIN must exit 0 with a
// warning (so `remove X && analyze Y` keeps working in scripts).
const r4 = runCliWithEnv(['remove', 'alias-a'], parentA, { GITNEXUS_HOME: gnHome }, 15000);
if (r4.status === null) return;
expect(r4.status).toBe(0);
expect(`${r4.stdout}${r4.stderr}`).toMatch(/Nothing to remove/i);
} finally {
cleanupTempDirSync(gnHome);
cleanupTempDirSync(parentA);
}
}, 180000); // 3-min outer budget (1 × ~60s analyze + 3 × fast remove calls)
it('ambiguous target (two entries share alias via --allow-duplicate-name) errors without mutating registry', () => {
const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-rm-amb-'));
const repoA = makeMiniRepoCopy('dup', 'gn-dup-a-');
const repoB = makeMiniRepoCopy('dup', 'gn-dup-b-');
const parentA = path.dirname(repoA);
const parentB = path.dirname(repoB);
try {
// Two repos registered under the same alias — only possible via
// --allow-duplicate-name (#829).
const r1 = runCliWithEnv(
['analyze', '--name', 'shared'],
repoA,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r1.status === null) return;
expect(r1.status).toBe(0);
const r2 = runCliWithEnv(
['analyze', '--name', 'shared', '--allow-duplicate-name'],
repoB,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r2.status === null) return;
expect(r2.status).toBe(0);
const registryPath = path.join(gnHome, 'registry.json');
const before = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(before).toHaveLength(2);
// `remove shared` must refuse to guess — exit 1, disambiguation hint.
const r3 = runCliWithEnv(
['remove', 'shared', '--force'],
parentA,
{ GITNEXUS_HOME: gnHome },
15000,
);
if (r3.status === null) return;
expect(r3.status).toBe(1);
const r3Output = `${r3.stdout}${r3.stderr}`;
expect(r3Output).toMatch(/Multiple registered repos match/i);
// Both paths must be surfaced in the hint so the user knows
// which ones to disambiguate between.
expect(r3Output).toMatch(/dup/);
// Registry unchanged — the failed resolution must NOT have
// mutated state.
const after = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(after).toHaveLength(2);
// And path-based remove still works: pass the absolute path of
// repoA and it resolves unambiguously.
//
// We pull the path from the registry snapshot rather than
// passing the outer `repoA` variable directly. This is the
// belt-and-suspenders for cross-platform path normalisation
// (#1003 review): the path the registry recorded has already
// gone through the analyze-side canonicalisation (which on
// macOS expands /var → /private/var and on Windows expands 8.3
// → long-name). Passing that exact string back to `remove`
// guarantees the comparison succeeds even on runners where the
// outer `repoA` is the symlink/short-name form. The code-side
// fix in `canonicalizePath` makes this redundant in practice,
// but the test shouldn't depend on the code fix being perfect
// on every platform — it should prove correctness against the
// registry contract.
const repoAEntry = before.find(
(e: { path: string }) =>
path.basename(e.path) === 'dup' && e.path.includes(path.basename(parentA)),
);
expect(
repoAEntry,
'repoA entry must exist in registry before path-remove step',
).toBeDefined();
const r4 = runCliWithEnv(
['remove', repoAEntry.path, '--force'],
parentA,
{ GITNEXUS_HOME: gnHome },
15000,
);
if (r4.status === null) return;
expect(
r4.status,
[
`remove-by-path exited with ${r4.status}`,
`stdout: ${r4.stdout}`,
`stderr: ${r4.stderr}`,
].join('\n'),
).toBe(0);
const finalEntries = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(finalEntries).toHaveLength(1);
// The survivor is repoB (its path stays in the registry).
expect(path.basename(finalEntries[0].path)).toBe('dup');
// And it's NOT the one we just removed.
expect(finalEntries[0].path).not.toBe(repoAEntry.path);
} finally {
cleanupTempDirSync(gnHome);
cleanupTempDirSync(parentA);
cleanupTempDirSync(parentB);
}
}, 240000); // 4-min outer budget (2 × ~60s analyze + 2 × fast remove)
it('refuses to proceed when a registry entry points storagePath outside <repo>/.gitnexus (#1003)', () => {
// Regression guard for the safety gap flagged by @magyargergo on
// PR #1003: `~/.gitnexus/registry.json` is a user-writable JSON
// file, so a corrupted or hand-edited entry could point
// storagePath at the repo root (catastrophic: rm the working
// tree) or at any other arbitrary path. `remove --force` must
// refuse to call fs.rm when storagePath isn't the canonical
// `<entry.path>/.gitnexus`. We verify:
// 1. Exit code 1 with the actionable "registry entry corrupted"
// hint.
// 2. The .gitnexus/ storage dir is UNTOUCHED.
// 3. The repo itself (entry.path) is UNTOUCHED.
// 4. The registry entry is NOT removed (no partial mutation).
const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-poison-'));
const repo = makeMiniRepoCopy('poisoned', 'gn-poison-');
const parent = path.dirname(repo);
try {
// Index the repo normally first so the registry has a valid
// entry we can then poison.
const r1 = runCliWithEnv(
['analyze', '--name', 'poisoned-alias'],
repo,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r1.status === null) return;
expect(r1.status).toBe(0);
const registryPath = path.join(gnHome, 'registry.json');
const original = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(original).toHaveLength(1);
// Poison the entry: set storagePath to the REPO ROOT itself.
// If the guard isn't in place, `remove --force` would call
// `fs.rm(repo, {recursive: true, force: true})` and wipe the
// entire working tree.
const poisoned = [{ ...original[0], storagePath: repo }];
fs.writeFileSync(registryPath, JSON.stringify(poisoned, null, 2));
// Sanity: storage dir and working tree both still exist.
expect(fs.existsSync(path.join(repo, '.gitnexus'))).toBe(true);
expect(fs.existsSync(repo)).toBe(true);
expect(fs.existsSync(path.join(repo, '.git'))).toBe(true);
// Attempt the remove — must FAIL without deleting anything.
const r2 = runCliWithEnv(
['remove', 'poisoned-alias', '--force'],
parent,
{ GITNEXUS_HOME: gnHome },
15000,
);
if (r2.status === null) return;
expect(
r2.status,
[`remove should have exited 1`, `stdout: ${r2.stdout}`, `stderr: ${r2.stderr}`].join(
'\n',
),
).toBe(1);
const r2Output = `${r2.stdout}${r2.stderr}`;
// Must surface the actionable "registry corrupted" hint, not
// just a raw fs.rm error.
expect(r2Output).toMatch(/Refusing to remove/i);
expect(r2Output).toMatch(/registry\.json/i);
// Repo + .gitnexus dir + .git dir must all still exist — the
// guard aborts BEFORE fs.rm. This is the whole point of the
// test: the working tree is not allowed to disappear.
expect(fs.existsSync(repo), 'repo working tree must survive').toBe(true);
expect(fs.existsSync(path.join(repo, '.gitnexus')), 'storage dir must survive').toBe(true);
expect(fs.existsSync(path.join(repo, '.git')), '.git must survive').toBe(true);
// Registry unchanged — no partial mutation.
const afterRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(afterRegistry).toHaveLength(1);
expect(afterRegistry[0].storagePath).toBe(repo); // still poisoned (we did that)
} finally {
cleanupTempDirSync(gnHome);
cleanupTempDirSync(parent);
}
}, 120000); // 2-min budget (1 × ~60s analyze + 1 × fast remove-refused)
});
// ─── clean --all: same safety guard applies (#1003 review) ───────
//
// The `clean --all` path iterates over the registry and calls
// `fs.rm(entry.storagePath)` — identical trust-the-registry pattern
// as `remove` had before the guard. A poisoned entry must be SKIPPED
// (not aborted), so clean --all preserves its existing per-repo
// error-tolerance semantics: one bad entry does not halt cleanup of
// the rest. We verify:
// 1. The poisoned entry is NOT deleted (working tree + .gitnexus
// survive), and the CLI prints a "Refusing to clean" message.
// 2. The poisoned entry is left in the registry (nothing was
// mutated for it).
// 3. A co-existing well-formed entry IS still cleaned (both its
// .gitnexus dir AND its registry entry are gone).
describe('clean --all with a poisoned registry entry (#1003)', () => {
it('skips poisoned entries, cleans valid ones, never deletes the working tree', () => {
const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-clean-poison-'));
const repoBad = makeMiniRepoCopy('bad-repo', 'gn-clean-bad-');
const repoGood = makeMiniRepoCopy('good-repo', 'gn-clean-good-');
const parentBad = path.dirname(repoBad);
const parentGood = path.dirname(repoGood);
try {
// Analyze both so the registry has two well-formed entries.
for (const [repo, alias] of [
[repoBad, 'bad-alias'],
[repoGood, 'good-alias'],
] as const) {
const r = runCliWithEnv(
['analyze', '--name', alias],
repo,
{ GITNEXUS_HOME: gnHome },
60000,
);
if (r.status === null) return;
expect(r.status, `analyze ${alias} exited ${r.status}: ${r.stdout}${r.stderr}`).toBe(0);
}
const registryPath = path.join(gnHome, 'registry.json');
const original = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(original).toHaveLength(2);
// Poison the 'bad-alias' entry by pointing its storagePath at
// the repo root itself. If the guard isn't wired into the
// clean --all loop, `clean --all --force` would fs.rm the
// working tree.
const poisoned = original.map((e: { name: string; storagePath: string; path: string }) =>
e.name === 'bad-alias' ? { ...e, storagePath: repoBad } : e,
);
fs.writeFileSync(registryPath, JSON.stringify(poisoned, null, 2));
// Sanity: both working trees and .gitnexus dirs still exist.
expect(fs.existsSync(repoBad)).toBe(true);
expect(fs.existsSync(path.join(repoBad, '.gitnexus'))).toBe(true);
expect(fs.existsSync(path.join(repoBad, '.git'))).toBe(true);
expect(fs.existsSync(path.join(repoGood, '.gitnexus'))).toBe(true);
// clean --all --force from a neutral cwd (parentBad), so the
// command isn't "inside" either repo.
const r = runCliWithEnv(
['clean', '--all', '--force'],
parentBad,
{ GITNEXUS_HOME: gnHome },
30000,
);
if (r.status === null) return;
// clean --all's per-entry error handling always exits 0 at
// the end (it only logs per-repo failures). The important
// assertions are on side effects, not the exit code.
const output = `${r.stdout}${r.stderr}`;
expect(output).toMatch(/Refusing to clean/i);
expect(output).toMatch(/bad-alias/);
// Poisoned repo: working tree + .gitnexus + .git all SURVIVE.
expect(fs.existsSync(repoBad), 'poisoned repo working tree must survive').toBe(true);
expect(
fs.existsSync(path.join(repoBad, '.gitnexus')),
'poisoned repo .gitnexus must survive (guard refused to rm repo root)',
).toBe(true);
expect(fs.existsSync(path.join(repoBad, '.git')), '.git must survive').toBe(true);
// Good repo: its .gitnexus IS gone (cleanup succeeded despite
// the poisoned sibling entry — per-entry error tolerance is
// preserved).
expect(
fs.existsSync(path.join(repoGood, '.gitnexus')),
'good repo .gitnexus should be cleaned',
).toBe(false);
// But the good repo's working tree stays (clean never touches
// anything outside .gitnexus).
expect(fs.existsSync(repoGood), 'good repo working tree must survive').toBe(true);
// Registry post-state: poisoned entry still present (skipped,
// not mutated); good entry unregistered.
const afterRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(afterRegistry).toHaveLength(1);
expect(afterRegistry[0].name).toBe('bad-alias');
} finally {
cleanupTempDirSync(gnHome);
cleanupTempDirSync(parentBad);
cleanupTempDirSync(parentGood);
}
}, 240000); // 4-min budget (2 × ~60s analyze + 1 × fast clean --all)
});
describe('unhappy path', () => {
it('exits with error when no command is given', () => {
const result = runCliRaw([], MINI_REPO);
// Accept timeout as valid on slow CI
if (result.status === null) return;
// Commander exits with code 1 when no subcommand is given and
// prints a usage/error message to stderr.
expect(result.status).toBe(1);
const combined = result.stdout + result.stderr;
expect(combined.length).toBeGreaterThan(0);
});
it('shows help with --help flag', () => {
const result = runCliRaw(['--help'], MINI_REPO);
// Accept timeout as valid on slow CI
if (result.status === null) return;
expect(result.status).toBe(0);
// Commander writes --help output to stdout.
expect(result.stdout).toMatch(/Usage:/i);
// The program name and at least one known subcommand should appear.
expect(result.stdout).toMatch(/gitnexus/i);
expect(result.stdout).toMatch(/analyze|status|serve/i);
});
it('fails with unknown command', () => {
const result = runCliRaw(['nonexistent'], MINI_REPO);
// Accept timeout as valid on slow CI
if (result.status === null) return;
// Commander exits with code 1 and prints an error to stderr for unknown commands.
expect(result.status).toBe(1);
expect(result.stderr).toMatch(/unknown command/i);
});
});
describe('CLI error handling', () => {
/**
* Helper to spawn CLI from a cwd outside the project tree.
* Uses the absolute file:// URL to tsx loader so the --import hook
* resolves even when cwd has no node_modules.
*/
function runCliOutsideProject(args: string[], cwd: string, timeoutMs = 15000) {
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...args], {
cwd,
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: cliEnv(),
});
}
it('status on non-indexed repo reports not indexed', () => {
// Even though MINI_REPO is now in an isolated tmpdir, previous tests
// in this suite may have created MINI_REPO/.gitnexus via analyze,
// and findRepo() walks up so any `.gitnexus` along the path still
// counts. This test needs a GUARANTEED pristine repo to assert the
// "not indexed" output, so it mints its own throwaway tmp git repo.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-noindex-'));
try {
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], {
cwd: tmpDir,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
},
});
const result = runCliOutsideProject(['status'], tmpDir);
if (result.status === null) return;
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Repository not indexed/);
} finally {
cleanupTempDirSync(tmpDir);
}
});
it('status on non-git directory reports not a git repo', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-nogit-'));
try {
const result = runCliOutsideProject(['status'], tmpDir);
if (result.status === null) return;
// status.ts doesn't set process.exitCode — just prints and returns
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Not a git repository/);
} finally {
cleanupTempDirSync(tmpDir);
}
});
it('analyze on non-git directory fails with exit code 1', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-nogit-'));
try {
// Pass the non-git path as a separate argument via runCliRaw
// (runCli passes the whole string as one arg which breaks path parsing)
const result = runCliRaw(['analyze', tmpDir], repoRoot);
if (result.status === null) return;
// analyze.ts sets process.exitCode = 1 for non-git paths
expect(result.status).toBe(1);
expect(result.stdout).toMatch(/not.*git repository/i);
} finally {
cleanupTempDirSync(tmpDir);
}
});
});
// ─── wiki command flags ─────────────────────────────────────────────
describe('wiki command flags', () => {
it('wiki --help shows --provider, --review, --verbose flags', () => {
const result = runCliRaw(['wiki', '--help'], repoRoot);
if (result.status === null) return;
expect(result.status).toBe(0);
expect(result.stdout).toContain('--provider <provider>');
expect(result.stdout).toContain('claude');
expect(result.stdout).toContain('codex');
expect(result.stdout).toContain('--review');
expect(result.stdout).toContain('-v, --verbose');
expect(result.stdout).toContain('--model <model>');
expect(result.stdout).toContain('--gist');
expect(result.stdout).toContain('--concurrency <n>');
});
it('wiki on non-git directory fails with exit code 1', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-nogit-'));
try {
const result = runCliRaw(['wiki', tmpDir], repoRoot);
if (result.status === null) return;
expect(result.status).toBe(1);
expect(result.stdout).toMatch(/not.*git repository/i);
} finally {
cleanupTempDirSync(tmpDir);
}
});
it('wiki on non-indexed repo fails with "No GitNexus index"', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-noindex-'));
try {
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], {
cwd: tmpDir,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
},
});
// Must spawn outside project tree so it doesn't find parent .gitnexus
const result = spawnSync(
process.execPath,
['--import', tsxImportUrl, cliEntry, 'wiki', tmpDir],
{
cwd: tmpDir,
encoding: 'utf8',
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe'],
env: cliEnv(),
},
);
if (result.status === null) return;
expect(result.status).toBe(1);
expect(result.stdout).toMatch(/No GitNexus index found/);
} finally {
cleanupTempDirSync(tmpDir);
}
});
it('wiki --provider cursor without API key does not prompt for key in non-TTY', () => {
// In non-TTY (piped stdin), --provider cursor should skip the API key prompt
// and proceed (or fail gracefully with Cursor CLI not found)
const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'cursor'], repoRoot, 15000);
if (result.status === null) return;
const combined = result.stdout + result.stderr;
// Should NOT ask for API key — cursor provider doesn't need one
expect(combined).not.toMatch(/API key:/);
});
it('wiki --provider claude without API key does not prompt for key in non-TTY', () => {
const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'claude'], repoRoot, 15000);
if (result.status === null) return;
const combined = result.stdout + result.stderr;
expect(combined).not.toMatch(/API key:/);
});
it('wiki --provider codex without API key does not prompt for key in non-TTY', () => {
const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'codex'], repoRoot, 15000);
if (result.status === null) return;
const combined = result.stdout + result.stderr;
expect(combined).not.toMatch(/API key:/);
});
it('wiki --help includes --verbose flag description', () => {
const result = runCliRaw(['wiki', '--help'], repoRoot);
if (result.status === null) return;
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/verbose/i);
});
});
// ─── stdout fd 1 tests (#324) ───────────────────────────────────────
// These tests verify that tool output goes to stdout (fd 1), not stderr.
// Requires analyze to have run first (the analyze test above populates .gitnexus/).
// All tool commands pass --repo to disambiguate when the global registry
// has multiple indexed repos (e.g. the parent project is also indexed).
describe('tool output goes to stdout via fd 1 (#324)', () => {
it('cypher: JSON appears on stdout, not stderr', () => {
const result = runCliRaw(
['cypher', 'MATCH (n) RETURN n.name LIMIT 3', '--repo', 'mini-repo'],
MINI_REPO,
);
if (result.status === null) return; // CI timeout tolerance
expect(result.status).toBe(0);
// stdout must contain valid JSON (array or object)
expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
// stderr must NOT contain JSON — only human-readable diagnostics allowed
const stderrTrimmed = result.stderr.trim();
if (stderrTrimmed.length > 0) {
expect(() => JSON.parse(stderrTrimmed)).toThrow();
}
});
it('query: JSON appears on stdout, not stderr', () => {
// "handler" is a generic term likely to match something in mini-repo
const result = runCliRaw(['query', 'handler', '--repo', 'mini-repo'], MINI_REPO);
if (result.status === null) return;
expect(result.status).toBe(0);
expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
});
it('impact: JSON appears on stdout, not stderr', () => {
const result = runCliRaw(
['impact', 'handleRequest', '--direction', 'upstream', '--repo', 'mini-repo'],
MINI_REPO,
);
if (result.status === null) return;
expect(result.status).toBe(0);
// impact may return an error object (symbol not found) or a real result —
// either way it must be valid JSON on stdout
expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
});
it('stdout is pipeable: cypher output parses as valid JSON', () => {
const result = runCliRaw(
['cypher', 'MATCH (n:Function) RETURN n.name LIMIT 5', '--repo', 'mini-repo'],
MINI_REPO,
);
if (result.status === null) return;
expect(result.status).toBe(0);
// Simulate what jq does: parse stdout as JSON
const parsed = JSON.parse(result.stdout.trim());
expect(Array.isArray(parsed) || typeof parsed === 'object').toBe(true);
});
});
// ─── EPIPE clean exit test (#324) ───────────────────────────────────
describe('EPIPE handling (#324)', () => {
it('cypher: EPIPE exits with code 0, not stderr dump', () => {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
[
'--import',
tsxImportUrl,
cliEntry,
'cypher',
'MATCH (n) RETURN n LIMIT 500',
'--repo',
'mini-repo',
],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: cliEnv(),
},
);
let stderrOutput = '';
child.stderr.on('data', (chunk: Buffer) => {
stderrOutput += chunk.toString();
});
// Destroy stdout immediately — simulates `| head -0` (consumer closes early)
child.stdout.once('data', () => {
child.stdout.destroy(); // triggers EPIPE on next write
});
const timer = setTimeout(() => {
child.kill('SIGTERM');
// Timeout is acceptable on CI — not a failure
resolve();
}, 20000);
child.on('close', (code) => {
clearTimeout(timer);
try {
// Clean EPIPE exit: code 0
expect(code).toBe(0);
// No JSON payload should appear on stderr
const trimmed = stderrOutput.trim();
if (trimmed.length > 0) {
expect(() => JSON.parse(trimmed)).toThrow();
}
resolve();
} catch (err) {
reject(err);
}
});
});
}, 25000);
});
// ─── eval-server READY signal test (#324) ───────────────────────────
describe('eval-server READY signal (#324)', () => {
it('READY signal appears on stdout, not stderr', () => {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
['--import', tsxImportUrl, cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: cliEnv(),
},
);
let stdoutBuffer = '';
let foundOnStdout = false;
let foundOnStderr = false;
child.stdout.on('data', (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) {
foundOnStdout = true;
child.kill('SIGTERM');
}
});
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString();
if (text.includes('GITNEXUS_EVAL_SERVER_READY:')) {
foundOnStderr = true;
child.kill('SIGTERM');
}
});
const timer = setTimeout(() => {
child.kill('SIGTERM');
// Timeout is acceptable on CI — not a failure
resolve();
}, 30000);
child.on('close', () => {
clearTimeout(timer);
try {
if (foundOnStderr) {
reject(new Error('READY signal appeared on stderr instead of stdout'));
} else if (foundOnStdout) {
resolve();
} else {
// eval-server may not start on all CI environments — don't fail
resolve();
}
} catch (err) {
reject(err);
}
});
});
}, 35000);
});
// ─── eval-server --host flag tests ───────────────────────────────────
// Verifies --host is wired to the actual bind address, not just accepted.
// Original flag registration test by Val Vladescu (PR #1602).
describe('eval-server --host flag', () => {
it('emits READY signal containing the bound host 127.0.0.1', () => {
return runEvalServerHostFlagTest(
['--port', '0', '--host', '127.0.0.1', '--idle-timeout', '3'],
{
timeoutMsg: 'eval-server did not emit READY signal within 30s',
onStdout({ stdoutBuffer, settle, resolve, reject }) {
if (!stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) return;
if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) {
settle(resolve);
} else {
settle(() =>
reject(
new Error(
`READY signal did not contain expected host 127.0.0.1:\n${stdoutBuffer}`,
),
),
);
}
},
},
);
}, 35000);
it('binds to 0.0.0.0 and serves /health on 127.0.0.1 (cross-container use case)', () => {
return runEvalServerHostFlagTest(
['--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'],
{
timeoutMsg: 'eval-server --host 0.0.0.0 did not emit READY signal within 30s',
async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) {
const readyLine = stdoutBuffer
.split('\n')
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:'));
if (!readyLine || isSettled()) return;
// Parse the actual OS-assigned port from the READY signal
const boundPort = readyLine.split(':').pop()?.trim();
if (!boundPort || isNaN(Number(boundPort))) {
settle(() =>
reject(new Error(`Could not parse port from READY signal: ${readyLine}`)),
);
return;
}
// A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host
try {
const res = await fetch(`http://127.0.0.1:${boundPort}/health`);
if (res.status === 200) {
settle(resolve);
} else {
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
}
} catch (err) {
settle(() =>
reject(
new Error(
`eval-server bound to 0.0.0.0 but /health unreachable on 127.0.0.1:${boundPort}: ${err}`,
),
),
);
}
},
},
);
}, 35000);
it('emits READY signal with bound IP (not literal "localhost") when --host localhost is used', () => {
return runEvalServerHostFlagTest(
['--port', '0', '--host', 'localhost', '--idle-timeout', '3'],
{
timeoutMsg: 'eval-server --host localhost did not emit READY signal within 30s',
async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) {
const readyLine = stdoutBuffer
.split('\n')
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:'));
if (!readyLine || isSettled()) return;
// The signal must contain a real bound IP, not the literal input string
if (readyLine.includes(':localhost:')) {
settle(() =>
reject(
new Error(
`READY signal contained literal "localhost" instead of a bound IP:\n${readyLine}`,
),
),
);
return;
}
// Parse host and port: everything after the prefix up to the last colon
const withoutPrefix = readyLine.slice('GITNEXUS_EVAL_SERVER_READY:'.length);
const lastColon = withoutPrefix.lastIndexOf(':');
const signalHost = withoutPrefix.slice(0, lastColon); // "127.0.0.1" or "[::1]"
const boundPort = withoutPrefix.slice(lastColon + 1).trim();
if (!boundPort || isNaN(Number(boundPort))) {
settle(() =>
reject(new Error(`Could not parse port from READY signal: ${readyLine}`)),
);
return;
}
// Probe /health at the bound address to confirm the server is reachable
try {
const res = await fetch(`http://${signalHost}:${boundPort}/health`);
if (res.status === 200) {
settle(resolve);
} else {
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
}
} catch (err) {
settle(() =>
reject(
new Error(
`eval-server bound to localhost but /health unreachable at ${signalHost}:${boundPort}: ${err}`,
),
),
);
}
},
},
);
}, 35000);
});
});
// ─── impact disambiguation flags reach the backend at runtime (#1907 U2) ──
// The mocked unit test proves the CLI option → callTool param mapping; this
// proves the flags survive the real Commander → lazy-action → impactCommand →
// callTool chain by spawning the actual CLI. The F2 gap is *flag-forwarding*,
// so a uniquely-named fixture symbol is enough — no ambiguous fixture needed.
// Tests self-skip when the environment cannot index the fixture (e.g. a
// worktree without the built parse-worker); CI validates the real path.
describe('impact disambiguation flags reach the backend (e2e, #1907)', () => {
const SYMBOL = 'formatResponse'; // uniquely named, in mini-repo/src/formatter.ts
let uid: string | undefined;
let symbolFile: string | undefined;
beforeAll(() => {
// Idempotent: the earlier analyze test may already have indexed mini-repo.
runCli('analyze', MINI_REPO, 60000);
// Derive the real uid + filePath from context so the test is robust to the
// exact uid format rather than hard-coding `Function:<path>:<name>`.
const ctx = runCliRaw(['context', SYMBOL, '--repo', 'mini-repo'], MINI_REPO, 30000);
if (ctx.status === 0) {
try {
const parsed = JSON.parse(ctx.stdout.trim());
uid = parsed?.symbol?.uid;
symbolFile = parsed?.symbol?.filePath;
} catch {
/* leave undefined → tests self-skip below */
}
}
});
it('forwards --uid alone with no positional target (U1 + --uid end-to-end)', () => {
if (!uid) return; // environment could not index — validated in CI
const res = runCliRaw(['impact', '--uid', uid, '--repo', 'mini-repo'], MINI_REPO, 30000);
if (res.status === null) return;
expect(res.status).toBe(0);
const out = JSON.parse(res.stdout.trim());
expect(out).not.toHaveProperty('error');
expect(out.target?.id).toBe(uid);
});
it('forwards --file: the correct file resolves, a wrong file does not (negative control)', () => {
if (!uid || !symbolFile) return;
const ok = runCliRaw(
['impact', SYMBOL, '--file', symbolFile, '--repo', 'mini-repo'],
MINI_REPO,
30000,
);
if (ok.status === null) return;
expect(ok.status).toBe(0);
const okOut = JSON.parse(ok.stdout.trim());
expect(okOut.status).not.toBe('ambiguous');
expect(okOut.target?.filePath).toBe(symbolFile);
// Wrong --file hint → CONTAINS matches nothing → must NOT resolve to the
// formatter.ts symbol. Proves the --file value reached the resolver.
const wrong = runCliRaw(
['impact', SYMBOL, '--file', 'does/not/exist/nowhere.ts', '--repo', 'mini-repo'],
MINI_REPO,
30000,
);
if (wrong.status === null) return;
const wrongOut = JSON.parse(wrong.stdout.trim());
expect(wrongOut.error !== undefined || wrongOut.target?.filePath !== symbolFile).toBe(true);
});
it('forwards --kind: exit 0 with the kind hint applied', () => {
if (!uid) return;
const res = runCliRaw(
['impact', SYMBOL, '--kind', 'Function', '--repo', 'mini-repo'],
MINI_REPO,
30000,
);
if (res.status === null) return;
expect(res.status).toBe(0);
const out = JSON.parse(res.stdout.trim());
expect(out).not.toHaveProperty('error');
});
});