mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-14 23:22:54 +00:00
* 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>
1835 lines
75 KiB
TypeScript
1835 lines
75 KiB
TypeScript
import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest';
|
|
|
|
// The logger is a Proxy with no `set` trap, so vi.spyOn can't patch it.
|
|
// Mock the module and expose `warn` as a countable spy (other levels no-op).
|
|
const warnSpy = vi.fn();
|
|
vi.mock('../../src/core/logger.js', () => ({
|
|
logger: {
|
|
debug: () => {},
|
|
info: () => {},
|
|
warn: (...args: unknown[]) => warnSpy(...args),
|
|
error: () => {},
|
|
trace: () => {},
|
|
fatal: () => {},
|
|
},
|
|
}));
|
|
|
|
import {
|
|
analyzeCloneOptions,
|
|
extractRepoName,
|
|
extractWebRepoName,
|
|
getCloneDir,
|
|
validateGitUrl,
|
|
cloneOrPull,
|
|
buildCloneArgs,
|
|
buildBranchCloneArgs,
|
|
buildGitEnv,
|
|
normalizeGitUrlForCompare,
|
|
assertRemoteMatchesRequestedUrl,
|
|
isAzureDevOpsUrl,
|
|
warnIfInsecureAzureConfig,
|
|
runGitForTest,
|
|
} from '../../src/server/git-clone.js';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import fs from 'node:fs/promises';
|
|
import { spawn } from 'node:child_process';
|
|
import { EventEmitter } from 'node:events';
|
|
import { getRemoteOriginUrl } from '../../src/storage/git.js';
|
|
import { getGlobalDir } from '../../src/storage/repo-manager.js';
|
|
|
|
// CLONE_ROOT now derives from getGlobalDir() (GITNEXUS_HOME || ~/.gitnexus), so
|
|
// assertions must mirror that derivation rather than hardcoding ~/.gitnexus —
|
|
// otherwise an ambient GITNEXUS_HOME (e.g. a CI runner that sets it) makes the
|
|
// "direct child of the clone root" assertions fail. We call getGlobalDir()
|
|
// directly (the same function production CLONE_ROOT uses) so the test cannot
|
|
// drift from production if that derivation ever changes. Computed at module
|
|
// load, the same point CLONE_ROOT is frozen, so the two always agree.
|
|
const EXPECTED_CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos'));
|
|
|
|
async function mkControlledRoot(prefix: string): Promise<string> {
|
|
const base = path.join(process.cwd(), '.tmp-test');
|
|
await fs.mkdir(base, { recursive: true });
|
|
return fs.realpath(await fs.mkdtemp(path.join(base, prefix)));
|
|
}
|
|
|
|
function runGit(args: string[], cwd: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
proc.stdout.on('data', (chunk: Buffer) => {
|
|
stdout += chunk;
|
|
});
|
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
stderr += chunk;
|
|
});
|
|
proc.on('close', (code) => {
|
|
if (code === 0) resolve(stdout);
|
|
else reject(new Error(`git ${args.join(' ')} failed (${code}): ${stderr}`));
|
|
});
|
|
proc.on('error', reject);
|
|
});
|
|
}
|
|
|
|
describe('git-clone', () => {
|
|
describe('extractRepoName', () => {
|
|
it('extracts name from HTTPS URL', () => {
|
|
expect(extractRepoName('https://github.com/user/my-repo.git')).toBe('my-repo');
|
|
});
|
|
|
|
it('extracts name from HTTPS URL without .git suffix', () => {
|
|
expect(extractRepoName('https://github.com/user/my-repo')).toBe('my-repo');
|
|
});
|
|
|
|
it('extracts name from SSH URL', () => {
|
|
expect(extractRepoName('git@github.com:user/my-repo.git')).toBe('my-repo');
|
|
});
|
|
|
|
it('handles trailing slashes', () => {
|
|
expect(extractRepoName('https://github.com/user/my-repo/')).toBe('my-repo');
|
|
});
|
|
|
|
it('handles nested paths', () => {
|
|
expect(extractRepoName('https://gitlab.com/group/subgroup/repo.git')).toBe('repo');
|
|
});
|
|
|
|
it('rejects URLs whose last segment is "..": prevents getCloneDir traversal escape', () => {
|
|
// Without the safe-name pattern, a URL ending in `/..` would yield
|
|
// `getCloneDir('..')` = `~/.gitnexus/repos/..` = `~/.gitnexus/`, breaking
|
|
// out of the intended clone root.
|
|
expect(() => extractRepoName('https://github.com/owner/repo:..')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
expect(() => extractRepoName('https://example.com/foo:..')).toThrow('valid repository name');
|
|
});
|
|
|
|
it('rejects URLs that yield a single dot', () => {
|
|
expect(() => extractRepoName('https://example.com/foo:.')).toThrow('valid repository name');
|
|
});
|
|
|
|
it('rejects empty input', () => {
|
|
expect(() => extractRepoName('')).toThrow('valid repository name');
|
|
});
|
|
|
|
it('handles many trailing slashes without polynomial-time blowup', () => {
|
|
// Pathological input the previous /\\/+$/ regex was flagged for
|
|
// (CodeQL js/polynomial-redos). The string-loop replacement is O(n).
|
|
const url = 'https://example.com/repo' + '/'.repeat(10000);
|
|
const start = performance.now();
|
|
expect(extractRepoName(url)).toBe('repo');
|
|
const elapsedMs = performance.now() - start;
|
|
// Threshold of 500ms is intentionally loose to absorb slow CI runners
|
|
// while still catching a true polynomial regression (which would take
|
|
// multiple seconds on 10k slashes).
|
|
expect(elapsedMs).toBeLessThan(500);
|
|
});
|
|
|
|
it('rejects leading dashes to prevent argument injection', () => {
|
|
expect(() => extractRepoName('https://github.com/user/--upload-pack=payload.git')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
expect(() => extractRepoName('https://github.com/user/-repo')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
});
|
|
|
|
it('rejects unsafe directory characters instead of sanitizing them', () => {
|
|
expect(() => extractRepoName('https://github.com/user/repo<tag>.git')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
});
|
|
|
|
it('rejects shell metacharacters in URL segments', () => {
|
|
// The split on /[/:]/ does not split on backslashes or other shell chars,
|
|
// so a name like `repo;rm -rf /` must fail instead of being rewritten.
|
|
expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
});
|
|
|
|
it('rejects whitespace and backslashes', () => {
|
|
expect(() => extractRepoName('https://example.com/foo:repo name')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
expect(() => extractRepoName('https://example.com/foo:repo\\name')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getCloneDir', () => {
|
|
it('returns path under the clone root (getGlobalDir()/repos/)', () => {
|
|
const dir = getCloneDir('my-repo');
|
|
expect(dir).toBe(path.join(EXPECTED_CLONE_ROOT, 'my-repo'));
|
|
});
|
|
|
|
it('rejects ".." to prevent path-traversal escape from the clone root', () => {
|
|
expect(() => getCloneDir('..')).toThrow('Invalid repository name');
|
|
expect(() => getCloneDir('.')).toThrow('Invalid repository name');
|
|
expect(() => getCloneDir('')).toThrow('Invalid repository name');
|
|
});
|
|
|
|
it('rejects names containing path separators', () => {
|
|
expect(() => getCloneDir('foo/bar')).toThrow('Invalid repository name');
|
|
expect(() => getCloneDir('foo\\bar')).toThrow('Invalid repository name');
|
|
});
|
|
|
|
it('returned path is always a direct child of the clone root', () => {
|
|
const cloneRoot = EXPECTED_CLONE_ROOT;
|
|
const dir = getCloneDir('my-repo');
|
|
const rel = path.relative(cloneRoot, path.resolve(dir));
|
|
// path.relative from the parent to the child must be just the child name —
|
|
// no .. and no path separators inside.
|
|
expect(rel).toBe('my-repo');
|
|
});
|
|
});
|
|
|
|
describe('validateGitUrl', () => {
|
|
it('allows valid HTTPS GitHub URLs', () => {
|
|
expect(() => validateGitUrl('https://github.com/user/repo.git')).not.toThrow();
|
|
expect(() => validateGitUrl('https://github.com/user/repo')).not.toThrow();
|
|
});
|
|
|
|
it('allows valid HTTP URLs', () => {
|
|
expect(() => validateGitUrl('http://gitlab.com/user/repo.git')).not.toThrow();
|
|
});
|
|
|
|
it('rejects query strings and fragments instead of reinterpreting clone remotes', () => {
|
|
expect(() => validateGitUrl('https://github.com/user/repo.git?ref=main')).toThrow(
|
|
'must not include query strings or fragments',
|
|
);
|
|
expect(() => validateGitUrl('https://github.com/user/repo.git#main')).toThrow(
|
|
'must not include query strings or fragments',
|
|
);
|
|
});
|
|
|
|
it('blocks SSH protocol', () => {
|
|
expect(() => validateGitUrl('ssh://git@github.com/user/repo.git')).toThrow(
|
|
'Only https:// and http://',
|
|
);
|
|
});
|
|
|
|
it('blocks file:// protocol', () => {
|
|
expect(() => validateGitUrl('file:///etc/passwd')).toThrow('Only https:// and http://');
|
|
});
|
|
|
|
it('blocks IPv4 loopback', () => {
|
|
expect(() => validateGitUrl('http://127.0.0.1/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://127.255.0.1/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks IPv6 loopback ::1', () => {
|
|
// Node URL parser strips brackets: hostname is "::1" not "[::1]"
|
|
expect(() => validateGitUrl('http://[::1]/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks IPv4 private ranges (10.x, 172.16-31.x, 192.168.x)', () => {
|
|
expect(() => validateGitUrl('http://10.0.0.1/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://172.16.0.1/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://172.31.255.255/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://192.168.1.1/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks link-local addresses', () => {
|
|
expect(() => validateGitUrl('http://169.254.1.1/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks cloud metadata hostname', () => {
|
|
expect(() => validateGitUrl('http://metadata.google.internal/repo')).toThrow(
|
|
'private/internal',
|
|
);
|
|
expect(() => validateGitUrl('http://metadata.azure.com/repo')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks IPv6 ULA (fc/fd)', () => {
|
|
expect(() => validateGitUrl('http://[fc00::1]/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://[fd12::1]/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks IPv6 link-local (fe80)', () => {
|
|
expect(() => validateGitUrl('http://[fe80::1]/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks IPv4-mapped IPv6', () => {
|
|
expect(() => validateGitUrl('http://[::ffff:127.0.0.1]/repo.git')).toThrow(
|
|
'private/internal',
|
|
);
|
|
});
|
|
|
|
it('blocks IPv4-compatible IPv6 (RFC 4291 deprecated, ::w.x.y.z)', () => {
|
|
// Node's URL parser collapses ::127.0.0.1 to ::7f00:1 — no ::ffff: marker,
|
|
// but still routable to 127.0.0.1 on most stacks.
|
|
expect(() => validateGitUrl('http://[::127.0.0.1]/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://[::7f00:1]/repo.git')).toThrow('private/internal');
|
|
// 169.254.169.254 (cloud metadata) embedded as IPv4-compatible
|
|
expect(() => validateGitUrl('http://[::a9fe:a9fe]/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks IPv4-compatible IPv6 in expanded / zero-padded forms', () => {
|
|
// The compressed-form check above relies on the WHATWG URL parser
|
|
// normalising fully-expanded inputs to ::xxxx[:yyyy]. These cases pin
|
|
// that assumption: if a future Node release stops collapsing them, a
|
|
// bypass would silently re-open without these tests catching it.
|
|
expect(() => validateGitUrl('http://[0:0:0:0:0:0:7f00:1]/repo.git')).toThrow(
|
|
'private/internal',
|
|
);
|
|
expect(() =>
|
|
validateGitUrl('http://[0000:0000:0000:0000:0000:0000:7f00:0001]/repo.git'),
|
|
).toThrow('private/internal');
|
|
// Mixed notation: trailing IPv4 quad in an otherwise expanded address.
|
|
expect(() => validateGitUrl('http://[0:0:0:0:0:0:127.0.0.1]/repo.git')).toThrow(
|
|
'private/internal',
|
|
);
|
|
});
|
|
|
|
it('blocks NAT64 well-known prefix (64:ff9b::/96)', () => {
|
|
// 64:ff9b::7f00:1 → 127.0.0.1 via NAT64 translation
|
|
expect(() => validateGitUrl('http://[64:ff9b::7f00:1]/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://[64:ff9b::a9fe:a9fe]/repo.git')).toThrow(
|
|
'private/internal',
|
|
);
|
|
// RFC 8215 local NAT64 prefix
|
|
expect(() => validateGitUrl('http://[64:ff9b:1::1]/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks NAT64 with embedded RFC1918 addresses', () => {
|
|
// The startsWith('64:ff9b:') check covers any embedded IPv4. These
|
|
// explicit RFC1918 architectures document SSRF coverage for the full private
|
|
// IPv4 surface — not just loopback and cloud metadata.
|
|
expect(() => validateGitUrl('http://[64:ff9b::a00:1]/repo.git')).toThrow('private/internal'); // 10.0.0.1
|
|
expect(() => validateGitUrl('http://[64:ff9b::ac10:1]/repo.git')).toThrow('private/internal'); // 172.16.0.1
|
|
expect(() => validateGitUrl('http://[64:ff9b::c0a8:101]/repo.git')).toThrow(
|
|
'private/internal',
|
|
); // 192.168.1.1
|
|
});
|
|
|
|
it('blocks 6to4 prefix (2002::/16, RFC 3056)', () => {
|
|
// 6to4 encodes an IPv4 address in bits 17-48, so 2002:WWXX:YYZZ::*
|
|
// routes to W.X.Y.Z on 6to4-capable stacks. The protocol is deprecated
|
|
// (RFC 7526), so the entire 2002::/16 block is defensively rejected.
|
|
expect(() => validateGitUrl('http://[2002:7f00:1::1]/repo.git')).toThrow('private/internal'); // 127.0.0.1
|
|
expect(() => validateGitUrl('http://[2002:a9fe:a9fe::1]/repo.git')).toThrow(
|
|
'private/internal',
|
|
); // 169.254.169.254
|
|
expect(() => validateGitUrl('http://[2002:c0a8:101::1]/repo.git')).toThrow(
|
|
'private/internal',
|
|
); // 192.168.1.1
|
|
});
|
|
|
|
it('does not block valid public IPs (IPv4 and IPv6)', () => {
|
|
expect(() => validateGitUrl('https://140.82.121.4/repo.git')).not.toThrow();
|
|
// Regression guard against over-blocking legitimate public IPv6.
|
|
// Cloudflare DNS (2606:4700::/32) and Google DNS (2001:4860::/32) —
|
|
// chosen because their prefixes don't collide with any block above.
|
|
expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow();
|
|
expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow();
|
|
// A public address that merely contains a `ffff` hextet is not IPv4-mapped.
|
|
expect(() => validateGitUrl('https://[2001:4860:ffff::1]/repo.git')).not.toThrow();
|
|
});
|
|
|
|
it('blocks CGN range (100.64.0.0/10)', () => {
|
|
expect(() => validateGitUrl('http://100.64.0.1/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://100.127.255.255/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks benchmarking range (198.18.0.0/15)', () => {
|
|
expect(() => validateGitUrl('http://198.18.0.1/repo.git')).toThrow('private/internal');
|
|
expect(() => validateGitUrl('http://198.19.255.255/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks numeric decimal IP encoding', () => {
|
|
expect(() => validateGitUrl('http://2130706433/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks hex IP encoding', () => {
|
|
expect(() => validateGitUrl('http://0x7f000001/repo.git')).toThrow('private/internal');
|
|
});
|
|
|
|
it('blocks 0.0.0.0', () => {
|
|
expect(() => validateGitUrl('http://0.0.0.0/repo.git')).toThrow('private/internal');
|
|
});
|
|
});
|
|
|
|
describe('buildCloneArgs', () => {
|
|
// Closes the test-coverage gap that PR #1325 review (HIGH finding 1)
|
|
// identified for CodeQL js/second-order-command-line-injection alerts
|
|
// #166/#167. The barrier these tests guard is the `--` separator that
|
|
// prevents an option-like URL from being parsed by git as a flag.
|
|
it('places `--` before the URL', () => {
|
|
const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target');
|
|
const dashDashIdx = args.indexOf('--');
|
|
const urlIdx = args.indexOf('https://github.com/owner/repo.git');
|
|
expect(dashDashIdx).toBeGreaterThan(-1);
|
|
expect(urlIdx).toBeGreaterThan(dashDashIdx);
|
|
});
|
|
|
|
it('treats an option-like URL as a positional argument, not a flag', () => {
|
|
// The exact mitigation for second-order-command-line-injection: a URL
|
|
// beginning with `--` must appear after the `--` separator so git
|
|
// refuses to interpret it as `--upload-pack=evil`.
|
|
const args = buildCloneArgs('--upload-pack=evil', '/safe/target');
|
|
const dashDashIdx = args.indexOf('--');
|
|
const urlIdx = args.indexOf('--upload-pack=evil');
|
|
expect(dashDashIdx).toBeGreaterThan(-1);
|
|
expect(urlIdx).toBeGreaterThan(dashDashIdx);
|
|
// And targetDir comes after URL, also positional.
|
|
expect(args.indexOf('/safe/target')).toBeGreaterThan(urlIdx);
|
|
});
|
|
|
|
it('preserves --depth 1 for shallow clones', () => {
|
|
const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target');
|
|
const depthIdx = args.indexOf('--depth');
|
|
expect(depthIdx).toBeGreaterThan(-1);
|
|
expect(args[depthIdx + 1]).toBe('1');
|
|
// --depth must be before the `--` separator (it's an option, not a positional).
|
|
expect(depthIdx).toBeLessThan(args.indexOf('--'));
|
|
});
|
|
|
|
it('never embeds a token in argv: credentials travel through env, not URL', () => {
|
|
// buildCloneArgs is URL-only; the credential must travel through env
|
|
// (buildGitEnv) so it cannot appear in `ps auxww` or in command logs.
|
|
const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target');
|
|
// No credential material in argv — assert on the credential markers
|
|
// directly rather than substring-matching the host (which CodeQL flags
|
|
// as incomplete URL sanitization, js/incomplete-url-substring).
|
|
expect(args.some((a) => a.includes('ghp_'))).toBe(false);
|
|
expect(args.some((a) => a.toLowerCase().includes('authorization'))).toBe(false);
|
|
expect(args.some((a) => a.includes('extraHeader'))).toBe(false);
|
|
});
|
|
|
|
it('adds --branch before the URL separator for branch-specific clones', () => {
|
|
const args = buildBranchCloneArgs('git@github.com:owner/repo.git', '/safe/target', 'develop');
|
|
expect(args).toEqual([
|
|
'clone',
|
|
'--depth',
|
|
'1',
|
|
'--branch',
|
|
'develop',
|
|
'--',
|
|
'git@github.com:owner/repo.git',
|
|
'/safe/target',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('buildGitEnv — managed git environment', () => {
|
|
// The token MUST travel via GIT_CONFIG_* env vars (git ≥2.31), not via
|
|
// argv or URL. This keeps it out of `ps`, shell history, and stderr.
|
|
|
|
it('passes through base env and sets prompt-suppression env vars', () => {
|
|
const env = buildGitEnv({ FOO: 'bar' });
|
|
expect(env.FOO).toBe('bar');
|
|
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
|
expect(env.GIT_ASKPASS).toBeDefined();
|
|
});
|
|
|
|
it('scrubs inherited git trace vars that would log the Authorization header', () => {
|
|
const env = buildGitEnv({
|
|
GIT_TRACE: '1',
|
|
GIT_TRACE_CURL: '1',
|
|
GIT_TRACE_PACKET: '1',
|
|
GIT_CURL_VERBOSE: '1',
|
|
});
|
|
expect(env.GIT_TRACE).toBeUndefined();
|
|
expect(env.GIT_TRACE_CURL).toBeUndefined();
|
|
expect(env.GIT_TRACE_PACKET).toBeUndefined();
|
|
expect(env.GIT_CURL_VERBOSE).toBeUndefined();
|
|
});
|
|
|
|
it('disables repository hooks even when no token is provided', () => {
|
|
const env = buildGitEnv({});
|
|
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
|
expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath');
|
|
expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull);
|
|
});
|
|
|
|
it('only disables repository hooks when token is empty string', () => {
|
|
const env = buildGitEnv({}, { token: '' });
|
|
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
|
expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath');
|
|
expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull);
|
|
});
|
|
|
|
it('injects a host-scoped Basic-auth header when a github.com token is provided', () => {
|
|
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/owner/repo' });
|
|
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
|
// Host-scoped key: the header attaches only to this origin's requests.
|
|
expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/owner/repo.extraHeader');
|
|
const expected =
|
|
'Authorization: Basic ' + Buffer.from('x-access-token:ghp_secret123').toString('base64');
|
|
expect(env.GIT_CONFIG_VALUE_1).toBe(expected);
|
|
});
|
|
|
|
it('does not inject a token for a non-github host (defense-in-depth host bind)', () => {
|
|
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://gitlab.com/owner/repo' });
|
|
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
|
});
|
|
|
|
it('never includes the raw token value in any env entry', () => {
|
|
// Defence-in-depth: token must only appear inside the base64 of the
|
|
// Authorization header, never as a plain substring of any env var.
|
|
const token = 'ghp_uniqueRawSecret_98765';
|
|
const env = buildGitEnv({ EXISTING: 'value' }, { token, url: 'https://github.com/o/r' });
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (key === 'GIT_CONFIG_VALUE_1') continue;
|
|
expect(String(value)).not.toContain(token);
|
|
}
|
|
});
|
|
|
|
it('injects the server Azure PAT (host-scoped) for an Azure URL', () => {
|
|
const prev = process.env.AZURE_DEVOPS_PAT;
|
|
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
|
|
try {
|
|
const env = buildGitEnv({}, { url: 'https://dev.azure.com/org/proj/_git/repo' });
|
|
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
|
expect(env.GIT_CONFIG_KEY_1).toBe(
|
|
'http.https://dev.azure.com/org/proj/_git/repo.extraHeader',
|
|
);
|
|
const expected = 'Authorization: Basic ' + Buffer.from(':azure-pat-xyz').toString('base64');
|
|
expect(env.GIT_CONFIG_VALUE_1).toBe(expected);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT;
|
|
else process.env.AZURE_DEVOPS_PAT = prev;
|
|
}
|
|
});
|
|
|
|
it('emits EXACTLY ONE header when a github token and an Azure PAT could both apply', () => {
|
|
// A token only injects for github.com (where isAzureDevOpsUrl is false),
|
|
// so the two never collide — guard the resolver directly anyway.
|
|
const prev = process.env.AZURE_DEVOPS_PAT;
|
|
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
|
|
try {
|
|
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/o/r' });
|
|
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
|
expect(env.GIT_CONFIG_VALUE_2).toBeUndefined();
|
|
for (const value of Object.values(env)) {
|
|
expect(String(value)).not.toContain('azure-pat-xyz');
|
|
}
|
|
} finally {
|
|
if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT;
|
|
else process.env.AZURE_DEVOPS_PAT = prev;
|
|
}
|
|
});
|
|
|
|
it('appends after an existing GIT_CONFIG_COUNT instead of overwriting it', () => {
|
|
const env = buildGitEnv(
|
|
{ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.sslVerify', GIT_CONFIG_VALUE_0: 'true' },
|
|
{ token: 'ghp_secret123', url: 'https://github.com/o/r' },
|
|
);
|
|
expect(env.GIT_CONFIG_COUNT).toBe('3');
|
|
// Operator's pre-existing config is preserved at index 0.
|
|
expect(env.GIT_CONFIG_KEY_0).toBe('http.sslVerify');
|
|
expect(env.GIT_CONFIG_VALUE_0).toBe('true');
|
|
expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath');
|
|
expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull);
|
|
expect(env.GIT_CONFIG_KEY_2).toBe('http.https://github.com/o/r.extraHeader');
|
|
expect(env.GIT_CONFIG_VALUE_2).toContain('Authorization: Basic ');
|
|
});
|
|
|
|
it('overrides an inherited hooks path with the managed safe value', () => {
|
|
const env = buildGitEnv({
|
|
GIT_CONFIG_COUNT: '1',
|
|
GIT_CONFIG_KEY_0: 'core.hooksPath',
|
|
GIT_CONFIG_VALUE_0: '/tmp/untrusted-hooks',
|
|
});
|
|
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
|
expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath');
|
|
expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull);
|
|
});
|
|
|
|
it('does not execute hooks from an existing repository', async () => {
|
|
if (process.platform === 'win32') return;
|
|
const root = await mkControlledRoot('gitnexus-managed-git-');
|
|
const marker = path.join(root, 'hook-ran');
|
|
try {
|
|
await runGit(['init', '--initial-branch=main'], root);
|
|
await runGit(['config', 'user.email', 'test@example.com'], root);
|
|
await runGit(['config', 'user.name', 'GitNexus Test'], root);
|
|
await fs.writeFile(path.join(root, 'README.md'), 'test\n');
|
|
await runGit(['add', 'README.md'], root);
|
|
await runGit(['commit', '-m', 'initial'], root);
|
|
const hook = path.join(root, '.git', 'hooks', 'post-checkout');
|
|
await fs.writeFile(hook, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`);
|
|
await fs.chmod(hook, 0o700);
|
|
|
|
await runGitForTest(['checkout', '-b', 'next'], root);
|
|
|
|
await expect(fs.access(marker)).rejects.toThrow();
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('strips control characters from the config key (no key injection)', () => {
|
|
const env = buildGitEnv(
|
|
{},
|
|
{ token: 'ghp_secret123', url: 'https://github.com/o/r%0Anewline' },
|
|
);
|
|
const key = env.GIT_CONFIG_KEY_1 ?? '';
|
|
expect(key).not.toContain('\n');
|
|
expect(key).not.toContain('\r');
|
|
});
|
|
});
|
|
|
|
describe('cloneOrPull — containment barrier', () => {
|
|
// Closes the test-coverage gap that PR #1325 review (HIGH finding 1)
|
|
// identified for CodeQL js/path-injection alerts #176/#177/#178. The
|
|
// barrier these tests guard is the path.relative containment check at
|
|
// the entry of cloneOrPull, which must reject any targetDir not strictly
|
|
// inside CLONE_ROOT before any filesystem or subprocess sink.
|
|
//
|
|
// These tests do NOT mock spawn — the barrier throws synchronously
|
|
// before git is invoked, so the rejection is observable directly.
|
|
const cloneRoot = EXPECTED_CLONE_ROOT;
|
|
|
|
it('rejects an absolute target outside CLONE_ROOT', async () => {
|
|
await expect(cloneOrPull('https://github.com/a/b.git', '/etc/passwd')).rejects.toThrow(
|
|
'Clone target must be a subdirectory',
|
|
);
|
|
});
|
|
|
|
it('rejects CLONE_ROOT itself (the rel === "" branch)', async () => {
|
|
await expect(cloneOrPull('https://github.com/a/b.git', cloneRoot)).rejects.toThrow(
|
|
'Clone target must be a subdirectory',
|
|
);
|
|
});
|
|
|
|
it('rejects a parent-directory traversal attempt', async () => {
|
|
await expect(
|
|
cloneOrPull('https://github.com/a/b.git', path.join(cloneRoot, '..', 'escape')),
|
|
).rejects.toThrow('Clone target must be a subdirectory');
|
|
});
|
|
|
|
it('rejects a sibling directory with a common prefix (CLONE_ROOT-evil)', async () => {
|
|
// Classic startsWith(root + sep) pitfall: '/x/repos' does not catch
|
|
// '/x/repos-evil/...'. The path.relative idiom does, and the test
|
|
// documents that property at the cloneOrPull boundary.
|
|
await expect(cloneOrPull('https://github.com/a/b.git', cloneRoot + '-evil')).rejects.toThrow(
|
|
'Clone target must be a subdirectory',
|
|
);
|
|
});
|
|
|
|
// Closes the SSRF-bypass vector that Codex's adversarial review on
|
|
// PR #1325 surfaced: validateGitUrl was only called in the clone
|
|
// branch. An attacker URL that shared a basename with an existing
|
|
// clone would skip the SSRF check entirely on the pull path.
|
|
//
|
|
// The barrier-pass-but-validateGitUrl-throw case here works because
|
|
// cloneOrPull validates the URL after the containment check and before
|
|
// the existence probe, so the rejection fires regardless of whether
|
|
// the target dir exists on disk.
|
|
it('rejects URLs that fail validateGitUrl even when the target shape is valid', async () => {
|
|
const fakeTarget = path.join(cloneRoot, 'name-that-does-not-exist');
|
|
await expect(cloneOrPull('http://127.0.0.1/repo.git', fakeTarget)).rejects.toThrow(
|
|
'private/internal',
|
|
);
|
|
await expect(cloneOrPull('http://localhost/repo.git', fakeTarget)).rejects.toThrow(
|
|
'private/internal',
|
|
);
|
|
await expect(cloneOrPull('file:///etc/passwd', fakeTarget)).rejects.toThrow(
|
|
'Only https:// and http://',
|
|
);
|
|
});
|
|
|
|
it('keeps regular cloneOrPull restricted to http and https URLs', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
try {
|
|
await expect(
|
|
cloneOrPull('git@github.com:owner/repo.git', path.join(root, 'repo'), undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('Invalid URL');
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('creates missing nested parents before checking controlled clone containment', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const target = path.join(root, 'github.com', 'owner', 'repo');
|
|
const runGitForTest = vi.fn(async () => {
|
|
await fs.mkdir(path.join(target, '.git'), { recursive: true });
|
|
return '';
|
|
});
|
|
try {
|
|
await expect(
|
|
cloneOrPull('git@github.com:owner/repo', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
allowAutoSyncSsh: true,
|
|
runGitForTest,
|
|
}),
|
|
).resolves.toBe(target);
|
|
|
|
expect(runGitForTest).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// `assertRemoteMatchesRequestedUrl` runs REAL git (it is not injectable), so
|
|
// these fixtures are real repositories with a matching origin; only the
|
|
// branch logic under test is driven through `runGitForTest`.
|
|
const REMOTE = 'https://github.com/owner/repo.git';
|
|
const makeExistingClone = async (root: string, branch: string) => {
|
|
const target = path.join(root, 'repo');
|
|
await fs.mkdir(target, { recursive: true });
|
|
await runGit(['init', `--initial-branch=${branch}`], target);
|
|
await runGit(['remote', 'add', 'origin', REMOTE], target);
|
|
return target;
|
|
};
|
|
|
|
it('re-indexing the SAME pinned branch fetches via a safe refspec instead of refusing a dirty tree', async () => {
|
|
// Analyze writes AGENTS.md / CLAUDE.md / .claude/ into the clone, so the
|
|
// tree is dirty from its own first run. Routing a same-branch request
|
|
// through the checkout path made every pinned RE-index fail asking for
|
|
// `overwrite_local_changes` — a flag the route will not pass because it
|
|
// would `git clean --force -d` the directory (#3199 review).
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
try {
|
|
const target = await makeExistingClone(root, 'develop');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse') return 'develop\n';
|
|
if (args[0] === 'status') return ' M AGENTS.md\n?? .claude/\n'; // dirty, as analyze leaves it
|
|
return '';
|
|
});
|
|
await cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
});
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
const verbs = calls.map((c) => c[0]);
|
|
expect(calls).toContainEqual([
|
|
'fetch',
|
|
'--depth',
|
|
'1',
|
|
'origin',
|
|
'+refs/heads/develop:refs/remotes/origin/develop',
|
|
]);
|
|
expect(calls).toContainEqual(['checkout', '-B', 'develop', 'origin/develop']);
|
|
// Never a raw `origin <branch>` pull — `+develop` would be a force-fetch.
|
|
expect(
|
|
calls.some((c) => c[0] === 'pull' && c.includes('origin') && c.includes('develop')),
|
|
).toBe(false);
|
|
expect(verbs).not.toContain('status'); // so the dirty check never ran
|
|
expect(calls.some((c) => c[0] === 'merge')).toBe(false);
|
|
// Must not fall back to a bare `git pull --ff-only` — that follows
|
|
// `branch.<name>.merge`, which is not verified (only origin.url is).
|
|
expect(calls.some((c) => c[0] === 'pull' && c.length === 2)).toBe(false);
|
|
});
|
|
|
|
it('same-branch argv uses +refs/heads/develop:refs/remotes/origin/develop, never raw develop as a pull dest', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
try {
|
|
const target = await makeExistingClone(root, 'develop');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'develop\n';
|
|
return '';
|
|
});
|
|
await cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
});
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
const fetchCall = calls.find((c) => c[0] === 'fetch');
|
|
expect(fetchCall).toEqual([
|
|
'fetch',
|
|
'--depth',
|
|
'1',
|
|
'origin',
|
|
'+refs/heads/develop:refs/remotes/origin/develop',
|
|
]);
|
|
expect(fetchCall?.[4]).not.toBe('develop');
|
|
expect(calls.some((c) => c[0] === 'pull' && c[3] === 'develop')).toBe(false);
|
|
});
|
|
|
|
it('does not treat a leading-plus branch as a force-fetch pull refspec', async () => {
|
|
// If `+develop` somehow reached cloneOrPull, the heads/ mapping keeps
|
|
// the `+` inside the ref name. It is not git's force-fetch prefix.
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
try {
|
|
const target = await makeExistingClone(root, 'main');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return '+develop\n';
|
|
return '';
|
|
});
|
|
await cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: '+develop',
|
|
runGitForTest,
|
|
});
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toContainEqual([
|
|
'fetch',
|
|
'--depth',
|
|
'1',
|
|
'origin',
|
|
'+refs/heads/+develop:refs/remotes/origin/+develop',
|
|
]);
|
|
expect(
|
|
calls.some((c) => c[0] === 'pull' && c.some((a) => a === '+develop' || a.startsWith('+'))),
|
|
).toBe(false);
|
|
// Force prefix is on the mapping, not a force-update of `develop`.
|
|
expect(
|
|
calls.some(
|
|
(c) => c[0] === 'fetch' && c.includes('+refs/heads/develop:refs/remotes/origin/develop'),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('restores a dirty AGENTS.md on the same branch without taking the switch refuse path', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
try {
|
|
const target = await makeExistingClone(root, 'develop');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'develop\n';
|
|
if (args[0] === 'ls-files' && args.includes('./AGENTS.md')) return 'AGENTS.md\n';
|
|
if (args[0] === 'status') return ' M AGENTS.md\n?? .claude/\n';
|
|
return '';
|
|
});
|
|
await expect(
|
|
cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
}),
|
|
).resolves.toBe(target);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toContainEqual(['ls-files', '--', './AGENTS.md']);
|
|
expect(calls).toContainEqual(['checkout', 'HEAD', '--', './AGENTS.md']);
|
|
expect(calls).toContainEqual([
|
|
'clean',
|
|
'-fdx',
|
|
'--',
|
|
'./AGENTS.md',
|
|
'./CLAUDE.md',
|
|
'./.claude',
|
|
]);
|
|
expect(calls.some((c) => c[0] === 'status')).toBe(false);
|
|
expect(calls).toContainEqual(['checkout', '-B', 'develop', 'origin/develop']);
|
|
expect(calls.some((c) => c[0] === 'clean' && c.includes('/.gitnexus'))).toBe(false);
|
|
});
|
|
|
|
it('removes untracked AGENTS.md on the same branch so merge is not blocked', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
try {
|
|
const target = await makeExistingClone(root, 'develop');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'develop\n';
|
|
if (args[0] === 'ls-files') return ''; // untracked analyze output
|
|
return '';
|
|
});
|
|
await cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
});
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toContainEqual([
|
|
'clean',
|
|
'-fdx',
|
|
'--',
|
|
'./AGENTS.md',
|
|
'./CLAUDE.md',
|
|
'./.claude',
|
|
]);
|
|
expect(calls.some((c) => c[0] === 'checkout' && c.includes('HEAD'))).toBe(false);
|
|
expect(calls).toContainEqual(['checkout', '-B', 'develop', 'origin/develop']);
|
|
});
|
|
|
|
it('still switches — and still refuses a dirty tree — for a DIFFERENT branch', async () => {
|
|
// The refusal must survive where it matters: a real switch can discard
|
|
// local work, so the fast path above must not weaken it.
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
try {
|
|
const target = await makeExistingClone(root, 'main');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
if (args[0] === 'rev-parse') return 'main\n'; // on a different branch
|
|
if (args[0] === 'status') return ' M src/index.ts\n';
|
|
return '';
|
|
});
|
|
await expect(
|
|
cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
}),
|
|
).rejects.toThrow(/local changes detected/);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('treats a detached HEAD as needing the checkout path', async () => {
|
|
// `rev-parse --abbrev-ref HEAD` reports `HEAD` when detached. A SHA that
|
|
// does not match the requested ref is a real switch, not "already there".
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
try {
|
|
const target = await makeExistingClone(root, 'main');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'HEAD\n';
|
|
if (args[0] === 'rev-parse' && args[1] === 'HEAD')
|
|
return 'aaa111aaa111aaa111aaa111aaa111aaa111aaa1\n';
|
|
if (args[0] === 'rev-parse') return 'bbb222bbb222bbb222bbb222bbb222bbb222bbb2\n';
|
|
if (args[0] === 'status') return ''; // clean, so the checkout proceeds
|
|
return '';
|
|
});
|
|
await cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
});
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
expect(calls.map((c) => c[0])).toContain('checkout');
|
|
expect(calls.some((c) => c[0] === 'checkout' && c.includes('-B'))).toBe(true);
|
|
});
|
|
|
|
it('does not switch or fetch when a detached HEAD SHA matches the requested ref', async () => {
|
|
// Tag / SHA pin: already at the commit. Fetching refs/heads/<name> would
|
|
// follow a same-named branch past the pin (#3199 review).
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
const sha = 'aaa111aaa111aaa111aaa111aaa111aaa111aaa1';
|
|
try {
|
|
const target = await makeExistingClone(root, 'main');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'HEAD\n';
|
|
if (args[0] === 'rev-parse') return `${sha}\n`;
|
|
if (args[0] === 'status') return ' M AGENTS.md\n';
|
|
if (args[0] === 'ls-files' && args.includes('./AGENTS.md')) return 'AGENTS.md\n';
|
|
return '';
|
|
});
|
|
await cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
});
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls.some((c) => c[0] === 'status')).toBe(false);
|
|
expect(calls.some((c) => c[0] === 'checkout' && c.includes('-B'))).toBe(false);
|
|
expect(calls).toContainEqual(['checkout', 'HEAD', '--', './AGENTS.md']);
|
|
expect(calls.map((c) => c[0])).not.toContain('fetch');
|
|
expect(calls.map((c) => c[0])).not.toContain('merge');
|
|
});
|
|
|
|
it('peels an annotated tag so a tag pin is not treated as a switch', async () => {
|
|
// `rev-parse v1.0` is the tag object; HEAD is the peeled commit. Without
|
|
// `^{commit}` the SHA match misses and re-index porcelain-refuses.
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
const commit = 'aaa111aaa111aaa111aaa111aaa111aaa111aaa1';
|
|
const tagObject = 'cccccccccccccccccccccccccccccccccccccccc';
|
|
try {
|
|
const target = await makeExistingClone(root, 'main');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'HEAD\n';
|
|
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return `${commit}\n`;
|
|
if (args[0] === 'rev-parse' && args[1] === 'v1.0^{commit}') return `${commit}\n`;
|
|
if (args[0] === 'rev-parse' && args[1] === 'v1.0') return `${tagObject}\n`;
|
|
if (args[0] === 'rev-parse') throw new Error('unknown ref');
|
|
if (args[0] === 'status') return ' M AGENTS.md\n';
|
|
return '';
|
|
});
|
|
await cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'v1.0',
|
|
runGitForTest,
|
|
});
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toContainEqual(['rev-parse', 'v1.0^{commit}']);
|
|
expect(calls.some((c) => c[0] === 'status')).toBe(false);
|
|
expect(calls.some((c) => c[0] === 'checkout' && c.includes('-B'))).toBe(false);
|
|
expect(calls.map((c) => c[0])).not.toContain('fetch');
|
|
});
|
|
|
|
it('still refuses a dirty tree when a detached HEAD SHA does not match the requested ref', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const calls: string[][] = [];
|
|
try {
|
|
const target = await makeExistingClone(root, 'main');
|
|
const runGitForTest = vi.fn(async (args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'HEAD\n';
|
|
if (args[0] === 'rev-parse' && args[1] === 'HEAD')
|
|
return 'aaa111aaa111aaa111aaa111aaa111aaa111aaa1\n';
|
|
if (args[0] === 'rev-parse') return 'bbb222bbb222bbb222bbb222bbb222bbb222bbb2\n';
|
|
if (args[0] === 'status') return ' M src/index.ts\n';
|
|
return '';
|
|
});
|
|
await expect(
|
|
cloneOrPull(REMOTE, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
}),
|
|
).rejects.toThrow(/local changes detected/);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls.some((c) => c[0] === 'status')).toBe(true);
|
|
expect(calls.some((c) => c[0] === 'checkout')).toBe(false);
|
|
});
|
|
|
|
it('allows auto-sync SSH SCP clone URLs with a per-repo timeout', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const target = path.join(root, 'repo');
|
|
const runGitForTest = vi.fn(async () => {
|
|
await fs.mkdir(target);
|
|
return '';
|
|
});
|
|
try {
|
|
await expect(
|
|
cloneOrPull('git@gitlab.com:group/subgroup/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
allowAutoSyncSsh: true,
|
|
timeoutMs: 10_000,
|
|
branch: 'develop',
|
|
runGitForTest,
|
|
}),
|
|
).resolves.toBe(target);
|
|
|
|
expect(runGitForTest).toHaveBeenCalledWith(
|
|
[
|
|
'clone',
|
|
'--depth',
|
|
'1',
|
|
'--branch',
|
|
'develop',
|
|
'--',
|
|
'git@gitlab.com:group/subgroup/repo.git',
|
|
target,
|
|
],
|
|
undefined,
|
|
{ token: undefined, url: 'git@gitlab.com:group/subgroup/repo.git', timeoutMs: 10_000 },
|
|
);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('allows an explicitly controlled auto-sync clone root outside the default root', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
try {
|
|
const target = path.join(root, 'repo');
|
|
await expect(
|
|
cloneOrPull('http://127.0.0.1/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('private/internal');
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects controlled-root target names that do not match the remote repo name', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
try {
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'other'), undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('basename must match');
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects symlink children before clone or pull', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const outside = await mkControlledRoot('gitnexus-outside-');
|
|
try {
|
|
await fs.symlink(outside, path.join(root, 'repo'));
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('symlink');
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
await fs.rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects writable existing directories below a controlled clone root', async () => {
|
|
if (process.platform === 'win32') return;
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const namespace = path.join(root, 'team');
|
|
try {
|
|
await fs.mkdir(namespace);
|
|
await fs.chmod(namespace, 0o777);
|
|
await expect(
|
|
cloneOrPull(
|
|
'https://example.com/team/repo.git',
|
|
path.join(namespace, 'repo'),
|
|
undefined,
|
|
{
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
},
|
|
),
|
|
).rejects.toThrow('world-writable');
|
|
} finally {
|
|
await fs.chmod(namespace, 0o700).catch(() => {});
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects writable .git metadata in an existing controlled clone', async () => {
|
|
if (process.platform === 'win32') return;
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const target = path.join(root, 'repo');
|
|
const gitDir = path.join(target, '.git');
|
|
try {
|
|
await fs.mkdir(gitDir, { recursive: true });
|
|
await fs.chmod(gitDir, 0o777);
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('world-writable');
|
|
} finally {
|
|
await fs.chmod(gitDir, 0o700).catch(() => {});
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects symlinked .git metadata in an existing controlled clone', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const outside = await mkControlledRoot('gitnexus-outside-git-dir-');
|
|
const target = path.join(root, 'repo');
|
|
try {
|
|
await fs.mkdir(target);
|
|
await fs.symlink(outside, path.join(target, '.git'));
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('symlink');
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
await fs.rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects existing clones whose remote origin mismatches the requested URL', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const target = path.join(root, 'repo');
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const proc = spawn('git', ['init'], { cwd: root, stdio: 'ignore' });
|
|
proc.on('close', (code) =>
|
|
code === 0 ? resolve() : reject(new Error(`git init ${code}`)),
|
|
);
|
|
proc.on('error', reject);
|
|
});
|
|
await fs.rename(path.join(root, '.git'), path.join(target, '.git')).catch(async () => {
|
|
await fs.mkdir(target);
|
|
await fs.rename(path.join(root, '.git'), path.join(target, '.git'));
|
|
});
|
|
await fs.writeFile(
|
|
path.join(target, '.git', 'config'),
|
|
[
|
|
'[remote "origin"]',
|
|
'\turl = https://example.com/other/repo.git',
|
|
'\tfetch = +refs/heads/*:refs/remotes/origin/*',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('not the requested URL');
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('switches a shallow single-branch clone to a fallback branch', async () => {
|
|
const root = await mkControlledRoot('gitnexus-shallow-fallback-');
|
|
const source = path.join(root, 'source');
|
|
const remote = path.join(root, 'remote.git');
|
|
const target = path.join(root, 'repo');
|
|
const remoteUrl = 'git@github.com:team/repo.git';
|
|
const gitConfig = path.join(root, 'gitconfig');
|
|
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
|
|
const previousNoSystemConfig = process.env.GIT_CONFIG_NOSYSTEM;
|
|
|
|
try {
|
|
await runGit(['init', '--bare', remote], root);
|
|
await runGit(['init', '--initial-branch=master', source], root);
|
|
await runGit(['config', 'user.email', 'test@example.com'], source);
|
|
await runGit(['config', 'user.name', 'GitNexus Test'], source);
|
|
await fs.writeFile(path.join(source, 'branch.txt'), 'master\n');
|
|
await runGit(['add', 'branch.txt'], source);
|
|
await runGit(['commit', '-m', 'master'], source);
|
|
await runGit(['checkout', '-b', 'main'], source);
|
|
await fs.writeFile(path.join(source, 'branch.txt'), 'main\n');
|
|
await runGit(['commit', '-am', 'main'], source);
|
|
await runGit(['remote', 'add', 'origin', `file://${remote}`], source);
|
|
await runGit(['push', 'origin', 'master', 'main'], source);
|
|
|
|
await fs.writeFile(
|
|
gitConfig,
|
|
`[protocol "file"]\n\tallow = always\n[url "file://${remote}"]\n\tinsteadOf = ${remoteUrl}\n`,
|
|
);
|
|
process.env.GIT_CONFIG_GLOBAL = gitConfig;
|
|
process.env.GIT_CONFIG_NOSYSTEM = '1';
|
|
|
|
await runGit(['clone', '--depth', '1', '--branch', 'master', remoteUrl, target], root);
|
|
await expect(
|
|
runGit(['show-ref', '--verify', '--quiet', 'refs/remotes/origin/main'], target),
|
|
).rejects.toThrow();
|
|
await expect(runGit(['rev-parse', '--is-shallow-repository'], target)).resolves.toBe(
|
|
'true\n',
|
|
);
|
|
|
|
await fs.writeFile(path.join(target, 'branch.txt'), 'local changes\n');
|
|
await expect(
|
|
cloneOrPull(remoteUrl, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
allowAutoSyncSsh: true,
|
|
branch: 'main',
|
|
}),
|
|
).rejects.toThrow();
|
|
await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe(
|
|
'local changes\n',
|
|
);
|
|
|
|
await cloneOrPull(remoteUrl, target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
allowAutoSyncSsh: true,
|
|
branch: 'main',
|
|
overwriteLocalChanges: true,
|
|
});
|
|
|
|
await expect(runGit(['branch', '--show-current'], target)).resolves.toBe('main\n');
|
|
await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe('main\n');
|
|
await expect(runGit(['rev-parse', 'main'], target)).resolves.toBe(
|
|
await runGit(['rev-parse', 'origin/main'], target),
|
|
);
|
|
} finally {
|
|
if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL;
|
|
else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
|
|
if (previousNoSystemConfig === undefined) delete process.env.GIT_CONFIG_NOSYSTEM;
|
|
else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystemConfig;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('clones into a pre-existing empty target directory', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const target = path.join(root, 'repo');
|
|
const runGitForTest = vi.fn(async () => '');
|
|
try {
|
|
await fs.mkdir(target);
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
runGitForTest,
|
|
}),
|
|
).resolves.toBe(target);
|
|
expect(runGitForTest).toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('quarantines partial auto-sync clone output on clone failure', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const quarantineRoot = path.join(root, 'quarantine');
|
|
const target = path.join(root, 'repo');
|
|
try {
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
quarantineRoot,
|
|
runGitForTest: async () => {
|
|
await fs.mkdir(target);
|
|
await fs.writeFile(path.join(target, 'partial.txt'), 'partial', 'utf-8');
|
|
throw new Error('git clone failed (exit code 128)');
|
|
},
|
|
}),
|
|
).rejects.toThrow('git clone failed');
|
|
|
|
const entries = await fs.readdir(quarantineRoot);
|
|
expect(
|
|
entries.some((entry) => entry.startsWith('auto-sync-') && entry.endsWith('-repo')),
|
|
).toBe(true);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not quarantine an existing non-git directory on clone failure', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
const quarantineRoot = path.join(root, 'quarantine');
|
|
const target = path.join(root, 'repo');
|
|
try {
|
|
await fs.mkdir(target);
|
|
await fs.writeFile(path.join(target, 'user-file.txt'), 'keep me', 'utf-8');
|
|
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
quarantineRoot,
|
|
}),
|
|
).rejects.toThrow('already exists but is not a git repository');
|
|
|
|
await expect(fs.readFile(path.join(target, 'user-file.txt'), 'utf-8')).resolves.toBe(
|
|
'keep me',
|
|
);
|
|
await expect(fs.access(quarantineRoot)).rejects.toThrow();
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects controlled clone roots with unsafe permissions inside cloneOrPull', async () => {
|
|
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
|
try {
|
|
await fs.chmod(root, 0o777);
|
|
await expect(
|
|
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), undefined, {
|
|
allowedCloneRoot: root,
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
).rejects.toThrow('world-writable');
|
|
} finally {
|
|
await fs.chmod(root, 0o700).catch(() => {});
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isAzureDevOpsUrl', () => {
|
|
it('recognizes dev.azure.com (cloud)', () => {
|
|
expect(isAzureDevOpsUrl('https://dev.azure.com/org/project/_git/repo')).toBe(true);
|
|
});
|
|
|
|
it('recognizes *.visualstudio.com (cloud legacy)', () => {
|
|
expect(isAzureDevOpsUrl('https://myorg.visualstudio.com/project/_git/repo')).toBe(true);
|
|
});
|
|
|
|
it('returns false for github.com', () => {
|
|
expect(isAzureDevOpsUrl('https://github.com/user/repo')).toBe(false);
|
|
});
|
|
|
|
it('returns false for gitlab.com', () => {
|
|
expect(isAzureDevOpsUrl('https://gitlab.com/user/repo')).toBe(false);
|
|
});
|
|
|
|
it('returns false for invalid URL', () => {
|
|
expect(isAzureDevOpsUrl('not-a-url')).toBe(false);
|
|
});
|
|
|
|
it('normalizes a trailing-dot FQDN (dev.azure.com.)', () => {
|
|
expect(isAzureDevOpsUrl('https://dev.azure.com./org/proj/_git/repo')).toBe(true);
|
|
expect(isAzureDevOpsUrl('https://myorg.visualstudio.com./project/_git/repo')).toBe(true);
|
|
});
|
|
|
|
it('does not over-match a lookalike host with a trailing label', () => {
|
|
expect(isAzureDevOpsUrl('https://dev.azure.com.evil.com/org/proj/_git/repo')).toBe(false);
|
|
expect(isAzureDevOpsUrl('https://evilvisualstudio.com/project/_git/repo')).toBe(false);
|
|
});
|
|
|
|
it('recognizes a self-hosted host configured via AZURE_DEVOPS_URL', () => {
|
|
const prev = process.env.AZURE_DEVOPS_URL;
|
|
try {
|
|
process.env.AZURE_DEVOPS_URL = 'http://tfs.corp.example';
|
|
expect(isAzureDevOpsUrl('http://tfs.corp.example/Coll/Proj/_git/Repo')).toBe(true);
|
|
expect(isAzureDevOpsUrl('https://other.host.example/Coll/Proj/_git/Repo')).toBe(false);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
|
|
else process.env.AZURE_DEVOPS_URL = prev;
|
|
}
|
|
});
|
|
|
|
it('falls through to the cloud check when AZURE_DEVOPS_URL is invalid', () => {
|
|
const prev = process.env.AZURE_DEVOPS_URL;
|
|
try {
|
|
process.env.AZURE_DEVOPS_URL = 'not-a-url';
|
|
expect(isAzureDevOpsUrl('https://dev.azure.com/org/proj/_git/repo')).toBe(true);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
|
|
else process.env.AZURE_DEVOPS_URL = prev;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cleartext-credential warnings', () => {
|
|
it('warns when injecting a credential over cleartext http://', () => {
|
|
warnSpy.mockClear();
|
|
buildGitEnv({}, { token: 'ghp_x', url: 'http://github.com/o/r' });
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not warn when injecting over https://', () => {
|
|
warnSpy.mockClear();
|
|
buildGitEnv({}, { token: 'ghp_x', url: 'https://github.com/o/r' });
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not warn over http:// when no credential is injected', () => {
|
|
warnSpy.mockClear();
|
|
// gitlab host is not in the token allowlist and no Azure PAT is set,
|
|
// so nothing is injected — and nothing is warned about.
|
|
buildGitEnv({}, { token: 'ghp_x', url: 'http://gitlab.com/o/r' });
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warnIfInsecureAzureConfig warns for http AZURE_DEVOPS_URL, not https', () => {
|
|
const prev = process.env.AZURE_DEVOPS_URL;
|
|
warnSpy.mockClear();
|
|
try {
|
|
process.env.AZURE_DEVOPS_URL = 'http://tfs.corp.example';
|
|
warnIfInsecureAzureConfig();
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
warnSpy.mockClear();
|
|
process.env.AZURE_DEVOPS_URL = 'https://tfs.corp.example';
|
|
warnIfInsecureAzureConfig();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
|
|
else process.env.AZURE_DEVOPS_URL = prev;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('extractRepoName — Azure DevOps URLs', () => {
|
|
it('extracts name from self-hosted Azure DevOps URL', () => {
|
|
expect(
|
|
extractRepoName('http://azuredevops.example.com/DefaultCollection/MyProject/_git/MyRepo'),
|
|
).toBe('MyRepo');
|
|
});
|
|
|
|
it('extracts name from dev.azure.com URL', () => {
|
|
expect(extractRepoName('https://dev.azure.com/org/project/_git/myrepo')).toBe('myrepo');
|
|
});
|
|
|
|
it('extracts name from visualstudio.com URL', () => {
|
|
expect(extractRepoName('https://myorg.visualstudio.com/project/_git/myrepo')).toBe('myrepo');
|
|
});
|
|
});
|
|
|
|
describe('extractWebRepoName — API clone compatibility', () => {
|
|
it('sanitizes repo names with spaces and unsafe directory characters at the web boundary', () => {
|
|
expect(extractWebRepoName('https://dev.azure.com/org/project/_git/My Repo With Spaces')).toBe(
|
|
'My_Repo_With_Spaces',
|
|
);
|
|
expect(extractWebRepoName('https://example.com/team/repo$name.git')).toBe('repo_name');
|
|
});
|
|
|
|
it('keeps Windows reserved names from becoming clone directories', () => {
|
|
expect(() => extractWebRepoName('https://example.com/team/CON.git')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
expect(() => extractWebRepoName('https://example.com/team/NUL.txt')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
});
|
|
|
|
it('leaves strict extractRepoName behavior unchanged for internal callers', () => {
|
|
expect(() => extractRepoName('https://example.com/team/repo$name.git')).toThrow(
|
|
'valid repository name',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('validateGitUrl — Azure DevOps URLs', () => {
|
|
it('allows self-hosted Azure DevOps Server URLs', () => {
|
|
expect(() =>
|
|
validateGitUrl('http://azuredevops.example.com/DefaultCollection/Project/_git/Repo'),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('allows dev.azure.com URLs', () => {
|
|
expect(() => validateGitUrl('https://dev.azure.com/org/project/_git/repo')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('normalizeGitUrlForCompare', () => {
|
|
it('strips trailing .git', () => {
|
|
expect(normalizeGitUrlForCompare('https://github.com/owner/repo.git')).toBe(
|
|
normalizeGitUrlForCompare('https://github.com/owner/repo'),
|
|
);
|
|
});
|
|
|
|
it('strips trailing slashes', () => {
|
|
expect(normalizeGitUrlForCompare('https://github.com/owner/repo/')).toBe(
|
|
normalizeGitUrlForCompare('https://github.com/owner/repo'),
|
|
);
|
|
expect(normalizeGitUrlForCompare('https://github.com/owner/repo///')).toBe(
|
|
normalizeGitUrlForCompare('https://github.com/owner/repo'),
|
|
);
|
|
});
|
|
|
|
it('lowercases the hostname but preserves path case', () => {
|
|
expect(normalizeGitUrlForCompare('https://GitHub.com/owner/Repo.git')).toBe(
|
|
normalizeGitUrlForCompare('https://github.com/owner/Repo'),
|
|
);
|
|
// Different path case → distinct repos (hosts treat path as case-sensitive on the wire)
|
|
expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe(
|
|
normalizeGitUrlForCompare('https://github.com/owner/REPO'),
|
|
);
|
|
});
|
|
|
|
it('strips default ports', () => {
|
|
expect(normalizeGitUrlForCompare('https://github.com:443/owner/repo')).toBe(
|
|
normalizeGitUrlForCompare('https://github.com/owner/repo'),
|
|
);
|
|
expect(normalizeGitUrlForCompare('http://github.com:80/owner/repo')).toBe(
|
|
normalizeGitUrlForCompare('http://github.com/owner/repo'),
|
|
);
|
|
});
|
|
|
|
it('preserves non-default ports', () => {
|
|
expect(normalizeGitUrlForCompare('https://git.corp:8443/owner/repo')).not.toBe(
|
|
normalizeGitUrlForCompare('https://git.corp/owner/repo'),
|
|
);
|
|
});
|
|
|
|
it('strips userinfo (basic auth) so equivalent URLs compare equal', () => {
|
|
expect(normalizeGitUrlForCompare('https://user:pass@github.com/owner/repo.git')).toBe(
|
|
normalizeGitUrlForCompare('https://github.com/owner/repo'),
|
|
);
|
|
});
|
|
|
|
it('treats different hosts as distinct', () => {
|
|
expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe(
|
|
normalizeGitUrlForCompare('https://gitlab.com/owner/repo'),
|
|
);
|
|
});
|
|
|
|
it('treats different paths on the same host as distinct', () => {
|
|
expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe(
|
|
normalizeGitUrlForCompare('https://github.com/attacker/repo'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('assertRemoteMatchesRequestedUrl', () => {
|
|
// Closes the wrong-repo silent-analysis vector that Codex's adversarial
|
|
// review on PR #1325 surfaced. Tests use a tmpdir-based fixture
|
|
// (anywhere on disk — independent of CLONE_ROOT) so the helper can be
|
|
// exercised without polluting the user's actual clone root.
|
|
let fixtureDir: string;
|
|
|
|
beforeAll(async () => {
|
|
fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-remote-match-'));
|
|
// git init + set remote.origin.url. We can't call git init via runGit
|
|
// since it's private; spawn directly.
|
|
await new Promise<void>((resolve, reject) => {
|
|
const proc = spawn('git', ['init', '--quiet'], { cwd: fixtureDir, stdio: 'ignore' });
|
|
proc.on('close', (code) =>
|
|
code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)),
|
|
);
|
|
proc.on('error', reject);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
const proc = spawn(
|
|
'git',
|
|
['config', 'remote.origin.url', 'https://github.com/legitorg/myproject.git'],
|
|
{ cwd: fixtureDir, stdio: 'ignore' },
|
|
);
|
|
proc.on('close', (code) =>
|
|
code === 0 ? resolve() : reject(new Error(`git config exit ${code}`)),
|
|
);
|
|
proc.on('error', reject);
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(fixtureDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('accepts the requested URL when it matches the configured remote', async () => {
|
|
await expect(
|
|
assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject.git'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('accepts equivalent forms (with/without .git, trailing slash, default port)', async () => {
|
|
await expect(
|
|
assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject'),
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject/'),
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
assertRemoteMatchesRequestedUrl(
|
|
fixtureDir,
|
|
'https://github.com:443/legitorg/myproject.git',
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
// The exact wrong-repo vector from Codex's review:
|
|
// existing clone → github.com/legitorg/myproject
|
|
// request URL → gitlab.example/attacker/myproject
|
|
// Both share the basename 'myproject'. Without this check, the pull
|
|
// would succeed and analysis would return wrong-repo data.
|
|
it('rejects a different host with the same basename', async () => {
|
|
await expect(
|
|
assertRemoteMatchesRequestedUrl(
|
|
fixtureDir,
|
|
'https://gitlab.example/attacker/myproject.git',
|
|
),
|
|
).rejects.toThrow('not the requested URL');
|
|
});
|
|
|
|
it('rejects a different owner on the same host', async () => {
|
|
await expect(
|
|
assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/attacker/myproject.git'),
|
|
).rejects.toThrow('not the requested URL');
|
|
});
|
|
|
|
it('rejects when the directory has no remote.origin', async () => {
|
|
const noRemoteDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-no-remote-'));
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const proc = spawn('git', ['init', '--quiet'], { cwd: noRemoteDir, stdio: 'ignore' });
|
|
proc.on('close', (code) =>
|
|
code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)),
|
|
);
|
|
proc.on('error', reject);
|
|
});
|
|
await expect(
|
|
assertRemoteMatchesRequestedUrl(noRemoteDir, 'https://github.com/owner/repo.git'),
|
|
).rejects.toThrow('no remote.origin');
|
|
} finally {
|
|
await fs.rm(noRemoteDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getRemoteOriginUrl', () => {
|
|
it('returns null for a directory that is not a git repository', async () => {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-not-git-'));
|
|
try {
|
|
const result = await getRemoteOriginUrl(tmp);
|
|
expect(result).toBeNull();
|
|
} finally {
|
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('runGit timeout', () => {
|
|
it('rejects after SIGKILL even when the child never closes', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stderr: EventEmitter;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
};
|
|
child.stderr = new EventEmitter();
|
|
child.kill = vi.fn();
|
|
const spawnForTest = vi.fn(() => child) as unknown as typeof spawn;
|
|
|
|
const promise = runGitForTest(['clone'], undefined, {
|
|
timeoutMs: 20,
|
|
timeoutKillGraceMs: 20,
|
|
spawnForTest,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
let settled = false;
|
|
promise
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
settled = true;
|
|
});
|
|
await vi.runAllTicks();
|
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
|
await expect(promise).rejects.toThrow('timed out after 20ms');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getCloneDir — a branch-pinned analyze gets its own working tree', () => {
|
|
it('keeps the historic directory for an unpinned request', () => {
|
|
// Backward compatibility: existing installs must keep using the dir they have.
|
|
expect(getCloneDir('Hello-World')).toBe(getCloneDir('Hello-World', undefined));
|
|
});
|
|
|
|
it('gives a pinned request a different directory from the unpinned one', () => {
|
|
// This separation is what stops (a) a pinned request failing on the dirty
|
|
// tree an earlier analyze left, and (b) a later unpinned request pulling on
|
|
// the branch a pin left checked out and indexing it as the default.
|
|
expect(getCloneDir('Hello-World', 'development')).not.toBe(getCloneDir('Hello-World'));
|
|
});
|
|
|
|
it('gives two different branches two different directories', () => {
|
|
expect(getCloneDir('Hello-World', 'development')).not.toBe(
|
|
getCloneDir('Hello-World', 'release/1.2'),
|
|
);
|
|
});
|
|
|
|
it('is stable for the same branch', () => {
|
|
expect(getCloneDir('Hello-World', 'release/1.2')).toBe(
|
|
getCloneDir('Hello-World', 'release/1.2'),
|
|
);
|
|
});
|
|
|
|
it('keeps a slash-bearing ref inside a single path segment under the clone root', () => {
|
|
// `release/1.2` must not become a nested directory, or the containment
|
|
// guarantees around CLONE_ROOT would be reasoning about a different path.
|
|
const dir = getCloneDir('Hello-World', 'release/1.2');
|
|
const base = getCloneDir('Hello-World');
|
|
expect(path.dirname(dir)).toBe(path.dirname(base));
|
|
expect(path.basename(dir)).not.toContain('/');
|
|
});
|
|
|
|
it('round-trips its own directory name, which is how DELETE re-derives it', () => {
|
|
// DELETE /api/repo calls getCloneDir(entry.name); the pinned repo is
|
|
// registered under this basename, so the two must agree.
|
|
const dir = getCloneDir('Hello-World', 'development');
|
|
expect(getCloneDir(path.basename(dir))).toBe(dir);
|
|
});
|
|
|
|
it('pinned basename differs from the GitHub stem (mid-job repoName / registerRepo)', () => {
|
|
// POST /api/analyze must set job.repoName and registryName to
|
|
// path.basename(targetPath), not extractWebRepoName(url). The stem is
|
|
// only getCloneDir's first argument (#3199 review).
|
|
const stem = 'Hello-World';
|
|
const basename = path.basename(getCloneDir(stem, 'development'));
|
|
expect(basename).not.toBe(stem);
|
|
expect(basename.startsWith(`${stem}__`)).toBe(true);
|
|
});
|
|
|
|
it('keeps the directory name inside the 255-byte filesystem limit', () => {
|
|
// validateBranchName allows a 255-char ref and branchSlug appends 9 more,
|
|
// so the naive `<repo>__<slug>` reached 267 and the clone could not create
|
|
// its target directory.
|
|
const longBranch = 'b'.repeat(255);
|
|
const base = path.basename(getCloneDir('Hello-World', longBranch));
|
|
expect(base.length).toBeLessThanOrEqual(255);
|
|
});
|
|
|
|
it('still separates two long branches that share a prefix', () => {
|
|
// Trimming keeps the hash, which is a digest of the FULL ref — otherwise
|
|
// two long branches would collapse onto one directory and silently share
|
|
// an index.
|
|
const a = 'b'.repeat(250) + 'one';
|
|
const b = 'b'.repeat(250) + 'two';
|
|
expect(getCloneDir('Hello-World', a)).not.toBe(getCloneDir('Hello-World', b));
|
|
expect(path.basename(getCloneDir('Hello-World', a)).length).toBeLessThanOrEqual(255);
|
|
});
|
|
|
|
it('round-trips a trimmed directory name too', () => {
|
|
const dir = getCloneDir('Hello-World', 'b'.repeat(255));
|
|
expect(getCloneDir(path.basename(dir))).toBe(dir);
|
|
});
|
|
|
|
it('still rejects a traversal attempt in the repo name', () => {
|
|
expect(() => getCloneDir('..', 'development')).toThrow(/Invalid repository name/);
|
|
expect(() => getCloneDir('a/b', 'development')).toThrow(/Invalid repository name/);
|
|
});
|
|
});
|
|
|
|
describe('analyzeCloneOptions — the /api/analyze glue for #3198', () => {
|
|
// The route passes the result straight to `cloneOrPull`. Inline, a regression
|
|
// that dropped `branch` for token-less URLs left every other test green while
|
|
// silently reindexing the default branch — so each combination is pinned.
|
|
it('returns undefined when neither a token nor a branch is supplied', () => {
|
|
expect(analyzeCloneOptions(undefined, undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('carries a token on its own', () => {
|
|
expect(analyzeCloneOptions('ghp_token', undefined)).toEqual({ token: 'ghp_token' });
|
|
});
|
|
|
|
it('carries a branch on its own — the public-repo case', () => {
|
|
// The regression that would reopen #3198: a branch requested for a public
|
|
// URL must still reach cloneOrPull, with no token in play.
|
|
expect(analyzeCloneOptions(undefined, 'development')).toEqual({ branch: 'development' });
|
|
});
|
|
|
|
it('carries both together', () => {
|
|
expect(analyzeCloneOptions('ghp_token', 'development')).toEqual({
|
|
token: 'ghp_token',
|
|
branch: 'development',
|
|
});
|
|
});
|
|
|
|
it('treats an empty branch as absent rather than sending an empty ref', () => {
|
|
expect(analyzeCloneOptions('ghp_token', '')).toEqual({ token: 'ghp_token' });
|
|
expect(analyzeCloneOptions('', '')).toBeUndefined();
|
|
});
|
|
});
|