From f59b3435d5ff643015b90c6c805b15a6ee20338e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 29 Oct 2026 03:12:18 -0700 Subject: [PATCH] v1.91.315.0 ship-receipt: carry a stacked child after its parent lands (#3572) * feat(ship-receipt): carry across regenerated outputs and onto a reviewed tree after a rebase A base merge whose only product-side change is a registered output the pregate regen check proved fresh now carries the receipt. A rebased head carries when its tree is one the receipt covers and its product patch-id against the current base matches (RECEIPT_CARRY_TREE_MISMATCH otherwise). Docs: merge main into a reviewed branch, never rebase it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: regenerate doctor anchors; type the head_tree assert Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(review): proportional /ship review depth (light, standard, full) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry sets aside only outputs the regen check proved it recreates The regen check deletes each touched output in its scratch worktree before the command runs and records the ones recreated byte-identically as proven. gstack-ship-receipt carry exempts only those paths, and only when the check ran origin/'s registry at the commit the carry reads. A file under an output glob that the generator never writes stays product (pre-landing review, #3564 pass 1). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-receipt): record review depth, check it in the queue, structured review-loop exception (#3559) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: prove generated outputs in a second regen run; literal names only The regen verdict comes from the unmodified run again, so a generator that reads its own registered output (gen-skill-docs and qa/templates/functional-report-template.md) passes as before; the proof is a second run with the touched outputs deleted, and its failure only means not proven. The carry ignores a proven path with glob syntax, since it uses the paths as pathspec globs (pre-landing review, #3564 pass 2). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: receipt evidence keys and carry record fields; carry refusal fix line Documentation audit for #3564 (/document-release, audit ship3564-docs-1). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (depth scan binding, queue scope, carry checks) - carry refuses a --head other than the checked-out HEAD, a receipt with gstack_version but no review_depth, and an owner exception that no longer stands; a dirty or foreign checkout makes the write-time gate scan unavailable (full) - the queue binds a gate scan only to the full bound commit and recomputes the scope signals with gstack-diff-scope --head , the same range the writer uses - the queue checks the review-loop exception's fix-check ancestry - a package.json version line counts as stamp-only only when it is a bare version key - docs and help name what review_depth is and which registry the queue reads Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG for the review fixes; defer two plan items to TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-receipt): carry a stacked child after its parent lands (#3570) When the carry base differs from the receipt's (a stack parent landed and the PR was retargeted), the receipt carries only when HEAD's product diff against the base equals the range reviewed against the parent branch. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: queue and carry refusal lists name the depth and exception codes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes A retargeted carry compared the reviewed range with a patch-id whose pathspec came from the reviewed head..HEAD range, so a parent's package.json change that never reached the base dropped out and the receipt carried. Compare with HEAD's whole product patch-id against the base (the one write records), rebind the receipt's base on a retarget carry, and cover the parent-metadata, landed- with-changes and test-only-child cases. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: approval-classes summary-line regexes allow the receipt's depth field Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: troubleshooting names the stacked-retarget carry refusals Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-receipt): summary line prints depth before approval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Revert "test: approval-classes summary-line regexes allow the receipt's depth field" This reverts commit e9ec264358f0043feff6bf4522895c63769a1fe2. * test: assert a retarget carry rebinds review_depth to the new base and binds at the queue; docs: split the --head carry advice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-ship-receipt | 30 ++++- docs/ship-policy.md | 16 +++ docs/troubleshooting.md | 26 ++-- lib/ship-receipt.ts | 4 +- package.json | 2 +- test/ship-receipt-carry-squash.test.ts | 168 +++++++++++++++++++++++++ 9 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 test/ship-receipt-carry-squash.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8964d4a6c..30d6b90a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.91.315.0] - 2026-10-29 + +### Changed +- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base ` compares HEAD's product diff against the base with the range the receipt reviewed against the parent branch. That is the recorded `product_patch_id`, or the range against `origin/` for an older receipt. HEAD's side is its whole product diff against the base, so a parent's `package.json` change that never reached the base refuses too. Equal carries with the reason `stack parent landed` and rebinds the receipt's `base` to the base; anything else refuses. Until now every stacked child paid a full /ship once its parent merged (gstack-evals#19). + +### Tests +- `test/ship-receipt-carry-squash.test.ts`: a child carries after its parent squash-merges and main is merged in, names main as its base, and carries again across a later main merge. A test-only child carries. A child edited after the merge, a parent that never landed, a parent `package.json` change that never reached main, and a merge that keeps the child's tree over the landed parent refuse. An older receipt falls back to `origin/` and refuses once that branch is deleted. + ## [1.91.314.0] - 2026-10-29 **A later autoplan gate that asks nothing new is now answered with the owner's earlier words instead of another round trip.** Captains were carrying approvals across gate revisions by hand (GSTA-140 reused a rev-1 "Do it" on rev 2; ALPH-50 lost about 27 minutes to one), and a replay over 23 recorded runs found 14 later gates, 8 of them holding only post-cap delta items. diff --git a/VERSION b/VERSION index 5ecd487b0..af3544127 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.314.0 +1.91.315.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 7e4680d3e..626a80836 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.314.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.315.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 6d50425b1..1b24fd1af 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -547,6 +547,8 @@ function cmdCarry(rest: string[], json: boolean): number { if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`); const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head); if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`); + const reviewedBase = receipt.base.replace(/^origin\//, ''); + const retarget = reviewedBase !== base.replace(/^origin\//, ''); let plan: ReturnType; if (!ancestor) { // Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded. @@ -565,11 +567,25 @@ function cmdCarry(rest: string[], json: boolean): number { const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests)); const testHead = asId(patchId(repoRoot, headBase, head, spec.tests)); for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`); - // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, - // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every - // path the base changed, none of which can differ between the two base diffs. + // A stacked child whose parent landed on the base (#3570): the receipt reviewed the child's own range against the + // parent branch, and the PR now targets the base. That reviewed range (the recorded product patch-id, else the one + // against origin/ when it still resolves) must be HEAD's whole product diff against the base, computed + // the way write records it (headPatch): the parent fully landed and the child is unchanged. Anything else fails closed. + let reviewed = productFrom; + if (retarget) { + const parentRef = `origin/${reviewedBase}`; + const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) + : git(['rev-parse', '--verify', '--quiet', `${parentRef}^{commit}`], repoRoot) ? productPatchAgainstBase(repoRoot, rules, parentRef, from) : { error: `the receipt records no product patch-id and ${parentRef} no longer resolves` }; + if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the receipt was reviewed against ${reviewedBase}, not ${base}, and its reviewed product diff is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`); + reviewed = recorded; + } + const current = retarget ? headPatch : productHead; const changedProduct: string[] = []; - if (productFrom !== productHead) { + if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`); + else if (productFrom !== productHead && !retarget) { + // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, + // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every + // path the base changed, none of which can differ between the two base diffs. const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); for (const p of [...touched].sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; @@ -583,7 +599,7 @@ function cmdCarry(rest: string[], json: boolean): number { // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; - plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff }); + plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) }); } if (!plan.ok) return refused(plan.code, plan.message); // Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD, @@ -611,7 +627,9 @@ function cmdCarry(rest: string[], json: boolean): number { if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`); depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } }; } - const carried = carryReceipt({ ...receipt, ...pregateField, ...depthField }, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); + // The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on. + const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField }; + const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); const newBody = placeReceiptFirst(body, carried); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index c08973c92..168ccb678 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -953,6 +953,22 @@ receipt when the base moves: | a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) | | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | +| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | + +**Stacked PRs (#3570).** A child PR's receipt is written against its parent +branch, so its `base` names that branch and its `product_patch_id` is the +child's own range. After the parent lands, the PR is retargeted to the base, and +/ship's carry check runs with `--base `. When that base differs from the +receipt's, the carry compares the reviewed range (the recorded +`product_patch_id`, or for an older receipt the range against +`origin/` while that branch still resolves) with HEAD's whole product +diff against the base, computed the way `write` records it, so a parent's +release-metadata change that never reached the base counts too. Equal means +the parent fully landed and the child is unchanged, so the receipt carries +and from then on names the base it was carried to. A parent that has not landed, a child edited +since, or a parent that landed with changes the child never merged all leave a +different diff and refuse with `RECEIPT_CARRY_PRODUCT_CHANGED`. An older +receipt whose parent branch is gone refuses with `RECEIPT_CARRY_REFUSED`. Since the release that added `review_scope`. A `/ship` receipt names the head it reviewed, so a conflict-only merge of the base or a test-only fix used to diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 82e666d05..f7f904eab 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4770,8 +4770,10 @@ to HEAD: the PR body has no valid receipt, the working tree has uncommitted or untracked changes (a carry certifies a commit, never a tree that differs from it), the receipt's head left HEAD's history and the receipt records no tree to compare (a receipt written before `head_tree`, whose head is not in this -clone), or git could not compare the two product diffs or read the working -tree's status. It also refuses when an owner exception the receipt records +clone), the PR was retargeted to the base after its stack parent landed but +the receipt records no `product_patch_id` and `origin/` no longer +resolves, so the reviewed range cannot be recomputed, or git could not +compare the two product diffs or read the working tree's status. It also refuses when an owner exception the receipt records no longer stands, and, for a receipt from a gstack that records review depth, when HEAD needs a deeper review than the receipt records, `--head` is not the checked-out HEAD, or the receipt records `gstack_version` without @@ -4782,9 +4784,10 @@ ready` refuses `QUEUE_RECEIPT_STALE`. **Fix.** Commit or stash every working-tree change, then rerun `gstack-ship-receipt carry --pr ` at the new head. When the history is already rewritten and the receipt predates `head_tree`, run /ship on the new -head; next time merge the base in instead of rebasing. For a depth or -exception refusal, run /ship on the new head (or check out `--head` and carry -there). +head; next time merge the base in instead of rebasing. For a retargeted stacked +PR whose older receipt lost its parent branch, or a depth or exception +refusal, run /ship on the new head. When `--head` is not the checked-out HEAD, +check it out and carry there. ### `REVIEW_LOOP_EXCEPTION_INVALID` @@ -4823,13 +4826,18 @@ registered output the pregate's regen check proved (listed under `proven`, checked against the same base registry), nor part of the base merge has a different patch than the reviewed head had. After a rebase it means HEAD has the reviewed tree but the base moved under it, so the diff a reviewer would see -is not the reviewed one. The reviewed verdict does not cover HEAD, so the -receipt is not carried. The refusal names the changed product paths. +is not the reviewed one. For a stacked PR retargeted to the base after its +parent landed, it means HEAD's product diff against the base is not the range +reviewed against the parent branch: the parent has not fully landed, or the +branch changed since. The reviewed verdict does not cover HEAD, so the +receipt is not carried. The refusal names the changed product paths, or for a +retargeted stacked PR the parent branch and the base. **Fix.** Run /ship on the new head. The receipt carries only across a conflict-only base merge, a test-only change, release metadata, a registered -output the pregate's regen check proved, or a rebase onto a covered tree with -the recorded product diff, which the +output the pregate's regen check proved, a rebase onto a covered tree with +the recorded product diff, or a stacked PR's retarget once its parent fully +landed on the base, which the `carry` object in the carried receipt records with the product patch-id and the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)). diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 25ba416e4..d02bded8f 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -166,6 +166,8 @@ export interface CarryPlanInput { changedProduct: string[]; mergeCommits: number; rangeDiff: string; + /** The base the receipt was reviewed against when it differs from the carry's (a stack parent that landed, #3570): productPatchId.from is then the recorded diff against it. */ + retargetedFrom?: string; } export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' } export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string }; @@ -232,7 +234,7 @@ export function planCarry(input: CarryPlanInput): CarryPlan { const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS); return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` }; } - const reasons: string[] = []; + const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : []; if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`); if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change'); if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata'); diff --git a/package.json b/package.json index 169116d6b..6ee5d4ca5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.314", + "version": "1.91.315", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/ship-receipt-carry-squash.test.ts b/test/ship-receipt-carry-squash.test.ts new file mode 100644 index 000000000..2c763e7b3 --- /dev/null +++ b/test/ship-receipt-carry-squash.test.ts @@ -0,0 +1,168 @@ +/** + * gstack-ship-receipt carry for a stacked child after its parent squash-merges (#3570, + * docs/ship-policy.md#receipt-carry): the receipt reviewed the child's own range against the parent + * branch, and that range must be HEAD's whole product diff against the base the PR now targets. + * Anything else (the parent not landed, the child changed) fails closed. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; +import { bindShippedReceipt } from '../lib/ship-receipt-depth'; +import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; +const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); +const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n"; + +const RETRY4 = "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n"; + +/** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */ +function stack(repo: FixtureRepo, parentFiles: Record = { 'lib/util.js': SUB }, childFiles: Record = { 'lib/retry.js': RETRY4 }): { parent: string; child: string; body: string } { + repo.git('checkout', '-q', '-b', 'parent'); + const parent = commit(repo, parentFiles, 'parent: sub'); + repo.git('push', '-q', 'origin', 'parent'); + repo.git('checkout', '-q', 'feature'); + repo.git('reset', '-q', '--hard', parent); + const child = commit(repo, childFiles, 'child: four retries'); + const body = path.join(repo.root, 'body.md'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'parent', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]); + expect(w.status).toBe(EXIT.ok); + return { parent, child, body }; +} +/** Land `files` on main as one commit (the parent's squash merge; none when the parent never lands), then an unrelated main commit, and merge main into the child. */ +function landOnMain(repo: FixtureRepo, files: Record): void { + repo.git('checkout', '-q', 'main'); + if (Object.keys(files).length) { + for (const [rel, text] of Object.entries(files)) write(repo.work, rel, text); + repo.git('add', '-A'); repo.git('commit', '-qm', 'squash: parent (#7)'); + } + write(repo.work, 'docs/README.md', '# docs after the parent\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); +} + +describe('carry after a stack parent squash-merges (#3570)', () => { + // Value: protects=a stacked child keeps its receipt when its parent squash-merges and main is merged in (gstack-evals#19 + // paid a full /ship for it); fails_when=carry compares the child's whole diff from the old fork point; seam=none; + // no_claim=gh pr edit (--body stands in) + test('the child carries when the range it was reviewed for is HEAD\'s whole product diff against main', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { child, body } = stack(repo); + const reviewed = receiptIn(body).product_patch_id; + landOnMain(repo, { 'lib/util.js': SUB }); + const head = repo.git('rev-parse', 'HEAD'); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.ok); + expect(out.out).toContain(`CARRY: carried from ${child.slice(0, 12)} to ${head.slice(0, 12)}`); + const r = receiptIn(body); + expect(r.head).toBe(head); + expect(r.carry!.reason).toBe('stack parent parent landed + base merge'); + expect(r.product_patch_id).toBe(reviewed); + expect(r.base).toBe('main'); + expect(r.review_depth!.base).toBe(repo.git('rev-parse', 'origin/main')); + expect(r.review_depth!.gate_scan.head).toBe(head); + expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '8', [head]).ok).toBe(true); + repo.git('checkout', '-q', 'main'); + commit(repo, { 'docs/CHANGES.md': '# main moves again\n' }, 'main moves again'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + const again = carry(repo, body); + expect(again.status).toBe(EXIT.ok); + expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge + base merge'); + expect(receiptIn(body).product_patch_id).toBe(reviewed); + }, 90_000); + + // Value: protects=a receipt written before product_patch_id still carries while the parent branch resolves, and refuses by + // name once it is gone; fails_when=the fallback reads another ref or carries with no reviewed diff; seam=none; no_claim=- + test('without a recorded product patch-id the child range comes from origin/, and a deleted parent branch refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { parent, child, body } = stack(repo); + landOnMain(repo, { 'lib/util.js': SUB }); + const { product_patch_id: _dropped, head_tree: _tree, ...legacy } = receiptIn(body); + fs.writeFileSync(body, renderReceiptBlock(legacy as any)); + expect(receiptIn(body).product_patch_id).toBeUndefined(); + repo.git('push', '-q', 'origin', '--delete', 'parent'); + repo.git('fetch', '-q', '--prune', 'origin'); + const gone = carry(repo, body); + expect(gone.status).toBe(EXIT.refused); + expect(gone.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)'); + expect(gone.err).toContain('the receipt was reviewed against parent, not main, and its reviewed product diff is unavailable'); + expect(receiptIn(body).head).toBe(child); + repo.git('push', '-q', 'origin', `${parent}:refs/heads/parent`); + repo.git('fetch', '-q', 'origin'); + expect(carry(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge'); + }, 90_000); + + // Value: protects=a child with no product change of its own (product_patch_id none) still carries once the parent lands; + // fails_when=the recorded 'none' is compared as a literal id; seam=none; no_claim=- + test('a test-only child carries after its parent lands', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = stack(repo, { 'lib/util.js': SUB }, { 'test/retry.test.js': "const test = require('node:test');\ntest('retries', () => {});\n" }); + expect(receiptIn(body).product_patch_id).toBe('none'); + landOnMain(repo, { 'lib/util.js': SUB }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.ok); + expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge'); + }, 90_000); + + // Value: protects=fail closed: a child changed after the parent merged, or a parent that never reached main, takes a fresh + // /ship; fails_when=the comparison ignores the child's remainder or the parent's absence; seam=none; no_claim=- + test('a product edit on the child after the merge, or a parent that never landed, refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { child, body } = stack(repo); + landOnMain(repo, { 'lib/util.js': SUB }); + commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 5;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }, 'child: five'); + const edited = carry(repo, body); + expect(edited.status).toBe(EXIT.refused); + expect(edited.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(receiptIn(body).head).toBe(child); + + const other = makeFixtureRepo(); repos.push(other); + const s = stack(other); + landOnMain(other, {}); + const abandoned = carry(other, s.body); + expect(abandoned.status).toBe(EXIT.refused); + expect(abandoned.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(abandoned.err).toContain('is not the one reviewed against parent (the parent has not fully landed on main'); + expect(receiptIn(s.body).head).toBe(s.child); + }, 120_000); + + // Value: protects=fail closed on the parent's release-metadata paths: a package.json change (a dependency and a postinstall + // script) the parent made but main never received is product in HEAD's diff against main, though the child never touched it; + // fails_when=the comparison takes its pathspec from the reviewed head..HEAD range, where package.json is unchanged; seam=none; + // no_claim=- + test('a parent package.json change that never reached main refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const pkg = JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node --test test/*.test.js', generate: 'node gen.js', postinstall: 'node gen.js' }, dependencies: { leftpad: '1.0.0' } }, null, 2) + '\n'; + const { child, body } = stack(repo, { 'lib/util.js': SUB, 'package.json': pkg }); + landOnMain(repo, { 'lib/util.js': SUB }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(receiptIn(body).head).toBe(child); + }, 90_000); + + // Value: protects=the docs row "a parent that landed with changes the child never merged" refuses; fails_when=a merge that + // keeps the child's tree (-s ours) over the landed parent reads as unchanged; seam=none; no_claim=- + test('a parent that landed with changes the child never merged refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { child, body } = stack(repo); + repo.git('checkout', '-q', 'main'); + commit(repo, { 'lib/util.js': SUB + "exports.mul = (a, b) => a * b;\n" }, 'squash: parent with a review fix (#7)'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', '-s', 'ours', 'origin/main'); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(receiptIn(body).head).toBe(child); + }, 90_000); +});