diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 9eab2d38cc..3ef0752976 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -2,7 +2,7 @@ /** * gstack-pregate — cheap checks before anything paid or remote (plan C8). * - * gstack-pregate [run] [--stage preflight|tests|all] [--only ] [--base ] + * gstack-pregate [run] [--stage preflight|tests|all] [--only ] [--base ] * [--allow-repo-commands] [--policy-from base|head] [--out ] * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--explain] [--json] * gstack-pregate verify [--out ] [--json] publication check: current tree, no fail/incomplete, obligations cleared @@ -60,7 +60,7 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do --stage preflight|tests|all default all --only restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')}) - --base base branch (default: origin/HEAD, else main) + --base a branch on origin, not a commit (default: origin/HEAD's branch, else main) --allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete --policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands) --out pregate.json path (default ${PREGATE_OUT_REL}) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e2719bca02..5c478aba6e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2360,6 +2360,12 @@ working-tree file silently. branch's own file deliberately, pass `--policy-from head --allow-repo-commands`; the receipt then says `policy: head (unreviewed)`. +`--base` names a branch on `origin`. A commit SHA, a tag or another remote's +ref (`upstream/main` in a fork clone) never resolves as `origin/`, and +fetching it cannot create that ref, so pass a branch that exists on `origin` +(`git branch -r --list 'origin/*'` lists them). `gstack-pregate` says so in +its fix instead of suggesting the fetch. + ### `POLICY_INVALID` diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 0238261b1e..4d55842844 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -14,7 +14,7 @@ import * as path from 'node:path'; import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen'; import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes'; import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation'; -import { policyUnavailableLine } from '../ship-policy'; +import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy'; import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config'; import type { CheckResult, PregateContext, TouchedFile } from './types'; import { discoverLanes } from './workflows'; @@ -40,7 +40,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO export interface ContextRefusal { code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE'; message: string; - /** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair). */ + /** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair; so does a `--base` that is no origin branch). */ fix?: string; errors?: Array; } @@ -62,16 +62,36 @@ export function wtreeRefusal(fp: Extract): Conte return { code: 'WTREE_UNAVAILABLE', message: fp.error, fix: 'run bin/gstack-wtree in the repository to see why it fails (it exits 1 outside a git repository, in one with no commits, or when a write fails), repair that, then rerun gstack-pregate' }; } +/** + * `origin/` does not resolve, yet `` names a commit here and no local + * branch: a SHA, a tag or another remote's ref (`upstream/main` in a fork clone). + * `git fetch origin ` never creates `origin/` for those (#3269), so the + * fix names an origin branch instead. A local branch, or a name that resolves + * nowhere, keeps the code's fetch fix: that is the shallow or never-fetched + * checkout it repairs. + */ +function notAnOriginBranch(repoRoot: string, base: string): ContextRefusal | null { + if (git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${base}`]).ok) return null; + if (!git(repoRoot, ['rev-parse', '--verify', '--quiet', `${base}^{commit}`]).ok) return null; + const fallback = resolveBaseBranch(repoRoot); + const example = git(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${fallback}^{commit}`]).ok ? `, such as --base ${fallback}` : ''; + return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `origin/${base} is not a resolvable commit; ${base} names a commit here, but --base takes a branch name on origin`, fix: `pass a branch that exists on origin${example} (git branch -r --list 'origin/*' lists them)` }; +} + export interface RefusalReport { code: ContextRefusal['code']; detail: string; fix: string; docs: string; line: string } -/** One context refusal as its stderr line and as the fields of the `--json` envelope. */ +/** + * One context refusal as its stderr line and as the fields of the `--json` envelope. + * A cause-specific `fix` replaces the code's own. The shared POLICY_SOURCE_UNAVAILABLE + * summary names the ship policy; pregate's line says "pin files". + */ export function describeRefusal(refused: ContextRefusal, base: string, context = 'gstack-pregate'): RefusalReport { const { code, message } = refused; const row = RESULT_CODES[code]; const docs = `${TROUBLESHOOTING_DOC}#${row.anchor}`; - if (code === 'POLICY_SOURCE_UNAVAILABLE') return { code, detail: message, fix: row.fix.replace('', base), docs, line: policyUnavailableLine(context, base, message).replace('.gstack/ship-policy.json', 'pin files') }; + if (code === 'POLICY_SOURCE_UNAVAILABLE' && !refused.fix) return { code, detail: message, fix: row.fix.replace('', base), docs, line: policyUnavailableLine(context, base, message).replace('.gstack/ship-policy.json', 'pin files') }; if (!refused.fix) return { code, detail: message, fix: row.fix, docs, line: resultLine(context, code, message) }; - return { code, detail: message, fix: refused.fix, docs, line: `${context}: ${row.summary} (${message}); fix: ${refused.fix} (${code})` }; + return { code, detail: message, fix: refused.fix, docs, line: `${context}: ${row.summary.replace('.gstack/ship-policy.json', 'pin files')} (${message}); fix: ${refused.fix} (${code})` }; } function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string } { @@ -107,7 +127,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad { const base = o.base.replace(/^origin\//, ''); const baseRef = `origin/${base}`; const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); - if (!baseSha.ok || !baseSha.out) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } }; + if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } }; const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out; const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out; const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks); diff --git a/test/pregate-wtree-refusal.test.ts b/test/pregate-wtree-refusal.test.ts index a9258ef05e..b50ffcc353 100644 --- a/test/pregate-wtree-refusal.test.ts +++ b/test/pregate-wtree-refusal.test.ts @@ -6,6 +6,9 @@ * names GSTACK_GIT_BASH, a gstack-wtree exit keeps the repair it printed. * Under --json every context refusal is one envelope on stdout with exit 3, * sharing `ok` and `status` with the run object; GSTACK_RESULT is on stderr. + * A `--base` that names a commit here but no origin branch (#3269) gets a fix + * naming a branch, never a fetch that cannot create `origin/`; a branch + * name keeps the fetch fix. */ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; @@ -114,4 +117,28 @@ describe('gstack-pregate context refusals through the CLI', () => { expect(run.err).toMatch(/^GSTACK_RESULT: skill=pregate status=complete run=.*pregate\.json$/m); expect(fs.existsSync(ok.out)).toBe(true); }); + + test('a --base that names a commit here but no origin branch says to pass a branch; a branch name keeps the fetch fix (#3269)', () => { + const repo = fixture(); + const sha = repo.git('rev-parse', '--short=8', 'main'); + repo.git('update-ref', 'refs/remotes/upstream/main', 'main'); + repo.git('branch', 'unpushed', 'main'); + for (const base of [sha, 'upstream/main']) { + const r = runBin(repo, 'gstack-pregate', ['--base', base, '--stage', 'preflight', '--json']); + expect(r.status).toBe(EXIT.refused); + const b = JSON.parse(r.out); + expect(b).toMatchObject({ ok: false, status: 'refused', code: 'POLICY_SOURCE_UNAVAILABLE' }); + expect(b.detail).toStartWith(`origin/${base} is not a resolvable commit`); + expect(b.detail).toContain('--base takes a branch name on origin'); + expect(b.fix).toContain('--base main'); + expect(b.docs).toEndWith('#policy-source-unavailable'); + const line = r.err.split('\n')[0]!; + expect(line).not.toContain('git fetch'); + expect(line).toContain('the reviewed pin files cannot be read'); + expect(line).toEndWith(`; fix: ${b.fix} (POLICY_SOURCE_UNAVAILABLE)`); + } + const unpushed = runBin(repo, 'gstack-pregate', ['--base', 'unpushed', '--stage', 'preflight', '--json']); + expect(unpushed.status).toBe(EXIT.refused); + expect(JSON.parse(unpushed.out).fix).toContain('git fetch origin unpushed --depth=1'); + }); });