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.