diff --git a/CHANGELOG.md b/CHANGELOG.md index f306cfa43..c97693ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.93.17.0] - 2026-11-02 + +- `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). + ## [1.93.16.0] - 2026-11-02 ### Fixed diff --git a/VERSION b/VERSION index 7cdafd83e..a194d2ff7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.93.16.0 +1.93.17.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2926a4a45..12980b593 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.93.16.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.93.17.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index fb1aefa2f..7d944d08c 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -600,13 +600,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 7d5dec193..0a95e7d66 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/package.json b/package.json index f4902d325..c30f64ba2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.93.16", + "version": "1.93.17", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 6a2f6c3cd..005acc918 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=a head whose required targeted reader is missing or failed is never READY (GST-966, gstack-evals#140); // fails_when=ready prints REVIEW_TARGETED verdict=missing or failed and still exits 0, or refuses a covered or unrequired head; // seam=bindShippedReceipt (ready, train build and land share it); no_claim=the ledger recording a clean reader as failed (GSTA-167) diff --git a/test/ship-queue-wave.test.ts b/test/ship-queue-wave.test.ts index d2f0a1ce2..d3515ac50 100644 --- a/test/ship-queue-wave.test.ts +++ b/test/ship-queue-wave.test.ts @@ -24,6 +24,37 @@ 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 (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 }); + 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 +110,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 +349,45 @@ 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)'); + 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)', () => { + 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', () => {