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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 01:19:15 +00:00
1 parent 3b90122ef2
commit ed93c9f3aa
6 files changed
+149 -4

No files matched your search

+3
View File
@@ -10,7 +10,10 @@
- **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`.
- **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules.
- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base <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/<parent>` for an older receipt. Equal carries with the reason `stack parent <branch> landed`; 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. A child edited after the merge and a parent that never landed refuse. An older receipt falls back to `origin/<parent>` and refuses once that branch is deleted.
- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product.
- `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`.
+17 -2
View File
@@ -542,8 +542,23 @@ function cmdCarry(rest: string[], json: boolean): number {
// 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/<receipt base> when it still resolves) must be HEAD's whole product diff against the base: the parent
// fully landed and the child is unchanged. Anything else fails closed.
const reviewedBase = receipt.base.replace(/^origin\//, '');
const retarget = reviewedBase !== base.replace(/^origin\//, '');
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 changedProduct: string[] = [];
if (productFrom !== productHead) {
if (retarget && reviewed !== productHead) 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) {
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;
@@ -557,7 +572,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: productHead 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,
+14
View File
@@ -901,6 +901,20 @@ 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 <branch> 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 <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/<parent>` while that branch still resolves) with HEAD's whole product
diff against the base. Equal means the parent fully landed and the child is
unchanged, so the receipt carries. 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
+4 -1
View File
@@ -4730,7 +4730,10 @@ 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
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.
**Fix.** Run /ship on the new head. The receipt carries only across a
+3 -1
View File
@@ -164,6 +164,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 };
@@ -230,7 +232,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');
+108
View File
@@ -0,0 +1,108 @@
/**
* 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 { 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";
/** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */
function stack(repo: FixtureRepo): { parent: string; child: string; body: string } {
repo.git('checkout', '-q', '-b', 'parent');
const parent = commit(repo, { 'lib/util.js': SUB }, 'parent: sub');
repo.git('push', '-q', 'origin', 'parent');
repo.git('checkout', '-q', 'feature');
repo.git('reset', '-q', '--hard', parent);
const child = commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }, '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<string, string>): 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);
}, 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/<parent>, 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);
}, 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);
});