fix(pregate): a --base that names a commit gets a branch fix, not an impossible fetch (#3269)

gstack-pregate --base <sha> refused with "fix: git fetch origin <sha>
--depth=1". That fetch can never create origin/<sha>: a short SHA fails
with "couldn't find remote ref", and a full SHA only reaches FETCH_HEAD.
A fork clone hits the same wall with --base upstream/main.

When origin/<base> does not resolve but <base> names a commit here and
no local branch (a SHA, a tag, another remote's ref), the refusal now
says --base takes a branch name on origin and suggests origin/HEAD's
branch. A local branch, or a name that resolves nowhere, keeps the
fetch fix: that is the unfetched or shallow checkout it repairs. Same
code, same exit 3, and pin files are still read only from the reviewed
origin/<base>.

Fixes #3269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
y$un_andClaude Opus 5.5 authored and garrytan committed 2026-10-19 18:34:31 +00:00
1 parent d5eabd8834
commit f8232a1067
4 files changed
+61 -8

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@
/**
* gstack-pregate — cheap checks before anything paid or remote (plan C8).
*
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <ref>]
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--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 <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
--base <ref> base branch (default: origin/HEAD, else main)
--base <branch> 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 <file> pregate.json path (default ${PREGATE_OUT_REL})
+6
View File
@@ -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/<base>`, 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.
<a id="policy-invalid"></a>
### `POLICY_INVALID`
+26 -6
View File
@@ -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<ConfigError | RegistryError>;
}
@@ -62,16 +62,36 @@ export function wtreeRefusal(fp: Extract<WtreeResult, { error: string }>): 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/<base>` does not resolve, yet `<base>` 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 <base>` never creates `origin/<base>` 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>', 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>', 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);
+27
View File
@@ -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/<base>`; 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');
});
});