GitNexus/gitnexus/test/unit/analyze-config.test.ts
Ankit Verma 8ddab9aed5
feat(api): honor branch on POST /api/analyze (#3199)
* feat(api): honor branch on POST /api/analyze

The serve route accepted a `branch` field in the request body, returned
202 and reported the job `complete` — while indexing the remote's default
branch. Express drops unknown body fields, so the caller got no error and
no warning; the only way to notice was to inspect the checked-out clone.

Both ends of the plumbing already existed: CloneOrPullOptions.branch is
honored by cloneOrPull, and AnalyzeOptions.branch already drives
resolveBranchPlacement. Only the HTTP layer was missing, so this wires
`branch` from the route through cloneOrPull and LaunchOptions into the
worker's AnalyzeOptions. StartMessage.options is already typed as
AnalyzeOptions, so the IPC protocol is unchanged.

Validation reuses validateBranchName — the same function backing the
CLI's `--branch` — so both entry points accept exactly the same refs and
a malformed value is rejected with 400 before it can reach git.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api): make branch part of job identity and complete ref validation

Addresses the review on #3199.

1. Job dedup ignored `branch`, so a request for branch B while branch A was
   in flight was answered with A's job and a 202. The caller would read that
   as "B is indexed" — the same silent wrong-branch outcome honoring `branch`
   was meant to remove. Branch is now part of the dedup identity; a
   different-branch request falls through to the single-slot guard and gets a
   truthful 409 instead.

2. validateBranchName implemented only a subset of git's ref rules, so
   `feature.lock`, `/feature`, `feature/`, `feature//next`, `@`, `@{` and
   dot-prefixed components passed validation and failed later in the git
   subprocess — a 202 plus a background failure rather than the advertised
   400. The remaining `git check-ref-format` rules are now enforced at the
   same chokepoint, which fixes the CLI and `.gitnexusrc` paths too. No
   branch git can create is affected.

3. The LaunchOptions doc claimed an explicit branch always pins
   `branches/<slug>/`. resolveBranchPlacement keeps the run on the flat slot
   when that slot has no owner, or when its owner is already this label.
   Comment and CHANGELOG corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(server): describe cloneOrPull's branch path in its contract comment

The header comment predated `options.branch` and still said an existing
clone is only ever `git pull --ff-only`. The implementation has a second
path: with a branch it fetches that ref and runs
`checkout -B <branch> origin/<branch>`, so the requested branch — not the
one already checked out — ends up in the working tree.

The stale comment is actively misleading: a reviewer reading it concludes
that requesting a branch on an existing clone silently analyzes the
default branch, which is not what happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(server): scope the origin check to the existing-clone path

My previous comment said remote.origin is verified "in both cases", which
is wrong: assertRemoteMatchesRequestedUrl runs inside `if (exists)`, so a
fresh clone has no origin to check. Restructured around whether targetDir
exists, which is what actually selects the behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(server): cover branch job identity in JobManager's own suite

The branch dedup tests were sitting in analyze-api.test.ts, but they
exercise JobManager directly, so they belong beside the existing
"returns existing job for same repoUrl when active" case in
analyze-job.test.ts. Moved, and extended to cover the callers that omit
branch entirely — the upload route, the embed manager and the existing
tests — which compare undefined === undefined and are unaffected.

Also pins that branch survives the whole clone -> analyze -> terminal
update sequence, since it is now part of dedup identity and must not
drift mid-flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api): give a pinned branch its own clone and settle the slot it wrote

Addresses the review on #3199.

Per-branch clone directories (review option 2). One checkout per repo made
`branch` one-shot: after any analyze the tree is dirty with generated
AGENTS.md / CLAUDE.md / .claude/, so a pinned request 202'd and then died on
cloneOrPull's porcelain refusal. Worse, a later request that OMITTED `branch`
pulled whatever branch the last pin left checked out and indexed it as the
default — silent wrong content, the same class as #3198 one request later.
A pinned run now clones into `<repo>__<branchSlug>`, so the two requests no
longer share a tree. The pinned clone registers under its directory name,
because both dirs share an origin and the inferred name would otherwise
collide; that name re-derives through getCloneDir, so DELETE still finds it.

