fix: refuse a base that contains HEAD; skip a self-pointing origin/HEAD (GST-774)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 13:56:21 +00:00
1 parent f739756389
commit b2c644c3f4
16 files changed
+545 -45

No files matched your search

+58 -14
View File
@@ -15,7 +15,11 @@
# trips loudly instead of silently disabling reviewers)
# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base
# (shallow CI checkout / missing fetch — a green here would mean
# "we could not look")
# "we could not look"; also no default base when origin/HEAD
# names the checked-out branch and nothing past it resolves)
# exit 2 base is HEAD or contains it → all false + SCOPE_ERROR=base_contains_head
# (the branch has commits its real base lacks, so it would be
# compared with itself, GST-774; the reason as a comment line)
# Every line is shell-safe for `source <(...)` consumers: assignments or
# `#`-comments only.
#
@@ -26,14 +30,25 @@
set -euo pipefail
# Detect the repo's default branch when no arg is given (#703-class
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
_default_base() {
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
echo "main"
}
BASE="${1:-$(_default_base)}"
# platform-agnostic rule, lib/default-base.ts): origin/HEAD unless it names the
# checked-out branch (a PR flow's clone can point it at the PR branch, GST-511,
# GST-774) -> the PR's base (gh, 5 s) -> origin/main -> origin/master -> main.
# An origin/HEAD skipped that way with nothing past it resolving leaves BASE
# empty, and that fails closed; so does a default-base.ts that cannot run.
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
_default_base_ts() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/lib/default-base.ts" "$@"; }
NO_DEFAULT=""
BASE="${1:-}"
if [ -z "$BASE" ]; then
_rc=0
_found=$(_default_base_ts default 2>/dev/null) || _rc=$?
case "$_rc" in
0) BASE="$_found" ;;
1) BASE="main" ;;
3) NO_DEFAULT="$_found" ;;
*) NO_DEFAULT="lib/default-base.ts could not run (exit $_rc); pass the base explicitly" ;;
esac
fi
HEAD_REF=""
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
@@ -65,16 +80,45 @@ _print_flags() {
echo "SCOPE_AUTH=$AUTH"
}
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
# "we could not look". Distinguish it before diffing.
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
if [ -z "$BASE" ]; then
_print_flags
echo "SCOPE_ERROR=no_base"
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
echo "# ${NO_DEFAULT}"
exit 2
fi
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
# "we could not look". Distinguish it before diffing. A branch name that does
# not resolve here reads its origin twin first (a PR checkout has no local main).
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
case "$BASE" in
HEAD|refs/*|origin/*|*[~^@:]*) ;;
*) if git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1; then BASE="origin/$BASE"; fi ;;
esac
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
_print_flags
echo "SCOPE_ERROR=no_base"
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
exit 2
fi
fi
# A base that is the head or already contains it reads the branch against
# itself, all false with exit 0 (GST-774). lib/default-base.ts decides whether
# that is the base branch itself or a fresh branch (today's output) or a
# refusal; it is asked only when git says the base contains the head.
if git merge-base --is-ancestor "${HEAD_REF:-HEAD}" "${BASE}" >/dev/null 2>&1; then
_rc=0
_why=$(_default_base_ts check "$BASE" "${HEAD_REF:-HEAD}" gstack-diff-scope 2>/dev/null) || _rc=$?
if [ "$_rc" -ne 0 ]; then
_print_flags
echo "SCOPE_ERROR=base_contains_head"
echo "# ${_why:-lib/default-base.ts could not check the base (exit $_rc) (BASE_CONTAINS_HEAD)}"
exit 2
fi
fi
# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
FILES_LIST=()
+7 -1
View File
@@ -18,7 +18,9 @@
* untracked files). It prints a short summary; the JSON file is the record.
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
* (Step 14.5 Blocked recovery): the record then carries that exception, so
* Step 16 can tell an accepted risk from a skipped audit.
* Step 16 can tell an accepted risk from a skipped audit. A --base that is HEAD
* or already contains it while the branch has commits its real base lacks is
* refused (BASE_CONTAINS_HEAD, lib/default-base.ts): it would select nothing.
*
* compare re-reads the same state and prints one JSON object: whether HEAD or
* the index changed, which recorded paths' content changed or disappeared, and
@@ -35,6 +37,8 @@
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { baseContainsHead } from '../lib/default-base';
import { resultLine } from '../lib/result-codes';
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
function git(args: string[]): string {
@@ -135,6 +139,8 @@ function main(argv: string[]): void {
const mode = one(args, '--mode');
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
const self = baseContainsHead(top, base);
if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
const exceptionFile = flag(args, '--exception')[0];
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
process.chdir(top);
+23 -4
View File
@@ -19,9 +19,13 @@
* /review's review_binding and make /ship loop).
*
* Exit: 0 with or without hunks. 2 only when the base is unresolvable
* (`GATE_ERROR=no_base ref=<ref> fix=<git command>` as the only stdout line)
* or the tool itself failed (`GATE_ERROR=internal …`). Unmatched paths are
* counted, never an exit 2.
* (`GATE_ERROR=no_base ref=<ref> fix=<git command>` as the only stdout line;
* `ref=<remote>/HEAD target=<remote>/<branch>` when no base is given, that
* remote's HEAD names the checked-out branch and no other default resolves),
* when the base is HEAD or already contains it while the branch has commits
* its real base lacks (`GATE_ERROR=base_contains_head ref=<ref>`, the
* BASE_CONTAINS_HEAD line on stderr), or the tool itself failed
* (`GATE_ERROR=internal …`). Unmatched paths are counted, never an exit 2.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -31,6 +35,8 @@ import {
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
import { resultLine } from '../lib/result-codes';
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
import { resolveStateRoot } from '../lib/state-root';
import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation';
@@ -141,7 +147,14 @@ function main(argv: string[]): number {
candidate = sha.slice(0, 12);
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
} else {
const baseRef = parsed.base ?? defaultBase(root);
const found = parsed.base !== undefined ? { ref: parsed.base } : defaultBase(root);
if ('skipped' in found) {
const { remote, branch } = found.skipped;
console.error(`gstack-gate-diff: ${noDefaultBaseLine(found.skipped)}`);
process.stdout.write(`GATE_ERROR=no_base ref=${remote}/HEAD target=${remote}/${branch} fix=git remote set-head ${remote} <base>\n`);
return 2;
}
const baseRef = found.ref;
const head = headSha(root);
const base = resolveBase(baseRef, root, head ?? undefined);
if (!base || !head) {
@@ -154,6 +167,12 @@ function main(argv: string[]): number {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
const self = mb === head ? baseContainsHead(root, base.ref) : null;
if (self) {
console.error(resultLine('gstack-gate-diff', 'BASE_CONTAINS_HEAD', self));
process.stdout.write(`GATE_ERROR=base_contains_head ref=${baseRef}\n`);
return 2;
}
files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)];
commits = commitsInRange(mb, head, root);
const fp = wtreeFingerprint({ cwd: root });
+5 -1
View File
@@ -26,6 +26,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { baseContainsHead } from '../lib/default-base';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, explainConfig, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
@@ -72,7 +73,8 @@ 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 <branch> a branch on origin, not a commit (default: origin/HEAD's branch, else main)
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch unless it is the
checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts)
--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; the head file may
only add test lanes over the base copy, else PREGATE_HEAD_POLICY_LOOSENS)
@@ -285,6 +287,8 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
if (!pushedSha) { process.stderr.write(`gstack-pregate sealed: ${end} is not a commit\n`); return EXIT.usage; }
const listed = range ? rangeCommits(root, range) : publishedCommits(root, { localSha: pushedSha, remoteName: remote });
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
const self = baseRef && baseContainsHead(root, baseRef);
if (self) { process.stderr.write(`${resultLine('gstack-pregate sealed', 'BASE_CONTAINS_HEAD', self)}; the policy would be the branch's own\n`); return EXIT.refused; }
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
? sealedErrorReport(listed.error, remoteUrl ?? remote)
+7 -1
View File
@@ -16,12 +16,16 @@
* FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed
* the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per
* reviewer and one SKIP: per reviewer left out. A plan that fails closed to full
* exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts.
* exits 0; a diff git cannot list exits 1, and so does a base that is HEAD or already
* contains it while the branch has commits its real base lacks (BASE_CONTAINS_HEAD,
* lib/default-base.ts). Logic: lib/review-depth.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { baseContainsHead } from '../lib/default-base';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { collectCommit, collectLive } from '../lib/review-collect';
import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
@@ -102,6 +106,8 @@ function main(): number {
return EXIT.fail;
}
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
const self = collected.mergeBase === head ? baseContainsHead(root, git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) ? `origin/${base}` : base) : null;
if (self) { process.stderr.write(`${resultLine('gstack-review-plan', 'BASE_CONTAINS_HEAD', self)}; no plan is safer than a plan for a diff compared with itself\n`); return EXIT.fail; }
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const scopeOf = (args: string[]): Record<string, string> => {
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
+2 -2
View File
@@ -20,7 +20,7 @@
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
* gstack-ship-queue wave plan|build|status|settle ... (wave PRs: 3-6 same-area PRs in one train slot; wave --help)
*
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
* Every subcommand takes --base <branch> (default lib/default-base.ts) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
* gstack-restamp stays the primitive. Nothing here pushes, except land, ready
* under changelog_fragments (its READY receipt note), and under a
@@ -69,7 +69,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
wave plan|build|status|settle ... wave PRs under a train policy: 3-6 same-area PRs in one slot and one version
(wave --help; docs/ship-policy.md#wave-prs)
--base <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
--base <branch>: base branch (default: origin/HEAD unless it names the checked-out branch, else the PR's base, origin/main). Nothing here pushes, except land (the stamp commit and the
refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains.
${exitTableHelp()}`;
+20
View File
@@ -5210,6 +5210,26 @@ wait`'s `--timeout`.
**Fix.** Run `wait` again (the pause stays valid until it expires), or find who
owns the base's red.
<a id="base-contains-head"></a>
### `BASE_CONTAINS_HEAD`
**Meaning.** A base-relative step (`gstack-ship-queue ready`, `land` and
`train` receipt binding, `gstack-review-plan`, `gstack-pregate`,
`gstack-gate-diff`, `gstack-diff-scope`, `gstack-docs-candidate snapshot`)
was given a base that is the head or already contains it, while the branch
has commits its real base lacks. The comparison would read the branch against
itself and report no changes, a false green. The usual cause is a checkout
made by a PR flow: `refs/remotes/origin/HEAD` names the PR branch and there is
no local `main` (GST-774). The detail names the base, the head, and the real
base (the PR's base from `gh`, else `origin/main`, else `origin/master`). For
a pull request's head the queue refuses whenever the base contains it. On the
base branch itself, on a detached HEAD, and on a fresh branch with no commits
past its base, the step runs as before.
**Fix.** Pass the real base (`--base main`, or the PR's base branch). When
`origin/HEAD` names the checked-out branch, `git remote set-head origin main`
repairs every default at once.
<a id="queue-receipt-depth-raised"></a>
### `QUEUE_RECEIPT_DEPTH_RAISED`
+103
View File
@@ -0,0 +1,103 @@
/**
* default-base — the base branch a command uses when none is given (GST-511), and the
* refusal every base-relative step shares when its base is the branch itself (GST-774).
* A remote's HEAD counts only when it names a branch other than the checked-out one: a
* clone made by a PR flow can point origin/HEAD at the PR branch, and that base diffs
* the branch against itself, an empty diff every gate passes. Past it: the PR's base
* from gh (a short timeout, prompts off, any failure ignored; none for a PR from another
* repository), then `<remote>/main`, then `<remote>/master`. A detached HEAD keeps the
* remote's HEAD. bin/gstack-diff-scope reads both through this file's command line.
*/
import { spawnSync } from 'node:child_process';
import { resultLine } from './result-codes';
export interface DefaultBase {
/** The base branch name, or null when nothing resolves. */
branch: string | null;
/** The remote whose HEAD named `branch`; unset for the PR base, main and master. */
remote?: string;
/** The first remote HEAD skipped because it names the checked-out branch. */
skipped?: { remote: string; branch: string };
}
/** gh's jq for the PR's base branch; a PR from another repository names a branch origin may not hold, so it names none. */
const PR_BASE = 'if .isCrossRepository then "" else .baseRefName end';
function read(cmd: string, args: string[], cwd: string, timeout: number): string {
const r = spawnSync(cmd, args, {
cwd, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1' },
});
return r.status === 0 ? (r.stdout ?? '').trim() : '';
}
const commit = (cwd: string, ref: string) => read('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], cwd, 30_000);
const ancestor = (cwd: string, a: string, b: string) => spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd, timeout: 30_000, stdio: 'ignore' }).status === 0;
export function defaultBaseBranch(cwd: string, remotes: readonly string[] = ['origin']): DefaultBase {
const current = read('git', ['symbolic-ref', '--quiet', 'HEAD'], cwd, 30_000).replace(/^refs\/heads\//, '');
let skipped: DefaultBase['skipped'];
for (const remote of remotes) {
const prefix = `refs/remotes/${remote}/`;
const target = read('git', ['symbolic-ref', '--quiet', `${prefix}HEAD`], cwd, 30_000);
if (!target.startsWith(prefix)) continue;
const branch = target.slice(prefix.length);
if (branch !== current) return { branch, remote };
skipped ??= { remote, branch };
}
const pr = current ? read('gh', ['pr', 'view', '--json', 'baseRefName,isCrossRepository', '-q', PR_BASE], cwd, 5_000).split('\n')[0] : '';
if (pr) return { branch: pr, skipped };
for (const name of ['main', 'master']) {
if (remotes.some((remote) => commit(cwd, `refs/remotes/${remote}/${name}`))) return { branch: name, skipped };
}
return { branch: null, skipped };
}
/** Why there is no default base: a remote HEAD named the checked-out branch and nothing past it resolved. */
export function noDefaultBaseLine({ remote, branch }: { remote: string; branch: string }): string {
return `${remote}/HEAD names the checked-out branch ${branch}, and no PR base, ${remote}/main or ${remote}/master resolves; pass the base explicitly or run git remote set-head ${remote} <base>`;
}
/**
* Why `base` cannot be the base of a comparison with `head` (BASE_CONTAINS_HEAD), or null. A
* base that is the head or already contains it compares the branch with itself, so the step
* would report no changes. A pull-request head (`head` other than HEAD) is refused whenever the
* base contains it. The checked-out branch is refused only when its real base (defaultBaseBranch)
* is another branch that lacks HEAD, so the branch has commits this base hides; on the base branch
* itself, on a detached HEAD, on a fresh branch with no commits past its base, or with no real base
* resolvable, it is null and the step keeps today's result.
*/
export function baseContainsHead(cwd: string, base: string, head = 'HEAD'): string | null {
if (!ancestor(cwd, head, base)) return null;
const baseSha = commit(cwd, base);
const headSha = commit(cwd, head);
if (!baseSha || !headSha) return null;
const what = `${base} (${baseSha.slice(0, 12)}) ${baseSha === headSha ? 'is' : 'already contains'}`;
if (head !== 'HEAD') return `${what} the head ${headSha.slice(0, 12)}, which then has no changes of its own`;
const branch = read('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd, 30_000);
if (!branch) return null;
const real = defaultBaseBranch(cwd);
if (!real.branch || real.branch === branch) return null;
const realRef = `${real.remote ?? 'origin'}/${real.branch}`;
const realSha = commit(cwd, `refs/remotes/${realRef}`);
if (!realSha || ancestor(cwd, headSha, realSha)) return null;
return `${what} HEAD ${headSha.slice(0, 12)} on ${branch}, whose commits ${realRef} lacks; the real base is ${real.branch}`;
}
if (import.meta.main) {
const [verb, ...args] = process.argv.slice(2);
const cwd = process.cwd();
if (verb === 'default') {
const found = defaultBaseBranch(cwd);
if (found.branch) console.log(found.branch);
else if (found.skipped) console.log(noDefaultBaseLine(found.skipped));
process.exitCode = found.branch ? 0 : found.skipped ? 3 : 1;
} else if (verb === 'check' && args[0] && args.length <= 3) {
const refused = baseContainsHead(cwd, args[0], args[1] || 'HEAD');
if (refused) console.log(resultLine(args[2] || 'gstack', 'BASE_CONTAINS_HEAD', refused));
process.exitCode = refused ? 1 : 0;
} else {
console.error('usage: default-base.ts default | check <base> [<head>] [<context>]');
process.exitCode = 2;
}
}
+13 -13
View File
@@ -13,6 +13,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { defaultBaseBranch, type DefaultBase } from '../default-base';
import type { CommitInfo, FileDiff, FileStatus, Hunk, Unsupported } from './types';
export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
@@ -63,20 +64,19 @@ function remoteNames(cwd: string): string[] {
}
/**
* The remote's HEAD target → `main` → `master` (the gstack-diff-scope chain),
* as a ref name; the remotes are walked in resolveBase's order, so the default
* and the fix line come from the same remote.
* The default base (lib/default-base.ts, the gstack-diff-scope chain) as a ref
* name: a remote's HEAD target as `<remote>/<branch>` unless it names the
* checked-out branch, the PR's base, `main`, `master`; the remotes are walked
* in resolveBase's order, so the default and the fix line come from the same
* remote. With no remote naming one, a local main or master, else `main`.
* `skipped` when a remote HEAD named the checked-out branch and nothing past it
* resolved: the caller fails closed instead of diffing the branch against itself.
*/
export function defaultBase(cwd: string): string {
const remotes = remoteNames(cwd);
for (const remote of remotes) {
const head = gitText(['symbolic-ref', '-q', `refs/remotes/${remote}/HEAD`], cwd);
if (head) return head.replace(/^refs\/remotes\//, '');
}
for (const name of ['main', 'master']) {
if (resolvesToCommit(name, cwd) || remotes.some((remote) => resolvesToCommit(`${remote}/${name}`, cwd))) return name;
}
return 'main';
export function defaultBase(cwd: string): { ref: string } | { skipped: NonNullable<DefaultBase['skipped']> } {
const found = defaultBaseBranch(cwd, remoteNames(cwd));
if (found.branch) return { ref: found.remote ? `${found.remote}/${found.branch}` : found.branch };
if (found.skipped) return { skipped: found.skipped };
return { ref: ['main', 'master'].find((name) => resolvesToCommit(name, cwd)) ?? 'main' };
}
export function isShallow(cwd: string): boolean {
+4 -1
View File
@@ -17,6 +17,7 @@ import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type
import { resolveStateRoot } from '../state-root';
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
import { baseContainsHead } from '../default-base';
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
import { headPolicy } from './head-policy';
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
@@ -48,7 +49,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO
return (r.status === 0 && (r.stdout ?? '').trim()) || 'local';
}
export interface ContextRefusal {
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
code: 'POLICY_SOURCE_UNAVAILABLE' | 'BASE_CONTAINS_HEAD' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
message: string;
/** 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;
@@ -210,6 +211,8 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
const baseRef = `origin/${base}`;
const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
const self = baseContainsHead(o.repoRoot, baseRef);
if (self) return { refused: { code: 'BASE_CONTAINS_HEAD', message: self } };
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
let configLoad: ConfigLoad = loadConfigFromBase(o.repoRoot, base, o.knownChecks);
+5
View File
@@ -254,6 +254,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'no new base head passed the paused cases before gstack-base-red wait’s timeout',
fix: 'run gstack-base-red wait --pause <file> again (the pause stays valid until it expires), or check who owns the base red',
},
BASE_CONTAINS_HEAD: {
anchor: 'base-contains-head',
summary: 'the base is the head or already contains it while the branch has commits its real base lacks, so a base-relative step would compare the branch with itself and report no changes',
fix: 'compare against the branch’s real base (the PR’s base branch, usually main): pass it as the base, or run git remote set-head origin <base> when origin/HEAD names the checked-out branch',
},
QUEUE_RECEIPT_DEPTH_RAISED: {
anchor: 'queue-receipt-depth-raised',
summary: 'the PR body’s /ship receipt records a review depth below the one its head needs (the queue recomputed it from git), or a current receipt records no review_depth at all',
+10 -4
View File
@@ -15,6 +15,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { defaultBaseBranch, noDefaultBaseLine } from './default-base';
import { RESULT_CODES, type ResultCodeName } from './result-codes';
import { cmpVersion, parseVersion } from './version-source';
@@ -635,12 +636,17 @@ function git(repoRoot: string, args: string[]): { ok: boolean; out: string; err:
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
}
/** `--base` when given, else origin/HEAD's branch, else `main`. */
/**
* `--base` when given, else the default base (lib/default-base.ts): origin/HEAD's
* branch unless it is the checked-out one, the PR's base, origin/main, origin/master.
* When none resolves it stays `main`, which origin then lacks, so every reader of
* origin/<base> refuses; when origin/HEAD named the checked-out branch, stderr says why.
*/
export function resolveBaseBranch(repoRoot: string, explicit?: string): string {
if (explicit && explicit.trim()) return explicit.trim().replace(/^origin\//, '');
const r = git(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
if (r.ok && r.out) return r.out.replace(/^origin\//, '');
return 'main';
const found = defaultBaseBranch(repoRoot);
if (!found.branch && found.skipped) process.stderr.write(`gstack: ${noDefaultBaseLine(found.skipped)}\n`);
return found.branch ?? 'main';
}
function finish(rawText: string | null, source: PolicySource): LoadedPolicy {
+5 -2
View File
@@ -19,6 +19,7 @@
*/
import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
import { baseContainsHead } from './default-base';
import type { ShipReceipt } from './headless-artifacts';
import { collectCommit } from './review-collect';
import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth';
@@ -189,12 +190,14 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip
return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` };
}
/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. */
/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. A base that already contains the head refuses (BASE_CONTAINS_HEAD, GST-774) before any depth is computed. */
export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]):
| { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string; targetedLine: string }
| { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } {
| { ok: false; code: ReceiptBindingCode | 'BASE_CONTAINS_HEAD' | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } {
const b = bindReceipt(body, pr, accepted);
if (!b.ok) return b;
const self = baseContainsHead(root, baseRef, b.head);
if (self) return { ok: false, code: 'BASE_CONTAINS_HEAD', message: self, line: b.line };
const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok;
const problems = exceptionProblems(body, b.receipt, ancestor);
if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line };
+280
View File
@@ -0,0 +1,280 @@
/**
* GST-774: a Capy machine checked out at a PR branch has refs/remotes/origin/HEAD naming that branch and
* no local main. The default base must not be the checked-out branch (lib/default-base.ts, the GST-511
* rule), and every base-relative step refuses a base that is HEAD or already contains it while the
* branch has commits its real base lacks (BASE_CONTAINS_HEAD) instead of reporting no changes. The
* control is a normal clone on the same feature branch with origin/HEAD naming main and a local main.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { baseContainsHead, defaultBaseBranch } from '../lib/default-base';
import { buildContext } from '../lib/pregate/context';
import { runScriptSync } from '../lib/script-invocation';
import { extractReceipt } from '../lib/ship-receipt';
import { bindShippedReceipt } from '../lib/ship-receipt-depth';
import { resolveBaseBranch } from '../lib/ship-policy';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = (name: string) => path.join(ROOT, 'bin', name);
const big = Array.from({ length: 80 }, (_, i) => `exports.f${i} = () => ${i};`).join('\n') + '\n';
interface Fx { root: string; origin: string; clone: string; control: string; noMain: string; home: string; failGh: string; releaseGh: string; env: Record<string, string> }
let fx: Fx;
function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = fx.env) {
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...env } });
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed in ${cwd}: ${r.stderr}`);
return (r.stdout ?? '').trim();
}
const git = (cwd: string, ...args: string[]) => run('git', args, cwd);
function bin(name: string, args: string[], cwd: string, extra: Record<string, string> = {}) {
const r = runScriptSync(BIN(name), args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...fx.env, ...extra } });
return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
}
function withEnv<T>(env: Record<string, string>, fn: () => T): T {
const saved = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]]));
Object.assign(process.env, env);
try { return fn(); } finally { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }
}
const stub = (dir: string, body: string) => { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'gh'), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); return dir; };
beforeAll(() => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gst774-')));
expect(spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', timeout: 10_000 }).status).not.toBe(0);
const home = path.join(root, 'home');
fs.mkdirSync(home);
const gitconfig = path.join(root, 'gitconfig');
fs.writeFileSync(gitconfig, '[user]\n\tname = fixture\n\temail = fixture@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
const failGh = stub(path.join(root, 'gh-fail'), 'exit 1');
const releaseGh = stub(path.join(root, 'gh-release'), 'echo release');
const env = { HOME: home, GSTACK_STATE_ROOT: home, GSTACK_HOME: home, GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', PATH: `${failGh}${path.delimiter}${process.env.PATH}` };
fx = { root, origin: path.join(root, 'origin.git'), clone: path.join(root, 'clone'), control: path.join(root, 'control'), noMain: path.join(root, 'nomain'), home, failGh, releaseGh, env };
const seed = path.join(root, 'seed');
run('git', ['init', '-q', '--bare', '-b', 'main', fx.origin], root);
run('git', ['init', '-q', '-b', 'main', seed], root);
fs.mkdirSync(path.join(seed, 'lib'));
fs.writeFileSync(path.join(seed, 'README.md'), '# fixture\n');
fs.writeFileSync(path.join(seed, 'lib', 'app.js'), 'exports.app = () => 1;\n');
git(seed, 'add', '-A');
git(seed, 'commit', '-qm', 'seed');
expect(git(seed, 'log', '-1', '--format=%an <%ae>')).toBe('fixture <fixture@test>');
git(seed, 'remote', 'add', 'origin', fx.origin);
git(seed, 'push', '-q', 'origin', 'main', 'main:release');
git(seed, 'checkout', '-q', '-b', 'pr-branch');
fs.writeFileSync(path.join(seed, 'lib', 'big.js'), big);
git(seed, 'add', '-A');
git(seed, 'commit', '-qm', 'pr change');
git(seed, 'push', '-q', 'origin', 'pr-branch');
run('git', ['clone', '-q', '--branch', 'pr-branch', fx.origin, fx.clone], root);
git(fx.clone, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/pr-branch');
run('git', ['clone', '-q', fx.origin, fx.control], root);
git(fx.control, 'checkout', '-q', '-b', 'pr-branch', 'origin/pr-branch');
const nomainOrigin = path.join(root, 'nomain.git');
run('git', ['clone', '-q', '--bare', fx.origin, nomainOrigin], root);
git(nomainOrigin, 'branch', '-m', 'main', 'trunk');
git(nomainOrigin, 'symbolic-ref', 'HEAD', 'refs/heads/trunk');
run('git', ['clone', '-q', '--branch', 'pr-branch', nomainOrigin, fx.noMain], root);
git(fx.noMain, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/pr-branch');
});
afterAll(() => { if (fx) fs.rmSync(fx.root, { recursive: true, force: true }); });
describe('the fixture is the reported checkout', () => {
// Value: protects=every case below reads the GST-774 shape, outside any checkout and with its own state root; fails_when=the
// clone has a local main or origin/HEAD names main, or state resolves into the operator's root; seam=none
test('the clone is on the PR branch, origin/HEAD names it, there is no local main, and state resolves into the fixture', () => {
expect(git(fx.clone, 'symbolic-ref', '--short', 'HEAD')).toBe('pr-branch');
expect(git(fx.clone, 'symbolic-ref', 'refs/remotes/origin/HEAD')).toBe('refs/remotes/origin/pr-branch');
expect(spawnSync('git', ['rev-parse', '--verify', '--quiet', 'refs/heads/main'], { cwd: fx.clone, timeout: 10_000 }).status).not.toBe(0);
expect(git(fx.clone, 'rev-parse', 'origin/main')).toBe(git(fx.control, 'rev-parse', 'main'));
expect(git(fx.control, 'symbolic-ref', 'refs/remotes/origin/HEAD')).toBe('refs/remotes/origin/main');
expect(git(fx.clone, 'rev-parse', '--show-toplevel')).toBe(fx.clone);
expect(bin('gstack-paths', ['--get', 'GSTACK_STATE_ROOT'], fx.clone).out.trim()).toBe(fx.home);
const slug = bin('gstack-slug', ['--get', 'SLUG'], fx.clone);
expect(slug.status).toBe(0);
expect(slug.out.trim()).not.toBe('');
});
});
describe('resolveBaseBranch skips an origin/HEAD that names the checked-out branch', () => {
// Value: protects=ship-queue, pregate and the receipt writer default to the PR's real base, never the branch itself; fails_when=
// origin/HEAD naming the PR branch is taken as the base; seam=lib/default-base.ts
test('origin/main on the bug checkout, the PR base when gh names one, explicit --base as given; the control is unchanged', () => {
withEnv(fx.env, () => {
expect(resolveBaseBranch(fx.clone)).toBe('main');
expect(resolveBaseBranch(fx.clone, 'pr-branch')).toBe('pr-branch');
expect(resolveBaseBranch(fx.control)).toBe('main');
git(fx.control, 'checkout', '-q', 'main');
try { expect(resolveBaseBranch(fx.control)).toBe('main'); } finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
});
withEnv({ ...fx.env, PATH: `${fx.releaseGh}${path.delimiter}${process.env.PATH}` }, () => expect(resolveBaseBranch(fx.clone)).toBe('release'));
});
});
describe('the review depth ship-queue ready binds', () => {
// Value: protects=a READY on the bug checkout never binds a light depth for an 80-line diff; fails_when=the queue compares the
// PR head with itself and reports required=light runtime_lines=0; seam=bindShippedReceipt
test('origin/<PR branch> refuses BASE_CONTAINS_HEAD; the resolved default base binds standard; the control binds the same', () => {
const body = path.join(fx.root, 'body.md');
const w = bin('gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], fx.clone);
expect(w.status, w.err).toBe(0);
const text = fs.readFileSync(body, 'utf8');
expect(extractReceipt(text).receipt!.review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 });
const head = git(fx.clone, 'rev-parse', 'HEAD');
withEnv(fx.env, () => {
const self = bindShippedReceipt(fx.clone, 'origin/pr-branch', text, '7', [head]);
expect(self.ok).toBe(false);
if (self.ok) return;
expect(self.code as string).toBe('BASE_CONTAINS_HEAD');
expect(self.message).toContain(head.slice(0, 12));
const real = bindShippedReceipt(fx.clone, `origin/${resolveBaseBranch(fx.clone)}`, text, '7', [head]);
expect(real.ok && real.depthLine).toContain('receipt=standard required=standard runtime_lines=80 class=code');
const control = bindShippedReceipt(fx.control, `origin/${resolveBaseBranch(fx.control)}`, text, '7', [head]);
expect(control.ok && control.depthLine).toContain('receipt=standard required=standard runtime_lines=80 class=code');
});
}, 120_000);
});
describe('gstack-review-plan', () => {
// Value: protects=/ship's review plan never plans light for a diff it compared with itself; fails_when=--base <PR branch> plans
// REVIEW_DEPTH light runtime_lines=0; seam=bin/gstack-review-plan
test('--base <PR branch> refuses; --base main plans the same depth on the bug checkout and the control; main itself plans an empty diff', () => {
const self = bin('gstack-review-plan', ['--base', 'pr-branch'], fx.clone);
expect(self.status).toBe(1);
expect(self.err).toContain('(BASE_CONTAINS_HEAD)');
const depth = (out: string) => /^REVIEW_DEPTH: .*$/m.exec(out)?.[0];
const real = bin('gstack-review-plan', ['--base', 'main'], fx.clone);
expect(real.status, real.err).toBe(0);
expect(depth(real.out)).toContain('REVIEW_DEPTH: standard runtime_lines=80');
const control = bin('gstack-review-plan', ['--base', 'main'], fx.control);
expect(depth(control.out)).toBe(depth(real.out));
git(fx.control, 'checkout', '-q', 'main');
try {
const onMain = bin('gstack-review-plan', ['--base', 'main'], fx.control);
expect(onMain.status, onMain.err).toBe(0);
expect(depth(onMain.out)).toContain('runtime_lines=0');
} finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
}, 120_000);
});
describe('the pregate base and the sealed policy base', () => {
// Value: protects=the pregate selects the PR's files and reads the base branch's policy, never the PR branch's; fails_when=the
// default base is the PR branch, so 0 files are touched and the policy is the PR's own; seam=buildContext, cmdSealed
const load = (cwd: string, base: string) => buildContext({ repoRoot: cwd, base, policyFrom: 'base', allowRepoCommands: false, cores: 64, testBackend: 'local', env: { ...process.env, ...fx.env }, progress: () => {} });
test('the default base is main with the PR file touched; an explicit PR-branch base refuses; the control and main itself are unchanged', () => {
withEnv(fx.env, () => {
const bug = load(fx.clone, resolveBaseBranch(fx.clone));
expect('ctx' in bug && { base: bug.ctx.base, touched: bug.ctx.touched.map(t => t.path) }).toEqual({ base: 'main', touched: ['lib/big.js'] });
const self = load(fx.clone, 'pr-branch');
expect('refused' in self && (self.refused.code as string)).toBe('BASE_CONTAINS_HEAD');
const control = load(fx.control, resolveBaseBranch(fx.control));
expect('ctx' in control && { base: control.ctx.base, touched: control.ctx.touched.map(t => t.path) }).toEqual({ base: 'main', touched: ['lib/big.js'] });
git(fx.control, 'checkout', '-q', 'main');
try {
const onMain = load(fx.control, resolveBaseBranch(fx.control));
expect('ctx' in onMain && onMain.ctx.touched).toEqual([]);
} finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
});
}, 120_000);
test('sealed reads the policy from origin/main by default and refuses an explicit PR-branch base', () => {
const bug = bin('gstack-pregate', ['sealed'], fx.clone);
expect(bug.status, bug.err).toBe(0);
expect(bug.out).toContain('policy=no sealed key on refs/remotes/origin/main or HEAD');
const self = bin('gstack-pregate', ['sealed', '--base', 'pr-branch'], fx.clone);
expect(self.status).not.toBe(0);
expect(self.err).toContain('(BASE_CONTAINS_HEAD)');
const control = bin('gstack-pregate', ['sealed'], fx.control);
expect(control.out).toBe(bug.out);
}, 120_000);
});
describe('gstack-gate-diff and gstack-diff-scope defaults', () => {
// Value: protects=the gate scan and the scope flags read the PR's diff on the bug checkout; fails_when=the default base is the
// PR branch, so the scan lists nothing and every scope flag is false with exit 0; seam=lib/gate-diff/git.ts, bin/gstack-diff-scope
test('gate-diff: the default diffs against origin/main like the control; the PR branch refuses; no resolvable default fails closed', () => {
const summary = (out: string) => /^GATE_SUMMARY: .*$/m.exec(out)?.[0];
const bug = bin('gstack-gate-diff', [], fx.clone);
expect(bug.status, bug.out + bug.err).toBe(0);
const control = bin('gstack-gate-diff', [], fx.control);
expect(control.status, control.out + control.err).toBe(0);
expect(summary(bug.out)).toBe(summary(control.out));
expect(summary(bug.out)).toContain('unmatched=1');
const self = bin('gstack-gate-diff', ['pr-branch'], fx.clone);
expect(self.status).toBe(2);
expect(self.out).toStartWith('GATE_ERROR=base_contains_head ref=pr-branch');
const none = bin('gstack-gate-diff', [], fx.noMain);
expect(none.status).toBe(2);
expect(none.out).toStartWith('GATE_ERROR=no_base ref=origin/HEAD target=origin/pr-branch');
}, 120_000);
test('diff-scope: the default reads the PR file like the control; the PR branch refuses; main and a fresh branch keep today\'s output', () => {
const bug = bin('gstack-diff-scope', [], fx.clone);
expect(bug.status, bug.out + bug.err).toBe(0);
expect(bug.out).toContain('SCOPE_BACKEND=true');
const control = bin('gstack-diff-scope', [], fx.control);
expect(control.out).toBe(bug.out);
const self = bin('gstack-diff-scope', ['pr-branch'], fx.clone);
expect(self.status).toBe(2);
expect(self.out).toContain('SCOPE_ERROR=base_contains_head');
const none = bin('gstack-diff-scope', [], fx.noMain);
expect(none.status).toBe(2);
expect(none.out).toContain('SCOPE_ERROR=no_base');
git(fx.control, 'checkout', '-q', 'main');
try {
const onMain = bin('gstack-diff-scope', [], fx.control);
expect(onMain.status).toBe(0);
expect(onMain.out).not.toContain('=true');
git(fx.control, 'checkout', '-q', '-b', 'fresh');
fs.writeFileSync(path.join(fx.control, 'lib', 'app.js'), 'exports.app = () => 2;\n');
const fresh = bin('gstack-diff-scope', ['main'], fx.control);
expect(fresh.status, fresh.out).toBe(0);
expect(fresh.out).toContain('SCOPE_BACKEND=true');
} finally { git(fx.control, 'checkout', '-q', '--', 'lib/app.js'); git(fx.control, 'checkout', '-q', 'pr-branch'); }
}, 120_000);
});
describe('the docs-audit base', () => {
// Value: protects=/ship's documentation audit selects the PR's files; fails_when=the snapshot's base is the PR head, so it
// selects nothing and every later freshness check passes; seam=bin/gstack-docs-candidate snapshot
test('snapshot refuses a base that is HEAD; the merge base with origin/main selects the PR file like the control', () => {
const snap = (cwd: string, base: string, name: string) => bin('gstack-docs-candidate', ['snapshot', '--out', path.join(fx.root, `${name}.json`), '--audit-id', name, '--mode', 'read-only', '--base', base], cwd);
const self = snap(fx.clone, git(fx.clone, 'rev-parse', 'origin/pr-branch'), 'self');
expect(self.status).not.toBe(0);
expect(self.err).toContain('(BASE_CONTAINS_HEAD)');
const real = snap(fx.clone, git(fx.clone, 'merge-base', 'origin/main', 'HEAD'), 'real');
expect(real.status, real.err).toBe(0);
expect(real.out).toContain('committed: "lib/big.js"');
const control = snap(fx.control, git(fx.control, 'merge-base', 'origin/main', 'HEAD'), 'control');
expect(control.out.split('\n').slice(2).join('\n')).toBe(real.out.split('\n').slice(2).join('\n'));
}, 120_000);
});
describe('baseContainsHead keeps today\'s result where the branch is not compared with itself', () => {
// Value: protects=the guard refuses only a self-comparison and never the base branch, a detached checkout or a fresh branch;
// fails_when=the refusal fires on main, on a CI-style detached HEAD or on a branch with only uncommitted work; seam=lib/default-base.ts
test('null on main, detached at the PR head and on a fresh branch; a PR head the base contains refuses; no default resolves past a skipped origin/HEAD', () => {
withEnv(fx.env, () => {
const head = git(fx.clone, 'rev-parse', 'HEAD');
expect(baseContainsHead(fx.clone, 'origin/pr-branch')).toContain('on pr-branch, whose commits origin/main lacks; the real base is main');
expect(baseContainsHead(fx.clone, 'origin/main')).toBeNull();
expect(baseContainsHead(fx.clone, 'origin/pr-branch', head)).toContain(`the head ${head.slice(0, 12)}`);
expect(baseContainsHead(fx.clone, 'origin/main', head)).toBeNull();
git(fx.clone, 'checkout', '-q', '--detach', head);
try { expect(baseContainsHead(fx.clone, 'origin/pr-branch')).toBeNull(); } finally { git(fx.clone, 'checkout', '-q', 'pr-branch'); }
git(fx.control, 'checkout', '-q', 'main');
try {
expect(baseContainsHead(fx.control, 'origin/main')).toBeNull();
git(fx.control, 'checkout', '-q', '-b', 'fresh-2');
expect(baseContainsHead(fx.control, 'origin/main')).toBeNull();
expect(bin('gstack-review-plan', ['--base', 'main'], fx.control).status).toBe(0);
} finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
expect(defaultBaseBranch(fx.noMain)).toEqual({ branch: null, skipped: { remote: 'origin', branch: 'pr-branch' } });
expect(defaultBaseBranch(fx.control)).toEqual({ branch: 'main', remote: 'origin' });
});
}, 120_000);
});
+1
View File
@@ -329,6 +329,7 @@ describe('parity with the measured prototype', () => {
'lib/bun-isolation.ts': 'GSTA-56: every bun launcher (script-invocation, restamp, qa-deadline, the hooks) imports it, so it reaches every consumer of those launchers',
'lib/ship-receipt-docs.ts': 'bin/gstack-ship-receipt imports it (the docs audit record and doc caps), so it reaches every consumer of the receipt binary',
'lib/ship-receipt-prose.ts': 'bin/gstack-ship-receipt imports it (read --check-prose), so it reaches every consumer of the receipt binary',
'lib/default-base.ts': 'GST-511/GST-774: lib/ship-policy.ts (resolveBaseBranch), lib/gate-diff/git.ts, lib/pregate/context.ts, lib/ship-receipt-depth.ts and the review-plan, gate-diff, pregate, docs-candidate and diff-scope bins import or run it, so it reaches every consumer of those readers',
};
test('the shipped map equals or is a reviewed superset of the prototype map on this checkout', () => {
+2 -2
View File
@@ -45,7 +45,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'scripts/resolvers/review-scope.ts', 'scripts/resolvers/review-army.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/index.ts',
'scripts/resolvers/types.ts', 'scripts/gen-skill-docs.ts', 'scripts/host-config.ts', 'scripts/discover-skills.ts', 'hosts/claude.ts', 'hosts/index.ts', 'hosts/define-host.ts',
'ship/sections/review-army.md*', 'ship/sections/manifest.json', 'review/checklist.md',
'bin/gstack-review-scope', 'lib/review-scope.ts', 'lib/ship-policy.ts', 'lib/result-codes-release.ts',
'bin/gstack-review-scope', 'lib/review-scope.ts', 'lib/ship-policy.ts', 'lib/default-base.ts', 'lib/result-codes-release.ts',
'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh',
'lib/review-evidence.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
'test/helpers/ship-frozen-scope-actor.ts', 'test/helpers/scratch-repo.ts',
@@ -140,7 +140,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'review-enum-completeness': [ 'review/**', 'test/fixtures/review-eval-enum*.rb', 'test/skill-e2e-review.test.ts',
'test/fixtures/fake-impeccable.ts', 'test/helpers/fake-impeccable.ts'],
'review-base-branch': [ 'review/**', 'test/skill-e2e-review-attribution.test.ts'],
'review-gate-integrity': [ 'review/**', 'lib/gate-diff/**', 'bin/gstack-gate-diff', 'scripts/resolvers/gate-scan.ts',
'review-gate-integrity': [ 'review/**', 'lib/gate-diff/**', 'lib/default-base.ts', 'bin/gstack-gate-diff', 'scripts/resolvers/gate-scan.ts',
'scripts/gate-diff-calibrate.ts', 'test/fixtures/gate-diff/**', 'test/helpers/gate-diff-repo.ts',
'scripts/question-registry.ts', 'test/skill-e2e-gate-integrity.test.ts', 'test/helpers/skill-fixture.ts', 'test/helpers/gate-disposition.ts'],
'review-design-lite': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'test/fixtures/review-eval-design-slop.*', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'scripts/resolvers/design-checklist.ts', 'scripts/resolvers/review-army.ts', 'test/skill-e2e-review.test.ts'