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);
+});