diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope
index aaf378203..7b0aca46c 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"):
@@ -31,8 +34,9 @@ set -euo pipefail
# Detect the repo's default branch when no arg is given (#703-class
# 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.
+# 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
@@ -50,7 +54,9 @@ if [ -z "$BASE" ]; then
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
FRONTEND=false
BACKEND=false
@@ -108,7 +114,7 @@ fi
# 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
+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
diff --git a/changelog.d/capy-refuse-a-base-that.md b/changelog.d/capy-refuse-a-base-that.md
index e2a591599..cd9c3222c 100644
--- a/changelog.d/capy-refuse-a-base-that.md
+++ b/changelog.d/capy-refuse-a-base-that.md
@@ -1 +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 and uses the PR's base ref when `gh` finds one, then `main` or `master`. Whatever the cause, `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 when the resolved base equals HEAD or contains it. For a checkout the refusal is narrowed to a base that contains HEAD while the real base does not, so a fresh branch with only uncommitted work still runs as before; a PR head is refused whenever the base contains it. `gstack-ship-queue land` resuming a merge-only PR that GitHub already merged with a true merge commit still lands instead of hitting the new refusal. See docs/troubleshooting.md#base-contains-head.
+- 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 5fad84364..889034511 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2311,8 +2311,9 @@ 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, and also when no base was given
-and none resolves because `origin/HEAD` names the checked-out branch and no PR
-base, `origin/main` or `origin/master` exists (its comment line says so).
+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
@@ -5223,7 +5224,8 @@ 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
+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
diff --git a/lib/default-base.ts b/lib/default-base.ts
index a1607eede..b1cf27a6d 100644
--- a/lib/default-base.ts
+++ b/lib/default-base.ts
@@ -1,12 +1,14 @@
/**
* 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 `/main`, then `/master`. A detached HEAD keeps the
- * remote's HEAD. bin/gstack-diff-scope reads both through this file's command line.
+ * 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';
@@ -36,17 +38,23 @@ const ancestor = (cwd: string, a: string, b: string) => spawnSync('git', ['merge
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) return { branch, remote };
+ 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 };
}
diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts
index 85bc36630..8fb6c690f 100644
--- a/lib/ship-receipt-depth.ts
+++ b/lib/ship-receipt-depth.ts
@@ -41,9 +41,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}`;
@@ -156,10 +156,10 @@ 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 }
-function headInputs(root: string, baseRef: string, head: string): HeadInputs {
+function headInputs(root: string, baseRef: string, head: string, merged = false): HeadInputs {
const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
const baseRev = baseSha.ok ? baseSha.out : baseRef;
- return { baseSha, c: collectCommit(root, baseRev, head), scope: scopeOfRange(root, BIN_DIR, baseRev, head) };
+ return { baseSha, c: collectCommit(root, baseRev, head), scope: scopeOfRange(root, BIN_DIR, baseRev, head, merged) };
}
/** The receipt's recorded gate scan when it names this head; otherwise null (unavailable). */
const boundScan = (r: ShipReceipt, head: string): number | null => {
@@ -190,7 +190,7 @@ 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. 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). */
+/** 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 | 'BASE_CONTAINS_HEAD' | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } {
@@ -201,7 +201,7 @@ export function bindShippedReceipt(root: string, baseRef: string, body: string,
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);
+ const at = headInputs(root, baseRef, b.head, merged);
const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head, at);
const gateTagged = boundScan(b.receipt, b.head) ?? (gateScanNeeded(at.c.paths, at.c.root) ? null : 0);
const targetedLine = receiptTargetedLine(targetedClasses({ ...at.c, scope: at.scope, gateTagged }).classes, b.receipt);
diff --git a/test/default-base.test.ts b/test/default-base.test.ts
index df139d47c..450c83a61 100644
--- a/test/default-base.test.ts
+++ b/test/default-base.test.ts
@@ -21,7 +21,7 @@ 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 }
+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) {
@@ -51,7 +51,7 @@ beforeAll(() => {
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 };
+ 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);
@@ -80,6 +80,18 @@ beforeAll(() => {
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 }); });
@@ -278,3 +290,31 @@ describe('baseContainsHead keeps today\'s result where the branch is not compare
});
}, 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/ship-queue-land.test.ts b/test/ship-queue-land.test.ts
index b965919a7..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()!); });
@@ -564,16 +566,21 @@ describe('land: one pass, base, lock and history', () => {
}, 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 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, not BASE_CONTAINS_HEAD', () => {
+ // 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' });
- openPr(fx, '14');
+ 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).not.toContain('BASE_CONTAINS_HEAD');
+ 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);
});