diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope
index 275bee55d..d0a8d3191 100755
--- a/bin/gstack-diff-scope
+++ b/bin/gstack-diff-scope
@@ -5,6 +5,9 @@
# Or: gstack-diff-scope --head → the committed range ... only
# (no working tree, no untracked files): what a receipt writer and the
# merge queue compute for one commit, from any checkout (GSTA-147).
+# Or: gstack-diff-scope --head --merged → the same for a head GitHub
+# already merged into the base (land resuming a true merge): the empty range
+# is the answer, so the base-contains-head refusal below does not apply.
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
@@ -15,7 +18,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,16 +33,30 @@
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, its upstream or HEAD's commit (a PR flow's clone can point
+# it at the PR branch, GST-511, GST-774) -> the PR's base (gh, 5 s) -> the
+# remote's live HEAD (ls-remote, 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=""
+MERGED_HEAD=false
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
+if [ -n "$HEAD_REF" ] && [ "${4:-}" = "--merged" ]; then MERGED_HEAD=true; fi
_twin=""
case "$BASE" in
HEAD|refs/*|*[~^@:]*) ;;
@@ -74,16 +95,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 [ "$MERGED_HEAD" = false ] && 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=()
diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate
index 517541019..a7e04e6ed 100755
--- a/bin/gstack-docs-candidate
+++ b/bin/gstack-docs-candidate
@@ -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);
diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh
index e22cf54e5..d0cbf16e3 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -306,6 +306,7 @@ gstack_result_anchor() {
BASE_RED_REPRO_ERROR) printf '%s' 'base-red-repro-error' ;;
BASE_RED_PAUSE_EXPIRED) printf '%s' 'base-red-pause-expired' ;;
BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;;
+ BASE_CONTAINS_HEAD) printf '%s' 'base-contains-head' ;;
QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff
index 3d817a385..8adc74bdd 100755
--- a/bin/gstack-gate-diff
+++ b/bin/gstack-gate-diff
@@ -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=[ fix=` 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=][ fix=` as the only stdout line;
+ * `ref=/HEAD target=/` 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=][`, 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} \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 });
diff --git a/bin/gstack-pregate b/bin/gstack-pregate
index 772bec1b1..dc47b67e2 100755
--- a/bin/gstack-pregate
+++ b/bin/gstack-pregate
@@ -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, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
@@ -70,7 +71,8 @@ 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 a branch on origin, not a commit (default: origin/HEAD's branch, else main)
+ --base 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)
@@ -267,6 +269,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)
diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan
index 21c8cb173..296cdb250 100755
--- a/bin/gstack-review-plan
+++ b/bin/gstack-review-plan
@@ -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 { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude';
@@ -106,6 +110,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 => {
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue
index 738acc9c2..2b503b0fe 100755
--- a/bin/gstack-ship-queue
+++ b/bin/gstack-ship-queue
@@ -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 (default origin/HEAD, else main) and
+ * Every subcommand takes --base (default lib/default-base.ts) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=.
* 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 : base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
+--base : 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()}`;
diff --git a/changelog.d/capy-refuse-a-base-that.md b/changelog.d/capy-refuse-a-base-that.md
new file mode 100644
index 000000000..cd9c3222c
--- /dev/null
+++ b/changelog.d/capy-refuse-a-base-that.md
@@ -0,0 +1 @@
+- On a checkout whose `origin/HEAD` points at the checked-out branch itself (a PR-branch clone), every base-relative step resolved the base to HEAD and reported zero changes, so review depth, `ready`, pregate and the docs audit passed on an empty diff (GST-774). Base resolution now skips a remote HEAD that names the checked-out branch, its upstream or HEAD's own commit (so a local branch under another name and a detached checkout of the PR branch are caught too), then uses the PR's base ref when `gh` finds one, then the remote's live default branch from `git ls-remote --symref`, then `main` or `master`. A repository whose default branch is `develop` or `trunk` keeps it. When the resolved base equals HEAD or contains it while the branch has commits its real base lacks, `gstack-diff-scope`, `gstack-gate-diff`, `gstack-review-plan`, `gstack-pregate`, `gstack-docs-candidate`, `/ship` policy base resolution and the receipt's depth binding refuse with `BASE_CONTAINS_HEAD` instead of reporting no changes. A pull request's head is refused whenever the base contains it; on the base branch itself, on a fresh branch with only uncommitted work, and on a detached HEAD given an explicit base, the step runs as before. `gstack-ship-queue land` resuming a merge-only PR that GitHub already merged with a true merge commit still lands, its depth check included. See docs/troubleshooting.md#base-contains-head.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index d783bc2a2..fa7bea642 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2359,7 +2359,10 @@ default `fetch-depth: 1`), a remote branch never fetched
first parent is missing from a shallow history. The tool never guesses a
base, because an empty diff against a wrong or absent base would read as "no
gate edits" and pass review. `gstack-diff-scope` prints `SCOPE_ERROR=no_base`
-(all flags false, exit 2) for the same cause.
+(all flags false, exit 2) for the same cause, and also when no base was given
+and none resolves because `origin/HEAD` names the checked-out branch (or its
+upstream, or HEAD's own commit) and no PR base, live remote HEAD, `origin/main`
+or `origin/master` resolves (its comment line says so).
**Fix.** Run the command the line prints. It is built for this checkout:
`git fetch origin +:refs/remotes/origin/` for an unfetched
@@ -5420,6 +5423,29 @@ wait`'s `--timeout`.
**Fix.** Run `wait` again (the pause stays valid until it expires), or find who
owns the base's red.
+
+### `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 the remote's live HEAD from
+`git ls-remote --symref`, 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. `gstack-diff-scope` reports it as
+`SCOPE_ERROR=base_contains_head` (all flags false, exit 2) with the reason on a
+comment line.
+
+**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.
+
### `QUEUE_RECEIPT_DEPTH_RAISED`
diff --git a/lib/default-base.ts b/lib/default-base.ts
new file mode 100644
index 000000000..b1cf27a6d
--- /dev/null
+++ b/lib/default-base.ts
@@ -0,0 +1,111 @@
+/**
+ * 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, its
+ * upstream, or HEAD's own commit: 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, then the remote's live HEAD (`git ls-remote --symref`,
+ * which keeps a default branch such as develop or trunk), then `/main`, then
+ * `/master`. Both network reads use a 5 s timeout with prompts off and ignore any
+ * failure; gh names no base for a PR from another repository. 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\//, '');
+ const upstream = current ? read('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], cwd, 30_000) : '';
+ const headSha = commit(cwd, 'HEAD');
+ 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 && upstream !== `${remote}/${branch}` && commit(cwd, target) !== headSha) 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 };
+ if (skipped) {
+ const live = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(read('git', ['ls-remote', '--symref', skipped.remote, 'HEAD'], cwd, 5_000))?.[1];
+ if (live) return { branch: live, remote: skipped.remote, 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} `;
+}
+
+/**
+ * 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 [] []');
+ process.exitCode = 2;
+ }
+}
diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts
index 37910adef..dbf6e6fbb 100644
--- a/lib/gate-diff/git.ts
+++ b/lib/gate-diff/git.ts
@@ -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 `/` 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 } {
+ 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 {
diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts
index b745cb5fc..c9b5038c0 100644
--- a/lib/pregate/context.ts
+++ b/lib/pregate/context.ts
@@ -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);
diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts
index 544973e0f..5079f74d5 100644
--- a/lib/result-codes-release.ts
+++ b/lib/result-codes-release.ts
@@ -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 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 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',
diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts
index a53ef5774..b57039ccb 100644
--- a/lib/ship-policy.ts
+++ b/lib/ship-policy.ts
@@ -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';
@@ -651,12 +652,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/ 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';
}
export function finish(rawText: string | null, source: PolicySource): LoadedPolicy {
diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts
index dd8d22d31..62c3059a7 100644
--- a/lib/ship-queue-land.ts
+++ b/lib/ship-queue-land.ts
@@ -338,7 +338,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
const pin = j?.reviewed_pin ?? recovered ?? info.headRefOid;
const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`];
git(root, ['fetch', '-q', 'origin', ctx.base]);
- const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin]);
+ const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin], mergedResume);
lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []));
if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: 'run /ship at the pinned head first' }, run);
if (!policy.changelog_fragments) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'the base policy has changelog_fragments off; land merges only under the key, where ready records the READY receipt it checks' }, run);
diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts
index 6d0aacb56..835e97ea7 100644
--- a/lib/ship-receipt-depth.ts
+++ b/lib/ship-receipt-depth.ts
@@ -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 { reviewExclude, type Exclusion } from './review-exclude';
@@ -43,9 +44,9 @@ function git(root: string, args: string[]): { ok: boolean; out: string } {
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
}
-/** gstack-diff-scope on the committed range `base`...`head` (`--head`): the same signals at write, carry and the queue. */
-function scopeOfRange(root: string, binDir: string, base: string, head: string): Record {
- const sr = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base, '--head', head], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
+/** gstack-diff-scope on the committed range `base`...`head` (`--head`): the same signals at write, carry and the queue; `merged` (`--merged`) for a head GitHub already merged into the base. */
+function scopeOfRange(root: string, binDir: string, base: string, head: string, merged = false): Record {
+ const sr = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base, '--head', head, ...(merged ? ['--merged'] : [])], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
const scope: Record = {};
for (const l of (sr.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; }
if (sr.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${sr.status}`;
@@ -192,12 +193,12 @@ export interface DepthVerdict { ok: boolean; line: string; message?: string }
/** The head's committed range against the base, read once for the depth verdict and the targeted line. */
interface HeadInputs { baseSha: { ok: boolean; out: string }; c: ReturnType; scope: Record; x: Exclusion }
-function headInputs(root: string, baseRef: string, head: string, r?: ShipReceipt): HeadInputs {
+function headInputs(root: string, baseRef: string, head: string, r?: ShipReceipt, merged = false): HeadInputs {
const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
const baseRev = baseSha.ok ? baseSha.out : baseRef;
const c = collectCommit(root, baseRev, head);
const tagged = r && boundScan(r, head) !== null ? new Set(r.review_exclude?.gate_tagged ?? []) : null;
- return { baseSha, c, scope: scopeOfRange(root, BIN_DIR, baseRev, head), x: exclusionAt(root, baseRef, baseSha.ok ? baseSha.out : null, head, c, tagged, 'gstack-ship-queue') };
+ return { baseSha, c, scope: scopeOfRange(root, BIN_DIR, baseRev, head, merged), x: exclusionAt(root, baseRef, baseSha.ok ? baseSha.out : null, head, c, tagged, 'gstack-ship-queue') };
}
/**
@@ -242,16 +243,18 @@ 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. */
-export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]):
+/** 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, except for a PR GitHub already merged (`merged`: land resuming after a merge-only true merge), whose depth then reads the range with diff-scope's `--merged`. */
+export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[], merged = false):
| { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string; targetedLine: string }
- | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID' | 'RECEIPT_INVALID' | Extract['code']; message: string; line: string; depthLine?: string; targetedLine?: string } {
+ | { ok: false; code: ReceiptBindingCode | 'BASE_CONTAINS_HEAD' | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID' | 'RECEIPT_INVALID' | Extract['code']; message: string; line: string; depthLine?: string; targetedLine?: string } {
const b = bindReceipt(body, pr, accepted);
if (!b.ok) return b;
+ const self = merged ? null : 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 };
- const at = headInputs(root, baseRef, b.head, b.receipt);
+ const at = headInputs(root, baseRef, b.head, b.receipt, merged);
if (at.x.state === 'refused') return { ok: false, code: at.x.code, message: at.x.lines.join('; '), line: b.line };
const mismatch = excludeMismatch(at.x, b.receipt, b.head, baseRef);
if (mismatch) return { ok: false, code: 'RECEIPT_INVALID', message: mismatch, line: b.line };
diff --git a/test/default-base.test.ts b/test/default-base.test.ts
new file mode 100644
index 000000000..450c83a61
--- /dev/null
+++ b/test/default-base.test.ts
@@ -0,0 +1,320 @@
+/**
+ * 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; alias: string; trunk: string; develop: string; home: string; failGh: string; releaseGh: string; env: Record }
+let fx: Fx;
+
+function run(cmd: string, args: string[], cwd: string, env: Record = 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 = {}) {
+ 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(env: Record, 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'), alias: path.join(root, 'alias'), trunk: path.join(root, 'trunk'), develop: path.join(root, 'develop'), 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 ');
+ 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');
+ git(fx.noMain, 'remote', 'set-url', 'origin', path.join(root, 'offline.git'));
+ run('git', ['clone', '-q', nomainOrigin, fx.trunk], root);
+
+ run('git', ['clone', '-q', fx.origin, fx.alias], root);
+ git(fx.alias, 'checkout', '-q', '-b', 'work', '--track', 'origin/pr-branch');
+ git(fx.alias, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/pr-branch');
+
+ const developOrigin = path.join(root, 'develop.git');
+ run('git', ['clone', '-q', '--bare', fx.origin, developOrigin], root);
+ git(developOrigin, 'branch', 'develop', 'pr-branch');
+ git(developOrigin, 'symbolic-ref', 'HEAD', 'refs/heads/develop');
+ run('git', ['clone', '-q', developOrigin, fx.develop], root);
+});
+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/ 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 plans
+ // REVIEW_DEPTH light runtime_lines=0; seam=bin/gstack-review-plan
+ test('--base 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);
+});
+
+describe('a remote HEAD naming the branch under another name is skipped; a real default branch is kept', () => {
+ // Value: protects=a local branch tracking the PR branch, one cut from its tip, and a detached checkout of it never take the PR
+ // branch as their base, while a repository whose default is develop or trunk keeps it; fails_when=the skip compares names only,
+ // so a differently named checkout reads the PR against itself (an empty diff with exit 0), or the skip falls through to a
+ // stale main or to no base on the default branch itself; why_new=the outside voice and the adversarial pass reproduced both;
+ // seam=lib/default-base.ts
+ test('upstream, same commit and detached checkouts resolve main; develop and trunk resolve from the remote\'s live HEAD', () => {
+ withEnv(fx.env, () => {
+ expect(defaultBaseBranch(fx.alias).branch).toBe('main');
+ expect(baseContainsHead(fx.alias, 'origin/pr-branch')).toContain('on work, whose commits origin/main lacks; the real base is main');
+ const aliased = bin('gstack-diff-scope', [], fx.alias);
+ expect(aliased.status, aliased.out).toBe(0);
+ expect(aliased.out).toContain('SCOPE_BACKEND=true');
+ git(fx.alias, 'checkout', '-q', '--no-track', '-b', 'work-2', 'origin/pr-branch');
+ try { expect(defaultBaseBranch(fx.alias).branch).toBe('main'); } finally { git(fx.alias, 'checkout', '-q', 'work'); }
+ git(fx.alias, 'checkout', '-q', '--detach', 'origin/pr-branch');
+ try { expect(defaultBaseBranch(fx.alias).branch).toBe('main'); } finally { git(fx.alias, 'checkout', '-q', 'work'); }
+ expect(defaultBaseBranch(fx.trunk)).toEqual({ branch: 'trunk', remote: 'origin', skipped: { remote: 'origin', branch: 'trunk' } });
+ expect(defaultBaseBranch(fx.develop).branch).toBe('develop');
+ for (const cwd of [fx.trunk, fx.develop]) {
+ const onDefault = bin('gstack-diff-scope', [], cwd);
+ expect(onDefault.status, onDefault.out).toBe(0);
+ expect(onDefault.out).not.toContain('=true');
+ }
+ });
+ }, 120_000);
+});
diff --git a/test/egress-receipt-wiring.test.ts b/test/egress-receipt-wiring.test.ts
index fecf4767a..4f26859a5 100644
--- a/test/egress-receipt-wiring.test.ts
+++ b/test/egress-receipt-wiring.test.ts
@@ -155,6 +155,8 @@ const SCANNER_EXEMPT: Record = {
'git fetch appears only in an error-message string',
'bin/gstack-worktree-audit':
'read-only audit: best-effort fetch of the user\'s own repo\'s base branch so the MERGED column is current (skipped with --no-fetch) — a user-repo dev-workflow op like gstack-next-version\'s, not gstack-state egress',
+ 'lib/default-base.ts':
+ 'read-only `git ls-remote --symref HEAD` of the user\'s own repo (5 s timeout, prompts off) to learn its live default branch, run only when the cached origin/HEAD names the checked-out branch (GST-774) — a user-repo dev-workflow op like gstack-next-version\'s, not gstack-state egress',
'bin/gstack-state-matrix':
'git fetch appears only in an error-message string (the fix the user runs when origin/ has no merge base)',
'bin/gstack-redact-prepush':
diff --git a/test/exercised-map.test.ts b/test/exercised-map.test.ts
index e3967fce9..eec13e115 100644
--- a/test/exercised-map.test.ts
+++ b/test/exercised-map.test.ts
@@ -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', () => {
diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts
index 7b7eaee35..7a4f00695 100644
--- a/test/helpers/touchfiles-data.ts
+++ b/test/helpers/touchfiles-data.ts
@@ -45,7 +45,7 @@ export const E2E_TOUCHFILES: Record = {
'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 = {
'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'
diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts
index 4b8fabfb8..806a0259f 100644
--- a/test/ship-queue-land.test.ts
+++ b/test/ship-queue-land.test.ts
@@ -14,9 +14,11 @@ import { EXIT } from '../lib/headless-artifacts';
import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, runBin, type FixtureRepo } from './helpers/restamp-fixture';
import { shippedBody } from './helpers/ship-queue-train-fixture';
import { resolveReleaseConflicts } from '../lib/ship-queue-land';
+import { receiptDepthAtHead } from '../lib/ship-receipt-depth';
import { mergePolicy } from '../lib/ship-policy';
import { persist, repoIdentity } from '../lib/ship-queue-train-record';
+const ROOT = path.resolve(import.meta.dir, '..');
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
@@ -47,7 +49,7 @@ function setup(policy: Record = POLICY): Fx {
" if(head!==want){console.error('Head branch was modified');process.exit(1)}",
" if(d.mergeRefuse){console.error('Pull request is not mergeable: the base branch policy prohibits the merge');process.exit(1)}",
" if(d.driftOnMerge){const m=g('rev-parse','refs/heads/main');const t=g('rev-parse',m+'^{tree}');g('update-ref','refs/heads/main',g('commit-tree',t,'-p',m,'-m','outside merge'));d.driftOnMerge=false}",
- " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);if(d.outsideAfter){g('update-ref','refs/heads/main',g('commit-tree',g('rev-parse',sq+'^{tree}'),'-p',sq,'-m','outside after'));d.outsideAfter=false}d.mergeArgs=a.slice(3);p.state='MERGED';save();if(d.mergeErrorAfter){d.mergeErrorAfter=false;save();console.error('HTTP 502: Bad Gateway');process.exit(1)}process.exit(0)}",
+ " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,...(d.trueMerge&&a.includes('--merge')?['-p',want]:[]),'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);if(d.outsideAfter){g('update-ref','refs/heads/main',g('commit-tree',g('rev-parse',sq+'^{tree}'),'-p',sq,'-m','outside after'));d.outsideAfter=false}d.mergeArgs=a.slice(3);p.state='MERGED';save();if(d.mergeErrorAfter){d.mergeErrorAfter=false;save();console.error('HTTP 502: Bad Gateway');process.exit(1)}process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='create'&&d.createFail){d.createFail=false;save();console.error('timeout');process.exit(1)}",
"if(a[0]==='pr'&&a[1]==='create'){const h=a[a.indexOf('--head')+1];const had=Object.entries(d.prs).find(([,p])=>p.headRefName===h&&p.state==='OPEN');if(had){console.error('a pull request for branch \"'+h+'\" into branch \"main\" already exists:\\nhttps://github.com/o/r/pull/'+had[0]);process.exit(1)}}",
"if(a[0]==='pr'&&a[1]==='create'){const n=String(Math.max(0,...Object.keys(d.prs).map(Number))+1);d.prs[n]={state:'OPEN',headRefName:a[a.indexOf('--head')+1],isCrossRepository:false,title:a[a.indexOf('--title')+1],body:''};save();console.log('https://github.com/o/r/pull/'+n);process.exit(0)}",
@@ -562,6 +564,25 @@ describe('land: one pass, base, lock and history', () => {
expect(r.out).toContain('LANDED: ');
expect(JSON.parse(fs.readFileSync(fx.data, 'utf8')).mergeArgs).toContain('--merge');
}, 90_000);
+
+ // Value: protects=a merge-only land interrupted after GitHub merged resumes to LANDED, though a true merge puts the pin inside
+ // the base; fails_when=the receipt check refuses the merged pin as BASE_CONTAINS_HEAD, or its depth check reads the empty
+ // range as a scope error and raises the depth, and the lock is never released; why_new=GST-774's base guard, and the shim
+ // only squashed; seam=none; no_claim=GitHub's merge-commit shape beyond two parents
+ test('merge-only: a true merge that errored after GitHub merged resumes to LANDED with a depth-bearing receipt', () => {
+ const fx = setup({ ...POLICY, history: 'merge-only' });
+ const head = openPr(fx, '14');
+ const depth = receiptDepthAtHead(fx.repo.work, 'main', path.join(ROOT, 'bin'), head);
+ expect(depth.depth).toBe('light');
+ fx.write(d => { d.prs['14'].body = shippedBody('14', head, { gstack_version: '1.91.400.0', review_depth: depth }); });
+ land(fx, ['--pr', '14']);
+ fx.write(d => { d.trueMerge = true; d.mergeErrorAfter = true; });
+ expect(land(fx, ['--pr', '14']).err).toContain('HTTP 502');
+ const again = land(fx, ['--pr', '14']);
+ expect(again.out + again.err).not.toMatch(/BASE_CONTAINS_HEAD|QUEUE_RECEIPT_DEPTH_RAISED/);
+ expect(again.out).toMatch(/^REVIEW_DEPTH: receipt=light required=light /m);
+ expect(again.out).toContain('LANDED: ');
+ }, 90_000);
});
// /ship adversarial (Claude) and outside-voice (Codex) findings (GSTA-154 PR A): each case failed or was unreachable before its fix.
]