Finalization gate. registerRepo always records the flat `.gitnexus`, but a
pinned run whose label differs from the flat slot's owner writes
`branches/<slug>/`. The gate probed the flat path regardless, so it never
settled, and the worker's normal exit 0 — sent ~500ms after `complete`, while
the job is deliberately still non-terminal — was classified as a crash and a
successful analysis was retried three times and failed. The gate now follows
the placement the worker reports (isPrimaryBranch, added to the IPC allowlist
under the rule that module already documents), and an exit after a terminal
IPC counts as winding down, not dying. Reported by the maintainer and
reproduced independently by @azizur100389.

analyzeCloneOptions extracted so the token/branch combination is asserted.
Inline, the branch-only case — a public URL with no token — was untested, and
dropping it there would silently reindex the default branch while every other
test stayed green.

CHANGELOG: the previous entry claimed the newly-400'd payloads "would have
failed at git", which is true of CLI --branch but wrong for HTTP, where they
succeeded on the default branch. Documented as an explicit behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): bound the branch clone-dir name to one path component

`validateBranchName` allows a 255-character ref and `branchSlug` appends a
dash plus 8 hash characters, so `<repo>__<slug>` reached 267 — past the
255-byte component limit on ext4/APFS/NTFS. The clone would then fail to
create its target directory, which the character-only regex could not catch.

Only the readable half is trimmed. The hash is a digest of the full ref and
is always kept, so two long branches sharing a prefix still resolve to
different directories rather than silently sharing an index. `branchSlug`
itself is untouched: the per-branch index slots already use those names on
disk, and shortening them there would orphan existing indexes.

Also corrects two comments: the forwarding test claimed a branch selector
always pins `branches/<slug>/` (it keeps the flat slot when that slot has no
owner or already owns the label), and the web client's `branch` doc said
omitting it means the remote default — true for a `url` request, but a `path`
request is never cloned and indexes whatever that tree has checked out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): bound the branch clone-dir name and unblock same-branch re-index

Two defects found by testing this branch end to end, plus the review nits.

1. Path length. `validateBranchName` allows a 255-character ref and
   `branchSlug` appends a dash plus 8 hash characters, so `<repo>__<slug>`
   reached 267 — past the 255-byte component limit on ext4/APFS/NTFS, and the
   clone could not create its directory. Only the readable half is trimmed;
   the hash is a digest of the full ref and is always kept, so two long
   branches sharing a prefix still get separate directories. `branchSlug`
   itself is untouched — the per-branch index slots already use those names on
   disk and shortening them there would orphan existing indexes.

2. Same-branch re-index. Per-branch clone dirs stopped branches from
   contaminating each other, but a REPEAT pin still failed: analyze writes
   AGENTS.md / CLAUDE.md / .claude/ into the clone, so the second pinned run
   met its own dirt at the porcelain check and asked for
   `overwrite_local_changes`. When HEAD already matches the requested branch
   there is nothing to switch, so the run now takes the same `pull --ff-only`
   path an unpinned request takes — review option (1), alongside (2). The
   refusal is untouched where it matters: a real switch, or a detached HEAD,
   still goes through the checkout path and can still refuse.

Also: repositions getCloneDir's JSDoc, which an inserted constant had
orphaned; gates the pinned `registryName` on the same condition as the clone,
so supplying both `url` and `path` no longer renames the operator's local
repo; corrects a test comment that claimed a branch selector always pins
`branches/<slug>/`; and corrects the web client's `branch` doc, which said
omitting it means the remote default — true for `url`, but a `path` request
is never cloned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: drop the CHANGELOG edits from this PR

Requested in review. Checking the history, no feat/fix PR here touches
gitnexus/CHANGELOG.md — the only recent commit on it is `chore: release
v1.6.11`, and CONTRIBUTING says release notes are generated from the merged
PR title via .github/release.yml. Hand-editing an [Unreleased] section from a
feature branch was my mistake, not the project's convention.

The behavior change it documented (branch: null / "" / non-string now 400
where they were previously dropped and the default branch indexed) is stated
in the PR description instead, which is what feeds the release notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(server): align the clone/pull contract with the same-branch fast path

