mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
train T2: merge #3572 at 14c4c5e263
This commit is contained in:
commit
21baad13f1
6 files changed
+236
-16
No files matched your search
@@ -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 <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. 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 <branch> 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/<parent>` 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
|
||||
|
||||
+24
-6
@@ -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<typeof planCarry>;
|
||||
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/<receipt base> 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);
|
||||
|
||||
@@ -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 <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, 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
|
||||
|
||||
+17
-9
@@ -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/<parent>` 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 <n>` 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.
|
||||
|
||||
<a id="review-loop-exception-invalid"></a>
|
||||
### `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)).
|
||||
|
||||
|
||||
+3
-1
@@ -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');
|
||||
|
||||
@@ -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<string, string> = { 'lib/util.js': SUB }, childFiles: Record<string, string> = { '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<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);
|
||||
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/<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);
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user