mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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:
1 parent
d5eabd8834
commit
f8232a1067
4 files changed
+61
-8
No files matched your search
+2
-2
@@ -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})
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user