mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* refactor(mcp): centralize ParamDef→JSON Schema via shared paramDefToSchema Three duplicate inline mappers existed across the MCP surface: - src/mcp/tool-defs.ts (stdio MCP buildToolDefs) - src/commands/serve-http.ts:837 (live HTTP MCP tools/list) - src/core/minions/tools/brain-allowlist.ts:84 (subagent tool registry) Each had subtly different items propagation. The HTTP MCP variant dropped items entirely, leaving extract_facts.entity_hints broken for OAuth- authenticated remote agents even after a buildToolDefs-only patch. The subagent variant propagated one level of items but used the same shallow shape so nested arrays would silently drop. Extract a single recursive paramDefToSchema helper exported from src/mcp/tool-defs.ts and have all three mappers consume it. Closes the bug class at the architecture level instead of patching one site at a time. The helper copies type, description, enum, default, and recursively rebuilds items so array-of-arrays preserves inner shape. Key ordering (type, description, enum, default, items) matches the pre-v0.34 inline mappers so JSON.stringify output stays byte-stable for every existing operation that does not use nested arrays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(schema): add items to extract_facts.entity_hints and handle-to-tweet candidates Two array fields shipped without the items property required by JSON Schema. Strict-mode validators (Gemini Pro structured outputs, OpenAI strict tool definitions) reject the entire schema when any type:'array' lacks items. Downstream agents on those providers couldn't use extract_facts or the x_handle_to_tweet resolver. extract_facts.entity_hints — declared items: { type: 'string' } matching the handler at src/core/operations.ts:2733 which already coerces the runtime value to string[]. handle_to_tweet outputSchema.candidates — full XTweetCandidate spec including required + additionalProperties: false. The XTweetCandidate TypeScript interface declares all five fields as required; without required in the JSON Schema, a validator would accept {} as a valid candidate. additionalProperties: false closes the OpenAI strict-mode contract. 19 community PRs (#1028 #999 #980 #979 #910 #904 #847 #832 #863 #862 #812 for entity_hints; #910 caught candidates) converged on these locations. This wave cherry-picks the deepest variant (#910 surfaced both bugs) and centralizes via the paramDefToSchema helper from the preceding commit so the live HTTP MCP tools/list path is also fixed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: DmitryBMsk (PR #910) * fix(git-remote): move --no-recurse-submodules after the subcommand verb Git CLI accepts two flag positions: git [global -c flags] <subcommand> [subcommand flags] [args] Global -c config flags belong before the verb. Subcommand-specific flags (like --no-recurse-submodules) belong after. Pre-v0.34 GIT_SSRF_FLAGS spliced both kinds before the verb, so cloneRepo invoked: git -c http.followRedirects=false ... --no-recurse-submodules clone URL DIR Real git rejects this with exit 129 ("unknown option: --no-recurse-submodules") because --no-recurse-submodules is a clone subcommand flag, not a global config flag. Every remote-source clone broke in production from v0.28 onward. The fake-git harness in test/git-remote.test.ts exits 0 regardless of argv shape, which is why CI never caught it. Split GIT_SSRF_FLAGS (3 -c config flags, spread BEFORE the verb) from GIT_SSRF_SUBCOMMAND_FLAGS (--no-recurse-submodules, spread AFTER the verb). cloneRepo and pullRepo both spread the new constant after their respective verbs. The constant names signal the position rule so future additions land in the right place. 7 community PRs converged on this location (#1023 #1020 #985 #963 #846 #842 — #800 doesn't exist). This wave cherry-picks the semantic- constant approach from #846's GIT_SSRF_SUBCOMMAND_FLAGS name (the clearest signal of the position rule). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(mcp+git+resolvers): structural array-items + subcommand-position guards Three new tests / test groups close the bug classes the wave fixes: test/mcp-tool-defs.test.ts — recursive structural guard walks every operation's inputSchema and fails with a property path if any type:'array' lacks items.type. Explicit fixture assertions for extract_facts.entity_hints.items.type and a synthetic nested-array ParamDef pinning items.items.type recursion. Without the explicit fixtures the legacyInlineMap byte-equality test is mirror-theater — mirroring both sides of the equality preserves the blind spot. test/git-remote.test.ts — split snapshot test into GIT_SSRF_FLAGS (3 global -c entries) and GIT_SSRF_SUBCOMMAND_FLAGS (--no-recurse-submodules). cloneRepo + pullRepo argv tests now assert the subcommand flag appears AFTER the verb index. Pre-v0.34 the pinned argv slice prefix included --no-recurse-submodules, which baked the bug into the test suite (codex catch). test/resolvers.test.ts — recursive walk over both inputSchema AND outputSchema for builtin resolvers (xHandleToTweetResolver, urlReachableResolver). Explicit imports rather than getDefaultRegistry(), which starts empty until commands/resolvers.ts runs — codex catch on a hollow-walk failure mode. Dedicated case pins candidates items shape including required + additionalProperties. Reference legacyInlineMap in mcp-tool-defs.test.ts mirrors the new recursive paramDefToSchema helper. No current op uses nested arrays so the byte-equality test stays green for every existing operation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): raise rerank timeouts for ZE live cold-start The first rerank call of a CI run hits ZeroEntropy's cold-start latency (observed ~5-6s on Tier 2 LLM Skills runners; subsequent calls < 500ms). Two timeouts fired simultaneously at ~5s: 1. bun:test's default 5000ms per-test timeout caused (fail). 2. gateway.rerank's DEFAULT_RERANK_TIMEOUT_MS = 5000 fired right after, reported as "Unhandled error between tests". The next rerank test (top_n=2) ran in 409ms because the API was already warm. Cold-start is the only issue. Pass explicit timeoutMs to each rerank() call and a longer per-test timeout (30s) on both ZE rerank tests. Production DEFAULT_RERANK_TIMEOUT_MS stays at 5s for the search hot path — these E2E tests bypass it locally without changing the default that protects user latency. Unrelated to the fix-wave in this PR (mcp-tool-defs + git-remote + resolver guards). Lands here to keep Tier 2 LLM Skills green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.35.2.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync for v0.35.2.0 Update CLAUDE.md Key files annotations for the v0.35.2.0 fix wave: - src/mcp/tool-defs.ts: document new exported recursive paramDefToSchema helper and the three-consumer centralization (stdio MCP, HTTP MCP tools/list, subagent registry). - src/core/minions/tools/brain-allowlist.ts: paramsToInputSchema now consumes the shared helper. - src/commands/serve-http.ts: tools/list handler now consumes the shared helper (closes the HTTP MCP items-dropped bug class). - src/core/git-remote.ts: new entry. Documents the GIT_SSRF_FLAGS (global config, pre-verb) vs GIT_SSRF_SUBCOMMAND_FLAGS (subcommand-scoped, post-verb) split, the 7-month silent regression, and the position-anchored regression guard in test/git-remote.test.ts. Regenerated llms-full.txt to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: rebump version to v0.35.3.0 Queue moved while this PR was open — v0.35.2.0 was claimed by master's v0.35.1.0 sibling work. Advancing one slot. No code changes; only: - VERSION + package.json: 0.35.2.0 → 0.35.3.0 - CHANGELOG.md: rewritten header + inline references - CLAUDE.md: rewritten 4 key-file annotations - llms-full.txt + llms.txt: regenerated to mirror CLAUDE.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import { test, expect, describe, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, chmodSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
GIT_SSRF_FLAGS,
|
|
GIT_SSRF_SUBCOMMAND_FLAGS,
|
|
parseRemoteUrl,
|
|
RemoteUrlError,
|
|
cloneRepo,
|
|
pullRepo,
|
|
GitOperationError,
|
|
validateRepoState,
|
|
} from '../src/core/git-remote.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake-git harness: write a shell script that records its argv to a log file,
|
|
// then prepend its dir to PATH for the test. Lets us assert exact argv shape
|
|
// without invoking real git.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FAKE_GIT_DIR = join(tmpdir(), `gbrain-git-remote-test-${process.pid}`);
|
|
const FAKE_GIT_LOG = join(FAKE_GIT_DIR, 'argv.log');
|
|
const FAKE_GIT_MODE = join(FAKE_GIT_DIR, 'mode');
|
|
|
|
function writeFakeGit(): void {
|
|
mkdirSync(FAKE_GIT_DIR, { recursive: true });
|
|
// Mode file controls fake-git behavior: "ok" = exit 0, "fail" = exit 1.
|
|
writeFileSync(FAKE_GIT_MODE, 'ok');
|
|
// Per-invocation argv goes into argv.log (one JSON array per line).
|
|
writeFileSync(FAKE_GIT_LOG, '');
|
|
const script = `#!/usr/bin/env bash
|
|
# Fake git for git-remote.test.ts
|
|
{ printf '['; for arg in "$@"; do printf '%s,' "$(printf '%s' "$arg" | jq -Rs .)"; done; printf 'null]\\n'; } >> "${FAKE_GIT_LOG}"
|
|
mode=$(cat "${FAKE_GIT_MODE}" 2>/dev/null || echo ok)
|
|
case "$mode" in
|
|
fail) exit 1 ;;
|
|
url-drift) echo "https://github.com/different/url" ;;
|
|
url-match) echo "https://github.com/expected/url" ;;
|
|
*) ;;
|
|
esac
|
|
exit 0
|
|
`;
|
|
const path = join(FAKE_GIT_DIR, 'git');
|
|
writeFileSync(path, script);
|
|
chmodSync(path, 0o755);
|
|
}
|
|
|
|
function readArgvLog(): string[][] {
|
|
const raw = readFileSync(FAKE_GIT_LOG, 'utf8');
|
|
return raw
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map(line => {
|
|
const arr = JSON.parse(line) as (string | null)[];
|
|
return arr.filter((x): x is string => x !== null);
|
|
});
|
|
}
|
|
|
|
function clearArgvLog(): void {
|
|
writeFileSync(FAKE_GIT_LOG, '');
|
|
}
|
|
|
|
function setMode(mode: 'ok' | 'fail' | 'url-drift' | 'url-match'): void {
|
|
writeFileSync(FAKE_GIT_MODE, mode);
|
|
}
|
|
|
|
beforeAll(() => writeFakeGit());
|
|
afterAll(() => rmSync(FAKE_GIT_DIR, { recursive: true, force: true }));
|
|
beforeEach(() => {
|
|
clearArgvLog();
|
|
setMode('ok');
|
|
});
|
|
|
|
const fakePath = (): string => `${FAKE_GIT_DIR}:${process.env.PATH ?? ''}`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GIT_SSRF_FLAGS — pinned shape (snapshot test). If a future flag is added,
|
|
// update the expected list here AND verify both cloneRepo + pullRepo pick it
|
|
// up via the GIT_SSRF_FLAGS spread (the codex finding that motivated this).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GIT_SSRF_FLAGS', () => {
|
|
test('exact shape — global -c config flags only (spread BEFORE the verb)', () => {
|
|
expect([...GIT_SSRF_FLAGS]).toEqual([
|
|
'-c', 'http.followRedirects=false',
|
|
'-c', 'protocol.file.allow=never',
|
|
'-c', 'protocol.ext.allow=never',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('GIT_SSRF_SUBCOMMAND_FLAGS', () => {
|
|
test('exact shape — subcommand-level flags only (spread AFTER the verb)', () => {
|
|
// v0.34 fix wave: --no-recurse-submodules is a clone/pull subcommand
|
|
// flag, not a global flag. Real git exits 129 with "unknown option"
|
|
// when it appears before the verb. The pre-v0.34 single-constant
|
|
// spread baked the bug in.
|
|
expect([...GIT_SSRF_SUBCOMMAND_FLAGS]).toEqual([
|
|
'--no-recurse-submodules',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseRemoteUrl
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('parseRemoteUrl — happy path', () => {
|
|
test('accepts plain https URL', () => {
|
|
const r = parseRemoteUrl('https://github.com/garrytan/dummy.git');
|
|
expect(r.url).toBe('https://github.com/garrytan/dummy.git');
|
|
expect(r.hostname).toBe('github.com');
|
|
});
|
|
});
|
|
|
|
describe('parseRemoteUrl — rejection cases', () => {
|
|
test('rejects empty input', () => {
|
|
expect(() => parseRemoteUrl('')).toThrow(RemoteUrlError);
|
|
});
|
|
test('rejects malformed URL', () => {
|
|
expect(() => parseRemoteUrl('not a url')).toThrow(/malformed|invalid_url/i);
|
|
});
|
|
test('rejects ssh:// scheme', () => {
|
|
try {
|
|
parseRemoteUrl('ssh://git@github.com/foo/bar.git');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('unsupported_scheme');
|
|
}
|
|
});
|
|
test('rejects git:// scheme', () => {
|
|
expect(() => parseRemoteUrl('git://github.com/foo/bar')).toThrow(/scheme not supported/i);
|
|
});
|
|
test('rejects file:// scheme', () => {
|
|
expect(() => parseRemoteUrl('file:///etc/passwd')).toThrow(/scheme not supported/i);
|
|
});
|
|
test('rejects embedded credentials', () => {
|
|
try {
|
|
parseRemoteUrl('https://user:pass@github.com/foo');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('embedded_credentials');
|
|
}
|
|
});
|
|
test('rejects path traversal (..)', () => {
|
|
try {
|
|
parseRemoteUrl('https://github.com/foo/../etc/passwd');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('path_traversal');
|
|
}
|
|
});
|
|
test('rejects RFC1918 192.168.x.x', () => {
|
|
try {
|
|
parseRemoteUrl('https://192.168.1.1/repo.git');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('internal_target');
|
|
}
|
|
});
|
|
test('rejects loopback 127.0.0.1', () => {
|
|
expect(() => parseRemoteUrl('https://127.0.0.1/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects localhost', () => {
|
|
expect(() => parseRemoteUrl('https://localhost/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects metadata.google.internal', () => {
|
|
expect(() => parseRemoteUrl('https://metadata.google.internal/foo')).toThrow(
|
|
/internal/i,
|
|
);
|
|
});
|
|
test('rejects 169.254.x.x AWS metadata range', () => {
|
|
expect(() => parseRemoteUrl('https://169.254.169.254/foo')).toThrow(/internal/i);
|
|
});
|
|
|
|
// Codex v0.28.1 finding: IPv6 ULA + link-local were not blocked.
|
|
test('rejects IPv6 ULA fc00::/7 (fd-prefix)', () => {
|
|
expect(() => parseRemoteUrl('https://[fd00:1234::1]/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects IPv6 ULA fc00::/7 (fc-prefix)', () => {
|
|
expect(() => parseRemoteUrl('https://[fc01:2345::abcd]/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects IPv6 link-local fe80::/10', () => {
|
|
expect(() => parseRemoteUrl('https://[fe80::1]/repo')).toThrow(/internal/i);
|
|
});
|
|
test('does NOT reject public IPv6', () => {
|
|
// 2606:4700:4700::1111 is Cloudflare DNS — public IPv6
|
|
const r = parseRemoteUrl('https://[2606:4700:4700::1111]/repo');
|
|
expect(r.hostname).toBe('[2606:4700:4700::1111]');
|
|
});
|
|
});
|
|
|
|
// T3 — Tailscale CGNAT regression cases.
|
|
describe('parseRemoteUrl — CGNAT 100.64/10 (Tailscale)', () => {
|
|
test('rejected by default', async () => {
|
|
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
|
|
try {
|
|
parseRemoteUrl('https://100.64.0.1/repo.git');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('internal_target');
|
|
}
|
|
});
|
|
});
|
|
test('accepted with GBRAIN_ALLOW_PRIVATE_REMOTES=1', async () => {
|
|
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: '1' }, async () => {
|
|
const r = parseRemoteUrl('https://100.64.0.1/repo.git');
|
|
expect(r.hostname).toBe('100.64.0.1');
|
|
});
|
|
});
|
|
test('also covers 100.127.x (upper end of CGNAT range)', async () => {
|
|
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
|
|
expect(() => parseRemoteUrl('https://100.127.255.1/x')).toThrow(/internal/i);
|
|
});
|
|
});
|
|
test('does NOT reject 100.0.x (just below CGNAT range)', () => {
|
|
// 100.0.0.0/8 is regular public IP space outside CGNAT
|
|
const r = parseRemoteUrl('https://100.63.255.1/repo');
|
|
expect(r.hostname).toBe('100.63.255.1');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// cloneRepo — fake-git harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('cloneRepo', () => {
|
|
test('happy path: invokes git with GIT_SSRF_FLAGS + --depth=1 + url + dest', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-target');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
cloneRepo('https://example.com/repo', dest);
|
|
});
|
|
const calls = readArgvLog();
|
|
expect(calls.length).toBe(1);
|
|
const argv = calls[0];
|
|
// Global -c config flags must appear BEFORE the 'clone' verb.
|
|
expect(argv.slice(0, GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
|
|
expect(argv).toContain('clone');
|
|
expect(argv).toContain('--depth=1');
|
|
expect(argv).toContain('https://example.com/repo');
|
|
expect(argv[argv.length - 1]).toBe(dest);
|
|
// v0.34 fix wave: subcommand flags MUST appear after the verb. Real
|
|
// git rejects `git --no-recurse-submodules clone ...` with exit 129.
|
|
// The fake-git harness returned 0 for any argv shape, so this
|
|
// position-anchored assertion is the structural regression test.
|
|
const cloneIdx = argv.indexOf('clone');
|
|
expect(cloneIdx).toBeGreaterThan(-1);
|
|
for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
|
|
const flagIdx = argv.indexOf(subFlag);
|
|
expect(flagIdx).toBeGreaterThan(cloneIdx);
|
|
}
|
|
});
|
|
|
|
test('depth=0 means no --depth flag (full clone)', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-full');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
cloneRepo('https://example.com/repo', dest, { depth: 0 });
|
|
});
|
|
const argv = readArgvLog()[0];
|
|
expect(argv.find(a => a.startsWith('--depth'))).toBeUndefined();
|
|
});
|
|
|
|
test('passes --branch when provided', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-branch');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
cloneRepo('https://example.com/repo', dest, { branch: 'main' });
|
|
});
|
|
const argv = readArgvLog()[0];
|
|
const branchIdx = argv.indexOf('--branch');
|
|
expect(branchIdx).toBeGreaterThan(-1);
|
|
expect(argv[branchIdx + 1]).toBe('main');
|
|
});
|
|
|
|
test('refuses non-empty destDir', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-nonempty');
|
|
mkdirSync(dest, { recursive: true });
|
|
writeFileSync(join(dest, 'sentinel'), 'hi');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
try {
|
|
cloneRepo('https://example.com/repo', dest);
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(GitOperationError);
|
|
expect((e as GitOperationError).op).toBe('clone');
|
|
}
|
|
});
|
|
expect(readArgvLog().length).toBe(0); // never invoked git
|
|
rmSync(dest, { recursive: true, force: true });
|
|
});
|
|
|
|
test('throws GitOperationError when git exits non-zero', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-fails');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
setMode('fail');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
try {
|
|
cloneRepo('https://example.com/repo', dest);
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(GitOperationError);
|
|
expect((e as GitOperationError).op).toBe('clone');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pullRepo — fake-git harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pullRepo', () => {
|
|
test('happy path: invokes git -C path with GIT_SSRF_FLAGS + pull --ff-only', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-target');
|
|
mkdirSync(repo, { recursive: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
pullRepo(repo);
|
|
});
|
|
const argv = readArgvLog()[0];
|
|
expect(argv[0]).toBe('-C');
|
|
expect(argv[1]).toBe(repo);
|
|
expect(argv.slice(2, 2 + GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
|
|
expect(argv).toContain('pull');
|
|
expect(argv).toContain('--ff-only');
|
|
// v0.34 fix wave: subcommand flag position assertion.
|
|
const pullIdx = argv.indexOf('pull');
|
|
expect(pullIdx).toBeGreaterThan(-1);
|
|
for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
|
|
const flagIdx = argv.indexOf(subFlag);
|
|
expect(flagIdx).toBeGreaterThan(pullIdx);
|
|
}
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('throws GitOperationError when git exits non-zero', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-fails');
|
|
mkdirSync(repo, { recursive: true });
|
|
setMode('fail');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(() => pullRepo(repo)).toThrow(GitOperationError);
|
|
});
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateRepoState — 6-state decision tree
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('validateRepoState', () => {
|
|
const fixtureDir = join(FAKE_GIT_DIR, 'state-fixtures');
|
|
|
|
beforeEach(() => {
|
|
rmSync(fixtureDir, { recursive: true, force: true });
|
|
mkdirSync(fixtureDir, { recursive: true });
|
|
});
|
|
|
|
test("returns 'missing' for nonexistent path", () => {
|
|
expect(validateRepoState(join(fixtureDir, 'nope'))).toBe('missing');
|
|
});
|
|
|
|
test("returns 'not-a-dir' when path is a file", () => {
|
|
const p = join(fixtureDir, 'a-file');
|
|
writeFileSync(p, 'hi');
|
|
expect(validateRepoState(p)).toBe('not-a-dir');
|
|
});
|
|
|
|
test("returns 'no-git' for directory without .git/", () => {
|
|
const p = join(fixtureDir, 'no-git-dir');
|
|
mkdirSync(p, { recursive: true });
|
|
expect(validateRepoState(p)).toBe('no-git');
|
|
});
|
|
|
|
test("returns 'corrupted' when git remote get-url fails", async () => {
|
|
const p = join(fixtureDir, 'corrupted-repo');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('fail');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p)).toBe('corrupted');
|
|
});
|
|
});
|
|
|
|
test("returns 'url-drift' when remote differs from expected", async () => {
|
|
const p = join(fixtureDir, 'drift-repo');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('url-drift');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('url-drift');
|
|
});
|
|
});
|
|
|
|
test("returns 'healthy' when remote matches expected", async () => {
|
|
const p = join(fixtureDir, 'healthy-repo');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('url-match');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('healthy');
|
|
});
|
|
});
|
|
|
|
test("returns 'healthy' when no expected URL provided (just probe)", async () => {
|
|
const p = join(fixtureDir, 'healthy-no-expect');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('ok');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p)).toBe('healthy');
|
|
});
|
|
});
|
|
});
|