Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/gstack-pregate
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down
6 changes: 6 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
32 changes: 26 additions & 6 deletions lib/pregate/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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>;
}
Expand All @@ -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 } {
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions test/pregate-wtree-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
Loading