diff --git a/CHANGELOG.md b/CHANGELOG.md index b43cecf5a..635ed44a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### 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. + +## [Unreleased] + **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. ### Added 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/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); +});