From b8d6eec9158c1282b29e10b2fa214b54d2e13d44 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:29:22 +0000 Subject: [PATCH 1/5] fix(ship-queue): wave plan leaves out heads that conflict with main, links rename-vs-edit conflicts, never splits a conflict cluster; ready reports unstamped against the fork point (GST-770 follow-up, #3558) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-policy.md | 15 +++++-- lib/ship-queue-wave.ts | 60 +++++++++++++++++---------- lib/ship-queue.ts | 4 +- test/gstack-ship-queue.test.ts | 20 +++++++++ test/ship-queue-wave.test.ts | 74 ++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 28 deletions(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e7d658833..04223f82d 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -574,13 +574,20 @@ gstack-ship-queue wave settle --wave [--dry-run] # after the wave - **When.** `wave plan` counts the live READY PRs (open, head equal to their READY receipt, CI green at that head; a READY head whose CI is red, still - running or absent prints a `NOT_GREEN` line and stays out of the plan). A - READY PR whose CI, head or diff cannot be read fails the whole plan with - `QUEUE_PROOF_MISSING`. Over the + running or absent prints a `NOT_GREEN` line and stays out of the plan; a + READY head that does not merge onto main with the train's merge rules prints + `NOT_MERGEABLE` with the conflicting files and stays out too, while a + conflict only in the CHANGELOG, which the train merges, does not count). A + READY PR whose CI, head, diff or merge onto main cannot be read fails the + whole plan with `QUEUE_PROOF_MISSING`. Over the trigger (6) it prints `WAVE_ADVISED` and suggested groups of 3 to 6: PRs whose changes to a common code file conflict (`git merge-tree` cannot merge the two heads, or the check itself fails) always share a group, so that - conflict is resolved once. Hub + conflict is resolved once. A PR that renames a file and one that edits the + old path are linked when the edit conflicts at the new path. A group of + linked PRs is never split across waves: one larger than the wave maximum + prints `WAVE_OVERSIZE` with no build command (land part of it as single + slots, then plan again). Hub files never link PRs: anything under `docs/`, Markdown (generated skill renders included; their `.tmpl` sources still link) and every `.json` or `.jsonl` file (ledgers and registries). Most PRs append to them, and diff --git a/lib/ship-queue-wave.ts b/lib/ship-queue-wave.ts index c0f505f41..7e0f0767e 100644 --- a/lib/ship-queue-wave.ts +++ b/lib/ship-queue-wave.ts @@ -27,7 +27,7 @@ import { versionAtRef } from './restamp'; import { globToRegExp, type ShipPolicy } from './ship-policy'; import { ciAtHead, type QueueContext } from './ship-queue'; import { git, sha256, type QueueFailure } from './ship-queue-gate'; -import { fetchBase, mergeCommit, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; +import { fetchBase, mergeCommit, mergeStep, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; import { prInfo, type PrInfo } from './ship-queue-train-github'; import { listWaves, loadWave, nowIso, persist, queueRoot, rebuildFromNotes, redactCause, wavePath, type ReadyReceipt, type WaveRecord } from './ship-queue-train-record'; @@ -67,7 +67,8 @@ export function areaKey(file: string, isSkillDir: (dir: string) => boolean): str return parts.length > 1 ? top : 'root'; } -export interface PlanPr { pr: string; head: string; files: string[]; lines: number } +/** renamed maps a path the PR renamed to its new path; files lists both. */ +export interface PlanPr { pr: string; head: string; files: string[]; lines: number; renamed?: Record } export interface WaveGroup { area: string; prs: PlanPr[]; integrator: string } /** The most common area across files, ties broken by name; 'misc' with no files. */ @@ -91,11 +92,13 @@ export const isHubFile = (file: string) => file.startsWith('docs/') || /\.(md|js * Group PRs into waves: PRs whose changes to a common code file conflict always travel together * (one integrator resolves the conflict once); hub files never link. conflicts(a, b) names the * files a merge of the two heads cannot resolve, or null when unknown, which counts every shared - * file as conflicting. Each cluster takes its largest PR's area, clusters of one area are packed - * in PR order into waves of at most max, and a wave below min rides solo. The integrator is the - * PR with the most changed lines. + * file as conflicting; a conflict at the path either PR renamed the shared file to counts as one + * on that file. Each cluster takes its largest PR's area and is packed whole, first fit in PR + * order, into the waves of its area of at most max; a cluster larger than max is oversize (never + * split, so its conflicts still meet one integrator), and a wave below min rides solo. The + * integrator is the PR with the most changed lines. */ -export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[] } { +export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[]; oversize: Array<{ area: string; prs: PlanPr[] }> } { const sorted = [...prs].sort((a, b) => Number(a.pr) - Number(b.pr)); const root = new Map(sorted.map(p => [p.pr, p.pr])); const find = (x: string): string => { const r = root.get(x)!; return r === x ? x : find(r); }; @@ -106,7 +109,8 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W const key = `${a.pr} ${b.pr}`; if (!clash.has(key)) clash.set(key, conflicts(a, b)); const hit = clash.get(key); - if ((!hit || hit.includes(f)) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr)); + const at = [f, a.renamed?.[f], b.renamed?.[f]]; + if ((!hit || at.some(p => p && hit.includes(p))) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr)); })); const clusters = new Map(); for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]); @@ -117,17 +121,20 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W } const groups: WaveGroup[] = []; const solo: string[] = []; + const oversize: Array<{ area: string; prs: PlanPr[] }> = []; for (const [area, list] of [...byArea].sort((a, b) => a[0].localeCompare(b[0]))) { - let cur: PlanPr[] = []; - const flush = () => { - if (cur.length >= min) groups.push({ area, prs: cur, integrator: [...cur].sort(largestFirst)[0]!.pr }); - else solo.push(...cur.map(p => p.pr)); - cur = []; - }; - for (const c of list) for (const p of c) { if (cur.length >= max) flush(); cur.push(p); } - flush(); + const waves: PlanPr[][] = []; + for (const c of list) { + if (c.length > max) { oversize.push({ area, prs: c }); continue; } + const fit = waves.find(w => w.length + c.length <= max); + if (fit) fit.push(...c); else waves.push([...c]); + } + for (const w of waves) { + if (w.length >= min) groups.push({ area, prs: w, integrator: [...w].sort(largestFirst)[0]!.pr }); + else solo.push(...w.map(p => p.pr)); + } } - return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)) }; + return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)), oversize }; } export interface WaveChain { integrator: string[]; merges: string[]; stamps: string[] } @@ -225,10 +232,14 @@ function readyReceipts(env: TrainEnv): ReadyReceipt[] { return [...newest.values()]; } -function changed(env: TrainEnv, head: string): { files: string[]; lines: number } | null { +function changed(env: TrainEnv, head: string): { files: string[]; lines: number; renamed: Record } | null { const fork = git(env.ctx.repoRoot, ['merge-base', `${env.remote}/${env.ctx.base}`, head]); const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', '--no-renames', fork.out, head]) : fork; - if (!stat.ok) return null; + const moves = fork.ok ? git(env.ctx.repoRoot, ['diff', '--name-status', '-z', '-M', '--diff-filter=R', fork.out, head]) : fork; + if (!stat.ok || !moves.ok) return null; + const renamed: Record = {}; + const fields = moves.out.split('\0'); + for (let i = 0; i + 2 < fields.length; i += 3) if (!isReleasePath(fields[i + 1]!, env.policy.policy)) renamed[fields[i + 1]!] = fields[i + 2]!; let lines = 0; const files: string[] = []; for (const row of stat.out.split('\n').filter(Boolean)) { @@ -237,7 +248,7 @@ function changed(env: TrainEnv, head: string): { files: string[]; lines: number files.push(file); lines += (Number(add) || 0) + (Number(del) || 0); } - return { files, lines }; + return { files, lines, renamed }; } const areaOf = (env: TrainEnv) => (f: string) => areaKey(f, dir => fs.existsSync(path.join(env.ctx.repoRoot, dir, 'SKILL.md.tmpl'))); @@ -269,12 +280,15 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m if (ci.line.startsWith('unknown')) { unreadable.push(`#${r.pr} (CI ${ci.line})`); continue; } if (!ci.green) { lines.push(`NOT_GREEN: #${r.pr} (${ci.line}; left out until CI is green at the READY head)`); continue; } if (!ensureCommit(env, r.head, r.pr)) { unreadable.push(`#${r.pr} (head ${short(r.head)} not fetchable)`); continue; } + const onBase = mergeStep(env.ctx.repoRoot, `${env.remote}/${env.ctx.base}`, r.head, env.policy.policy); + if ('conflicts' in onBase && onBase.conflicts.some(f => f.startsWith('('))) { unreadable.push(`#${r.pr} (merge onto ${env.ctx.base} unreadable: ${onBase.conflicts.join(', ')})`); continue; } + if ('conflicts' in onBase) { lines.push(`NOT_MERGEABLE: #${r.pr} (conflicts with ${env.remote}/${env.ctx.base} at ${short(r.head)} in ${onBase.conflicts.join(', ')}; left out until the head merges cleanly onto ${env.ctx.base})`); continue; } const c = changed(env, r.head); if (!c) { unreadable.push(`#${r.pr} (diff unreadable)`); continue; } live.push({ pr: r.pr, head: r.head, ...c }); } if (unreadable.length) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${unreadable.length} READY PR(s): ${unreadable.join('; ')}; a plan from a partial queue would mislead`), lines, `${Q} wave plan (after gh and git can read every PR)`, 'none'); - lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there); wave trigger ${o.trigger}`); + lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there, merging cleanly onto ${env.ctx.base}); wave trigger ${o.trigger}`); if (live.length <= o.trigger) return outOk([...lines, `WAVE: not needed (${live.length} <= ${o.trigger}); build trains of single PRs`], `${Q} train build --prs `, 'none'); const plan = groupWaves(live, areaOf(env), o.min, o.max, (a, b) => { const m = git(env.ctx.repoRoot, ['merge-tree', '--write-tree', '--name-only', '--no-messages', a.head, b.head]); @@ -285,6 +299,7 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m lines.push(`WAVE_GROUP: area=${g.area} prs=${g.prs.map(p => '#' + p.pr).join(',')} integrator=#${g.integrator} (most changed lines)`); lines.push(` build: ${buildCmd(g.area, g.prs)}`); } + for (const c of plan.oversize) lines.push(`WAVE_OVERSIZE: area=${c.area} prs=${c.prs.map(p => '#' + p.pr).join(',')} (${c.prs.length} PRs whose changes conflict, over the wave maximum ${o.max}; kept whole with no build command: land part of it as single slots, then run wave plan again)`); if (plan.solo.length) lines.push(`SOLO: ${plan.solo.map(p => '#' + p).join(',')} (no area group of ${o.min}+; they ride as single slots)`); const first = plan.groups[0]; return outOk(lines, first ? `#${first.integrator}'s owner integrates: ${buildCmd(first.area, first.prs)}` : `${Q} train build --prs `, 'none'); @@ -577,8 +592,9 @@ export function waveSettle(ctx: QueueContext, o: { wave: string; dryRun: boolean export const WAVE_HELP = `gstack-ship-queue wave: wave PRs for merge trains (docs/ship-policy.md#wave-prs) wave plan [--trigger ] [--min ] [--max ] - read-only: live READY PRs; over the trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and - WAVE_GROUP lines (area, PRs, integrator) with each group's build command + read-only: live READY PRs (NOT_GREEN and NOT_MERGEABLE heads left out); over the + trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and WAVE_GROUP lines (area, PRs, integrator) + with each group's build command; WAVE_OVERSIZE for linked PRs over --max (no command) wave build --prs @,... [--area ] [--min ] [--max ] [--dry-run] the integrator branch wave/: main plus each pin merged unchanged (train merge rules), the wave record in refs/notes/gstack-trains, the branch pushed, a frozen notice diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index ae3925ece..9c0dbdd9b 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -226,8 +226,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run); const atHead = versionAtRef(ctx.repoRoot, head); const baseSha = git(ctx.repoRoot, ['rev-parse', `origin/${ctx.base}`]).out; - const atBase = versionAtRef(ctx.repoRoot, baseSha); - const version = 'version' in atHead && 'version' in atBase && atHead.version !== atBase.version ? atHead.version : 'unstamped'; + const atFork = versionAtRef(ctx.repoRoot, git(ctx.repoRoot, ['merge-base', baseSha, head]).out || baseSha); + const version = 'version' in atHead && 'version' in atFork && atHead.version !== atFork.version ? atHead.version : 'unstamped'; const ci = ciAtHead(ctx, head); const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL); let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`; diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 9b9248631..88bcbc511 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -361,6 +361,26 @@ describe('ready', () => { expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/); }, 180_000); + test('STAMP_VERSION is unstamped for a PR whose own diff leaves VERSION alone after main moved to a newer version, and the stamp for one that changes it (GST-770 follow-up)', () => { + const repo = fixture(); + const head = makePr(repo, 'pr-a'); + repo.git('checkout', '-q', 'origin/main', '-B', 'main-moves'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + repo.git('commit', '-qam', 'v1.2.4.0 another PR landed'); + repo.git('push', '-q', 'origin', 'HEAD:main'); + repo.git('fetch', '-q', 'origin'); + repo.git('checkout', '-q', 'pr-a'); + const shim = (prHead: string) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body: shippedBody('1', head) } }, checks: { [prHead]: green } }); + const forked = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, head)], shim(head)); + expect([forked.status, forked.err]).toEqual([EXIT.ok, '']); + expect(forked.out.split('\n').slice(0, 4)).toEqual([`READY #1 ${head} STAMP_VERSION=unstamped`, 'pr: https://github.com/o/r/pull/1', `head: ${head}`, 'version: unstamped']); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + repo.git('commit', '-qam', 'v1.2.5.0 stamp'); + const stamped = repo.git('rev-parse', 'HEAD'); + const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, stamped)], shim(stamped)); + expect(ok.out.split('\n')[0]).toBe(`READY #1 ${stamped} STAMP_VERSION=1.2.5.0`); + }, 180_000); + // Value: protects=the re-READY after a slot's stamp push accepts the receipt /ship wrote for the pinned head, and only then; // fails_when=ready binds the receipt to HEAD alone (every stamped READY turns QUEUE_RECEIPT_STALE) or accepts any ancestor's receipt; // why_new=the READY test above binds receipts to the exact head only; seam=none; no_claim=real gh and CI (shims; unit only) diff --git a/test/ship-queue-wave.test.ts b/test/ship-queue-wave.test.ts index d2f0a1ce2..8ea60dab6 100644 --- a/test/ship-queue-wave.test.ts +++ b/test/ship-queue-wave.test.ts @@ -24,6 +24,36 @@ const nonceOf = (out: string) => /^WAVE: (\S+)/m.exec(out)?.[1] ?? ''; const waveJson = (fx: TrainFixture, nonce: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'waves', `${nonce}.json`), 'utf8')); const gitIn = (fx: TrainFixture, cwd: string, ...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }); +/** Commit seed files onto main and push them. */ +function seedMain(fx: TrainFixture, files: Record, message = 'seed'): void { + fx.repo.git('checkout', '-q', 'origin/main', '-B', 'seed'); + for (const [f, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(fx.repo.work, f), t); } + fx.repo.git('add', '-A'); + fx.repo.git('commit', '-q', '-m', message); + fx.repo.git('push', '-q', 'origin', 'HEAD:main'); + fx.repo.git('fetch', '-q', 'origin'); +} + +/** A READY PR off main writing (or, for null, deleting) files, with green CI and a READY receipt at its head. */ +function readyPr(fx: TrainFixture, n: string, files: Record): string { + fx.repo.git('checkout', '-q', 'origin/main', '-B', `pr-${n}`); + for (const [f, t] of Object.entries(files)) { + if (t === null) { fs.rmSync(path.join(fx.repo.work, f)); continue; } + fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); + fs.writeFileSync(path.join(fx.repo.work, f), t); + } + fx.repo.git('add', '-A'); + fx.repo.git('commit', '-q', '-m', `feat #${n}`); + const head = fx.repo.git('rev-parse', 'HEAD'); + fx.repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${n}/head`); + fx.write(d => { + d.prs[n] = { headRefOid: head, isCrossRepository: false, state: 'OPEN', headRefName: `pr-${n}`, url: `https://github.com/fixture/repo/pull/${n}`, body: shippedBody(n, head) }; + d.ci[head] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }]; + }); + readyReceipt(fx, n, head); + return head; +} + function threePrs(fx: TrainFixture) { return ['1', '2', '3'].map(pr => openPr(fx, pr, { file: `lib/ship-${pr}.ts`, changelog: pr === '1' })); } @@ -79,6 +109,15 @@ describe('areas and grouping', () => { for (const hub of ['docs/troubleshooting.md', 'ship/SKILL.md', 'scripts/harness-version.json']) expect(linked([hub], () => [hub])).toEqual([]); expect([isHubFile('docs/x.ts'), isHubFile('TODOS.md'), isHubFile('test/fixtures/x.json'), isHubFile('ship/SKILL.md.tmpl'), isHubFile('lib/x.ts')]).toEqual([true, true, true, false, false]); }); + + test('groupWaves never splits a conflict cluster across waves, and a cluster over the wave maximum is oversize and stays whole (GST-770 follow-up)', () => { + const pr = (n: number, file: string): PlanPr => ({ pr: String(n), head: String(n).repeat(40).slice(0, 40), files: [file], lines: 10 }); + const cluster = (from: number, to: number, file: string) => Array.from({ length: to - from + 1 }, (_, i) => pr(from + i, file)); + const plan = groupWaves([...cluster(1, 4, 'lib/ship-x.ts'), ...cluster(5, 8, 'lib/ship-y.ts'), ...cluster(9, 15, 'lib/ship-z.ts')], f => areaKey(f, () => false), 3, 6); + expect(plan.groups.map(g => g.prs.map(p => p.pr))).toEqual([['1', '2', '3', '4'], ['5', '6', '7', '8']]); + expect(plan.oversize.map(g => [g.area, g.prs.map(p => p.pr)])).toEqual([['ship', ['9', '10', '11', '12', '13', '14', '15']]]); + expect(plan.solo).toEqual([]); + }); }); describe('wave build', () => { @@ -309,6 +348,41 @@ describe('ready and train build read the wave', () => { expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]); expect(unread.err).toContain('#4 (CI unknown at'); }, 180_000); + + test('wave plan leaves out a READY head that conflicts with main as NOT_MERGEABLE, and keeps one whose only conflict is the CHANGELOG the train merges (GST-770 follow-up)', () => { + const fx = make(); + seedMain(fx, { 'lib/ship-a.ts': 'export const a = 0;\n' }); + const changelog = (entry: string) => fx.repo.git('show', 'origin/main:CHANGELOG.md').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- ${entry}\n`) + '\n'; + const conflicting = readyPr(fx, '1', { 'lib/ship-a.ts': "export const a = 'one';\n" }); + readyPr(fx, '2', { 'lib/ship-b.ts': 'export const b = 2;\n', 'CHANGELOG.md': changelog('#2 change') }); + seedMain(fx, { 'lib/ship-a.ts': "export const a = 'main';\n", 'CHANGELOG.md': changelog('a change that landed first') }, 'main moves'); + const plan = queue(fx, ['wave', 'plan']); + expect([plan.status, plan.err]).toEqual([EXIT.ok, '']); + expect(plan.out.split('\n').filter(l => l.startsWith('NOT_MERGEABLE:'))).toEqual([ + `NOT_MERGEABLE: #1 (conflicts with origin/main at ${conflicting.slice(0, 12)} in lib/ship-a.ts; left out until the head merges cleanly onto main)`, + ]); + expect(line(plan.out, 'QUEUE:')).toContain('1 live READY PR(s)'); + }, 180_000); + + test('wave plan links a rename with an edit of the old path that conflicts with it, not with an edit that merges cleanly, and prints a cluster over --max as WAVE_OVERSIZE with no build command (GST-770 follow-up)', () => { + const fx = make(); + const body = (first = '', last = '') => Array.from({ length: 10 }, (_, i) => i === 0 && first ? `export const o0 = '${first}';\n` : i === 9 && last ? `export const o9 = '${last}';\n` : `export const o${i} = ${i};\n`).join(''); + seedMain(fx, { 'lib/ship-old.ts': body() }); + readyPr(fx, '1', { 'lib/ship-old.ts': null, 'lib/ship-new.ts': body('one') }); + readyPr(fx, '2', { 'lib/ship-old.ts': body('two'), 'lib/review-2.ts': 'export const r = 2;\n', 'test/review-2.test.ts': '// #2\n' }); + readyPr(fx, '3', { 'lib/ship-old.ts': body('', 'three'), 'lib/pregate-3.ts': 'export const p = 3;\n', 'test/pregate-3.test.ts': '// #3\n' }); + const plan = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '2']); + expect([plan.status, plan.err]).toEqual([EXIT.ok, '']); + expect(plan.out.split('\n').filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=ship prs=#1,#2 integrator=#1 (most changed lines)']); + expect(line(plan.out, 'SOLO:')).toBe('SOLO: #3 (no area group of 2+; they ride as single slots)'); + const small = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '1', '--max', '1']); + expect([small.status, small.err]).toEqual([EXIT.ok, '']); + const rows = small.out.split('\n'); + const at = rows.findIndex(l => l.startsWith('WAVE_OVERSIZE:')); + expect(rows[at]).toBe('WAVE_OVERSIZE: area=ship prs=#1,#2 (2 PRs whose changes conflict, over the wave maximum 1; kept whole with no build command: land part of it as single slots, then run wave plan again)'); + expect(rows[at + 1]).not.toContain('build:'); + expect(rows.filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=pregate prs=#3 integrator=#3 (most changed lines)']); + }, 180_000); }); describe('wave settle', () => { From a749249e07e8d48d71f30a61bd7d1ed9841071b5 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:44:28 +0000 Subject: [PATCH 2/5] fix: pre-landing review fixes (test the unreadable merge onto main) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/ship-queue-wave.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/ship-queue-wave.test.ts b/test/ship-queue-wave.test.ts index 8ea60dab6..d3515ac50 100644 --- a/test/ship-queue-wave.test.ts +++ b/test/ship-queue-wave.test.ts @@ -34,9 +34,10 @@ function seedMain(fx: TrainFixture, files: Record, message = 'se fx.repo.git('fetch', '-q', 'origin'); } -/** A READY PR off main writing (or, for null, deleting) files, with green CI and a READY receipt at its head. */ -function readyPr(fx: TrainFixture, n: string, files: Record): string { - fx.repo.git('checkout', '-q', 'origin/main', '-B', `pr-${n}`); +/** A READY PR off main (or, orphan, with no history in common with main) writing (or, for null, deleting) files, with green CI and a READY receipt at its head. */ +function readyPr(fx: TrainFixture, n: string, files: Record, orphan = false): string { + if (orphan) { fx.repo.git('checkout', '-q', '--orphan', `pr-${n}`); fx.repo.git('rm', '-rqf', '.'); } + else fx.repo.git('checkout', '-q', 'origin/main', '-B', `pr-${n}`); for (const [f, t] of Object.entries(files)) { if (t === null) { fs.rmSync(path.join(fx.repo.work, f)); continue; } fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); @@ -362,6 +363,10 @@ describe('ready and train build read the wave', () => { `NOT_MERGEABLE: #1 (conflicts with origin/main at ${conflicting.slice(0, 12)} in lib/ship-a.ts; left out until the head merges cleanly onto main)`, ]); expect(line(plan.out, 'QUEUE:')).toContain('1 live READY PR(s)'); + readyPr(fx, '3', { 'lib/ship-c.ts': 'export const c = 3;\n' }, true); + const unread = queue(fx, ['wave', 'plan']); + expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]); + expect(unread.err).toContain('#3 (merge onto main unreadable: (git merge-tree failed'); }, 180_000); test('wave plan links a rename with an edit of the old path that conflicts with it, not with an edit that merges cleanly, and prints a cluster over --max as WAVE_OVERSIZE with no build command (GST-770 follow-up)', () => { From ec2955526b3615e0f4992e084326db7b7e90a6ec Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 17:05:05 +0000 Subject: [PATCH 3/5] docs: wave plan command comment lists every line it prints (GST-770 follow-up) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 04223f82d..096ae78cc 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -563,7 +563,7 @@ of required checks before it merges, one slot after another, so a wave of three saves two of those rounds and two versions. ```bash -gstack-ship-queue wave plan # read-only (merge-tree scratch objects aside): QUEUE, WAVE_ADVISED, WAVE_GROUP lines +gstack-ship-queue wave plan # read-only (merge-tree scratch objects aside): NOT_GREEN, NOT_MERGEABLE, QUEUE, WAVE_ADVISED, WAVE_GROUP, WAVE_OVERSIZE lines gstack-ship-queue wave build --area ship --prs 41@,42@,43@ gstack-ship-queue wave build --wave --continue # after resolving a conflict in the printed worktree gstack-ship-queue wave build --wave --drop 42 # rebuild without #42 as a new wave From 711bb301767f9a1e108828fe288a4fb290a6aa0b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 17:05:05 +0000 Subject: [PATCH 4/5] docs: changelog fragment for the GST-770 wave plan follow-up and #3558 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/fix-gst-770-wave-followup.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/fix-gst-770-wave-followup.md diff --git a/changelog.d/fix-gst-770-wave-followup.md b/changelog.d/fix-gst-770-wave-followup.md new file mode 100644 index 000000000..3db6c9c45 --- /dev/null +++ b/changelog.d/fix-gst-770-wave-followup.md @@ -0,0 +1,4 @@ +- `gstack-ship-queue wave plan` now leaves out a READY head that does not merge onto main. It prints `NOT_MERGEABLE: # (...)` with the conflicting files, the same way `NOT_GREEN` works for red CI. A conflict only in the CHANGELOG doesn't count, since the train merges those. Before, such a head was planned into a wave and the wave build stopped on it. +- A PR that renames a file and a PR that edits the old path are now linked into one wave group when the edit conflicts with the rename. Before, the conflict was reported at the new path, which the editing PR never touched, so the two ended up in different waves. +- Linked PRs are never split across waves anymore. A group of linked PRs larger than the wave size prints `WAVE_OVERSIZE` with no build command and stays whole. Before, the packer cut such a group at the wave size, so its conflict reached two integrators. +- `gstack-ship-queue ready` reports `STAMP_VERSION=unstamped` for a PR whose own changes leave VERSION alone, even after main moves to a newer version. Before, it compared the head with current main, so an unstamped PR showed its old version as a stamp (#3804 showed 1.92.30.0 while main was 1.92.32.0; #3558). From c90ca7aa491851f22f8d24b01ec2b1a50f753924 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 17:11:10 +0000 Subject: [PATCH 5/5] Revert "docs: wave plan command comment lists every line it prints (GST-770 follow-up)" This reverts commit ec2955526b3615e0f4992e084326db7b7e90a6ec. --- docs/ship-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 096ae78cc..04223f82d 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -563,7 +563,7 @@ of required checks before it merges, one slot after another, so a wave of three saves two of those rounds and two versions. ```bash -gstack-ship-queue wave plan # read-only (merge-tree scratch objects aside): NOT_GREEN, NOT_MERGEABLE, QUEUE, WAVE_ADVISED, WAVE_GROUP, WAVE_OVERSIZE lines +gstack-ship-queue wave plan # read-only (merge-tree scratch objects aside): QUEUE, WAVE_ADVISED, WAVE_GROUP lines gstack-ship-queue wave build --area ship --prs 41@,42@,43@ gstack-ship-queue wave build --wave --continue # after resolving a conflict in the printed worktree gstack-ship-queue wave build --wave --drop 42 # rebuild without #42 as a new wave