Two comments I wrote went stale against my own later change.

`cloneOrPull`'s header still said an existing clone with `options.branch`
always fetches and runs `checkout -B`. Since the same-branch fast path landed
that is only true when the branch actually differs; when it is already checked
out the run takes `pull --ff-only` and no dirty-tree check applies. The header
now splits on whether the branch differs, which is what the code branches on.

The web client's `branch` doc said omitting it on a `url` request clones the
remote default. That holds only when there is no clone yet — an existing
unpinned clone is pulled on whatever branch it already has checked out.

Comments only; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address PR review feedback (#3199)

Pin a same-branch re-index to `git pull --ff-only origin <branch>` so the job cannot follow an unverified `branch.<name>.merge` while still skipping the dirty-tree refuse.

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

* fix(server): close the #3199 holes on pinned analyze re-index

Same-ref updates were a raw pull dest (force-fetch via +) or a shallow ff-merge that could not move, and a tag pin compared the tag-object SHA so re-index refused a dirty tree. Fetch the mapped remote-tracking ref, stay put on a peeled SHA match, restore only GitNexus overlays, skip the 60s settle on alreadyUpToDate, and keep branch validation in core so the HTTP route does not import the CLI.

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-08 15:15:45 +01:00

462 lines
20 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import fsSync from 'node:fs';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
loadAnalyzeConfig,
loadAnalyzeConfigStrict,
mergeAnalyzeOptions,
resolveDefaultBranch,
validateBranchName,
sanitizeDetectedBranch,
GitNexusRcError,
GITNEXUS_RC_FILENAME,
DEFAULT_BRANCH_FALLBACK,
} from '../../src/cli/analyze-config.js';
import type { AnalyzeOptions } from '../../src/cli/analyze.js';
import {
MAX_REPO_CONTROL_FILE_BYTES,
readRepoControlFile,
} from '../../src/config/repo-control-file.js';
describe('analyze-config (.gitnexusrc support, #243)', () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-'));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
const writeRc = (contents: string) =>
fs.writeFile(path.join(dir, GITNEXUS_RC_FILENAME), contents);
// ── loadAnalyzeConfig ──────────────────────────────────────────────
it('returns undefined when no .gitnexusrc exists (the normal case)', () => {
expect(loadAnalyzeConfig(dir)).toBeUndefined();
});
it('rejects an oversized repository config before parsing', async () => {
await writeRc(' '.repeat(MAX_REPO_CONTROL_FILE_BYTES + 1));
await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(/exceeds/);
});
it('keeps the read bounded if a control file grows after its size check', async () => {
await writeRc('{}');
const fstatSync = fsSync.fstatSync;
const stat = vi.spyOn(fsSync, 'fstatSync').mockImplementation((fd) => {
const opened = fstatSync(fd);
Object.defineProperty(opened, 'size', { value: MAX_REPO_CONTROL_FILE_BYTES + 1 });
return opened;
});
try {
await expect(readRepoControlFile(dir, GITNEXUS_RC_FILENAME)).rejects.toThrow(/exceeds/);
expect(stat).toHaveBeenCalledOnce();
} finally {
stat.mockRestore();
}
});
it('rejects a hardlinked repository config', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-hardlink-'));
try {
const target = path.join(outside, 'config.json');
await fs.writeFile(target, JSON.stringify({ workers: '8' }));
await fs.link(target, path.join(dir, GITNEXUS_RC_FILENAME));
await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(/hard link/);
} finally {
await fs.rm(outside, { recursive: true, force: true });
}
});
it.skipIf(process.platform === 'win32')(
'rejects a FIFO before opening it for reading',
async () => {
const fifo = path.join(dir, GITNEXUS_RC_FILENAME);
execFileSync('mkfifo', [fifo]);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await expect(
Promise.race([
readRepoControlFile(dir, GITNEXUS_RC_FILENAME),
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error('FIFO read did not fail promptly')), 500);
}),
]),
).rejects.toThrow(/regular file/);
} finally {
if (timeout !== undefined) clearTimeout(timeout);
}
},
);
it.skipIf(process.platform === 'win32')(
'rejects a final-file symlink for repository config',
async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-outside-'));
try {
const target = path.join(outside, 'config.json');
await fs.writeFile(target, JSON.stringify({ workers: '8' }));
await fs.symlink(target, path.join(dir, GITNEXUS_RC_FILENAME), 'file');
await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(/symbolic link/);
} finally {
await fs.rm(outside, { recursive: true, force: true });
}
},
);
it('throws an actionable error on invalid JSON, naming the file', async () => {
await writeRc('{ not valid json ');
expect(() => loadAnalyzeConfig(dir)).toThrow(GitNexusRcError);
try {
loadAnalyzeConfig(dir);
} catch (err) {
expect((err as Error).message).toContain(GITNEXUS_RC_FILENAME);
expect((err as Error).message).toMatch(/not valid JSON/i);
}
});
it('rejects a non-object top-level value', async () => {
await writeRc('["develop"]');
expect(() => loadAnalyzeConfig(dir)).toThrow(/must contain a JSON object/);
});
it('fails closed on an unknown key (typo protection)', async () => {
await writeRc(JSON.stringify({ defalutBranch: 'develop' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/Unknown key "defalutBranch"/);
});
it('accepts embeddingBaseUrl / embeddingModel but rejects embeddingDims (CLI/env-only)', async () => {
// URL + MODEL are read lazily at runtime, so they are valid config keys.
await writeRc(JSON.stringify({ embeddingBaseUrl: 'http://h/v1', embeddingModel: 'm' }));
expect(loadAnalyzeConfig(dir)).toEqual({
embeddingBaseUrl: 'http://h/v1',
embeddingModel: 'm',
});
// DIMS is read at module-load (before .gitnexusrc), so it is intentionally
// not a config key — a typo'd/intended value fails closed rather than
// silently sizing nothing.
await writeRc(JSON.stringify({ embeddingDims: 4096 }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/Unknown key "embeddingDims"/);
});
it('parses the flat form and maps aliases onto AnalyzeOptions', async () => {
await writeRc(
JSON.stringify({
defaultBranch: 'develop',
skipContextFiles: true,
skipSkills: true,
embeddings: true,
workerTimeout: 60,
}),
);
const cfg = loadAnalyzeConfig(dir);
expect(cfg).toEqual({
defaultBranch: 'develop',
skipAgentsMd: true, // skipContextFiles → skipAgentsMd
skipSkills: true,
embeddings: true,
workerTimeout: '60', // numeric → string (Commander contract)
});
});
it('normalizes the pdg opt-in (#2081) and rejects a non-boolean value', async () => {
await writeRc(JSON.stringify({ pdg: true }));
expect(loadAnalyzeConfig(dir)).toEqual({ pdg: true });
await writeRc(JSON.stringify({ pdg: false }));
expect(loadAnalyzeConfig(dir)).toEqual({ pdg: false });
await writeRc(JSON.stringify({ pdg: 'yes' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/);
});
it('parses the nested analyze form', async () => {
await writeRc(JSON.stringify({ analyze: { defaultBranch: 'master', skipSkills: true } }));
expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'master', skipSkills: true });
});
it('lets the nested analyze block override flat keys for the same option', async () => {
await writeRc(
JSON.stringify({
defaultBranch: 'flat-branch',
skipSkills: false,
analyze: { defaultBranch: 'nested-branch', skipSkills: true },
}),
);
expect(loadAnalyzeConfig(dir)).toEqual({
defaultBranch: 'nested-branch',
skipSkills: true,
});
});
it('supports the legacy "branch" alias and the issue-comment shape', async () => {
await writeRc(JSON.stringify({ branch: 'develop', skipAiContext: true, embeddings: false }));
expect(loadAnalyzeConfig(dir)).toEqual({
defaultBranch: 'develop',
skipAgentsMd: true, // skipAiContext → skipAgentsMd
embeddings: false,
});
});
it('maps noStats onto stats (negated)', async () => {
await writeRc(JSON.stringify({ noStats: true }));
expect(loadAnalyzeConfig(dir)).toEqual({ stats: false });
});
it('rejects two aliases that configure the same option at the same level', async () => {
await writeRc(JSON.stringify({ skipContextFiles: true, skipAgentsMd: false }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/both configure the same option/);
});
it('requires booleans to be booleans', async () => {
await writeRc(JSON.stringify({ skipSkills: 'yes' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/);
});
it('requires the nested analyze value to be an object', async () => {
await writeRc(JSON.stringify({ analyze: 'develop' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/"analyze" must be a JSON object/);
});
it('normalizes numeric embeddings cap to a string and validates it', async () => {
await writeRc(JSON.stringify({ embeddings: 1000 }));
expect(loadAnalyzeConfig(dir)).toEqual({ embeddings: '1000' });
await writeRc(JSON.stringify({ embeddings: -1 }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/non-negative integer/);
});
it('rejects an invalid branch string in config (control characters)', async () => {
// Build the JSON manually so the control char survives.
await writeRc('{"defaultBranch": "de\\u0007velop"}');
expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/);
});
// ── fetchWrappers (#1589/#1852 residual) ───────────────────────────
it('normalizes a fetchWrappers string array (de-duped)', async () => {
await writeRc(JSON.stringify({ fetchWrappers: ['doRequest', 'apiClient.get', 'doRequest'] }));
expect(loadAnalyzeConfig(dir)).toEqual({ fetchWrappers: ['doRequest', 'apiClient.get'] });
});
it('rejects a non-array fetchWrappers value', async () => {
await writeRc(JSON.stringify({ fetchWrappers: 'doRequest' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an array of strings/);
});
it('rejects a fetchWrappers entry with regex / non-identifier characters', async () => {
await writeRc(JSON.stringify({ fetchWrappers: ['do(.*)Request'] }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an identifier or member name/);
});
it('rejects an empty fetchWrappers array', async () => {
await writeRc(JSON.stringify({ fetchWrappers: [] }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/at least one string/);
});
// ── Spring Actuator runtime enrichment (#2418) ────────────────────
it('normalizes a Spring Actuator snapshot path', async () => {
await writeRc(JSON.stringify({ springActuator: ' fixtures/actuator snapshots ' }));
expect(loadAnalyzeConfig(dir)).toEqual({
springActuator: 'fixtures/actuator snapshots',
});
});
it('rejects empty, non-string, and hidden-character Actuator paths', async () => {
await writeRc(JSON.stringify({ springActuator: ' ' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must not be empty/);
await writeRc(JSON.stringify({ springActuator: 42 }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/file or directory path/);
await writeRc('{"springActuator":"actuator\\u0007"}');
expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/);
});
// ── validateBranchName ─────────────────────────────────────────────
it('validateBranchName trims and accepts normal branch names', () => {
expect(validateBranchName(' develop ', 'src')).toBe('develop');
expect(validateBranchName('feature/foo-bar', 'src')).toBe('feature/foo-bar');
expect(validateBranchName('release/1.2', 'src')).toBe('release/1.2');
});
it('validateBranchName rejects empty, whitespace, ref-special, leading dash, and "."', () => {
expect(() => validateBranchName(' ', 'src')).toThrow(/must not be empty/);
expect(() => validateBranchName('foo bar', 'src')).toThrow(/whitespace/);
expect(() => validateBranchName('foo~1', 'src')).toThrow(/not allowed in a git ref/);
expect(() => validateBranchName('foo:bar', 'src')).toThrow(/not allowed in a git ref/);
expect(() => validateBranchName('-foo', 'src')).toThrow(/must not start with "-"/);
expect(() => validateBranchName('foo..bar', 'src')).toThrow(/must not contain ".."/);
});
it('validateBranchName rejects the ref shapes git check-ref-format rejects', () => {
// These previously passed validation and failed later in the git subprocess,
// which over HTTP meant a 202 and a background failure instead of a 400.
expect(() => validateBranchName('feature.lock', 'src')).toThrow(/must not end with "\.lock"/);
expect(() => validateBranchName('refs/heads.lock/x', 'src')).toThrow(
/must not end with "\.lock"/,
);
expect(() => validateBranchName('/feature', 'src')).toThrow(/must not start or end with "\/"/);
expect(() => validateBranchName('feature/', 'src')).toThrow(/must not start or end with "\/"/);
expect(() => validateBranchName('feature//next', 'src')).toThrow(/consecutive slashes/);
expect(() => validateBranchName('@', 'src')).toThrow(/single character "@"/);
expect(() => validateBranchName('feature@{1}', 'src')).toThrow(/must not contain "@\{"/);
expect(() => validateBranchName('.hidden', 'src')).toThrow(/starting with "\."/);
expect(() => validateBranchName('feature/.hidden', 'src')).toThrow(/starting with "\."/);
expect(() => validateBranchName('feature.', 'src')).toThrow(/end with "\."/);
});
it('validateBranchName still accepts the real branch shapes those rules must not catch', () => {
// A dot, a slash and an @ are all legal in the middle of a ref — the new
// rules must reject only what git itself would.
expect(validateBranchName('release/1.2.3', 'src')).toBe('release/1.2.3');
expect(validateBranchName('feature/lockfile-bump', 'src')).toBe('feature/lockfile-bump');
expect(validateBranchName('user@host', 'src')).toBe('user@host');
expect(validateBranchName('v1.0', 'src')).toBe('v1.0');
expect(validateBranchName('a/b/c', 'src')).toBe('a/b/c');
});
it('validateBranchName rejects a newline / control character', () => {
expect(() => validateBranchName('main\nrm -rf', 'src')).toThrow(/control or hidden|whitespace/);
});
it('sanitizeDetectedBranch returns undefined for junk, the name otherwise', () => {
expect(sanitizeDetectedBranch('develop')).toBe('develop');
expect(sanitizeDetectedBranch('with space')).toBeUndefined();
expect(sanitizeDetectedBranch(null)).toBeUndefined();
expect(sanitizeDetectedBranch('')).toBeUndefined();
});
// ── resolveDefaultBranch ───────────────────────────────────────────
it('resolveDefaultBranch: CLI wins over config and detection', () => {
expect(
resolveDefaultBranch({ cliBranch: 'cli', configBranch: 'cfg', detectedBranch: 'det' }),
).toBe('cli');
});
it('resolveDefaultBranch: config wins over detection', () => {
expect(resolveDefaultBranch({ configBranch: 'develop', detectedBranch: 'main' })).toBe(
'develop',
);
});
it('resolveDefaultBranch: auto-detected branch used when no CLI/config', () => {
expect(resolveDefaultBranch({ detectedBranch: 'trunk' })).toBe('trunk');
});
it('resolveDefaultBranch: falls back to "main" with nothing available', () => {
expect(resolveDefaultBranch({})).toBe(DEFAULT_BRANCH_FALLBACK);
expect(resolveDefaultBranch({ detectedBranch: null })).toBe('main');
// An unusable detected branch is ignored, not surfaced as an error.
expect(resolveDefaultBranch({ detectedBranch: 'bad branch' })).toBe('main');
});
it('resolveDefaultBranch: invalid CLI branch throws (user error)', () => {
expect(() => resolveDefaultBranch({ cliBranch: 'bad branch' })).toThrow(GitNexusRcError);
expect(() => resolveDefaultBranch({ cliBranch: 'bad branch' })).toThrow(/--default-branch/);
});
// ── mergeAnalyzeOptions ────────────────────────────────────────────
it('mergeAnalyzeOptions: returns CLI unchanged when there is no config', () => {
const cli: AnalyzeOptions = { force: true };
expect(mergeAnalyzeOptions(cli, undefined)).toBe(cli);
});
it('mergeAnalyzeOptions: config fills options the CLI left unset', () => {
const merged = mergeAnalyzeOptions({}, { skipAgentsMd: true, workerTimeout: '60' });
expect(merged.skipAgentsMd).toBe(true);
expect(merged.workerTimeout).toBe('60');
});
it('mergeAnalyzeOptions: CLI value wins over config', () => {
const merged = mergeAnalyzeOptions({ workerTimeout: '5' }, { workerTimeout: '60' });
expect(merged.workerTimeout).toBe('5');
});
it('mergeAnalyzeOptions: an explicit CLI false overrides a config true', () => {
const merged = mergeAnalyzeOptions({ skipSkills: false }, { skipSkills: true });
expect(merged.skipSkills).toBe(false);
});
it('mergeAnalyzeOptions: config stats applies unless --no-stats was passed', () => {
// Commander default (stats:true) with config stats:false → config wins (off).
expect(mergeAnalyzeOptions({ stats: true }, { stats: false }).stats).toBe(false);
// Explicit --no-stats (stats:false) is never overridden back on by config.
expect(mergeAnalyzeOptions({ stats: false }, { stats: true }).stats).toBe(false);
// No config stats → CLI value preserved.
expect(mergeAnalyzeOptions({ stats: true }, { skipSkills: true }).stats).toBe(true);
});
it('mergeAnalyzeOptions: does NOT forward defaultBranch (resolver owns it) (#1996)', () => {
// Pins the deliberate exclusion: defaultBranch is resolved via
// resolveDefaultBranch (CLI > config > detect > main), not the generic merge.
const merged = mergeAnalyzeOptions({}, { defaultBranch: 'develop', skipSkills: true });
expect(merged.skipSkills).toBe(true);
expect(merged.defaultBranch).toBeUndefined();
});
// ── #1996 tri-review hardening ─────────────────────────────────────
it('validateBranchName rejects a backtick (breaks generated Markdown) (#1996)', () => {
expect(() => validateBranchName('main`evil', 'src')).toThrow(/backtick/);
expect(() => validateBranchName('a`b', 'src')).toThrow(GitNexusRcError);
// sanitizeDetectedBranch swallows it → falls back via the resolver chain.
expect(sanitizeDetectedBranch('main`evil')).toBeUndefined();
});
it('validateBranchName enforces the 255-char max (#1996)', () => {
expect(validateBranchName('a'.repeat(255), 'src')).toBe('a'.repeat(255));
expect(() => validateBranchName('a'.repeat(256), 'src')).toThrow(/too long/);
});
it('validateBranchName rejects HEAD (case-sensitive) and accepts head (#3199)', () => {
expect(() => validateBranchName('HEAD', 'src')).toThrow(GitNexusRcError);
expect(() => validateBranchName('HEAD', 'src')).toThrow(/must not be "HEAD"/);
expect(() => validateBranchName(' HEAD ', 'src')).toThrow(GitNexusRcError);
expect(validateBranchName('head', 'src')).toBe('head');
});
it('validateBranchName rejects a force-refspec "+" prefix (#3199)', () => {
expect(() => validateBranchName('+main', 'src')).toThrow(GitNexusRcError);
expect(() => validateBranchName('+main', 'src')).toThrow(/must not start with "\+"/);
expect(() => validateBranchName('+develop', 'src')).toThrow(GitNexusRcError);
expect(() => validateBranchName('+develop', 'src')).toThrow(/must not start with "\+"/);
});
it('rejects Markdown-significant characters in a config name, allows real names (#1996)', async () => {
await writeRc(JSON.stringify({ name: '**evil**' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/Markdown-significant/);
await writeRc(JSON.stringify({ name: 'repo`x' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/Markdown-significant/);
// Underscores, dots, dashes, slashes are legitimate in repo names.
await writeRc(JSON.stringify({ name: 'my_org/my-repo.v2' }));
expect(loadAnalyzeConfig(dir)).toEqual({ name: 'my_org/my-repo.v2' });
});
it('reports inherited keys (__proto__, constructor) as Unknown key, not a kind error (#1996)', async () => {
for (const key of ['__proto__', 'constructor', 'toString']) {
await writeRc(`{"${key}": true}`);
expect(() => loadAnalyzeConfig(dir), key).toThrow(/Unknown key/);
}
// And no prototype pollution leaked from the attempt.
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it('strips a leading UTF-8 BOM before parsing (#1996)', async () => {
const BOM = String.fromCharCode(0xfeff);
await writeRc(BOM + JSON.stringify({ defaultBranch: 'develop' }));
expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'develop' });
});
});