train S5: merge #3811 at 8825c0d7c4 v1.93.17.0

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit 9cea71cba1
9 files changed
+160 -31

No files matched your search

+7
View File
@@ -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: #<n> (...)` 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
+1 -1
View File
@@ -1 +1 @@
1.93.16.0
1.93.17.0
+1 -1
View File
@@ -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,
+11 -4
View File
@@ -600,13 +600,20 @@ gstack-ship-queue wave settle --wave <nonce> [--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
+38 -22
View File
@@ -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<string, string> }
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<string, PlanPr[]>();
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<string, string> } | 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<string, string> = {};
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 <pins>`, '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 <pins>`, '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 <n>] [--min <n>] [--max <n>]
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 <n>@<sha>,... [--area <name>] [--min <n>] [--max <n>] [--dry-run]
the integrator branch wave/<nonce>: main plus each pin merged unchanged (train merge
rules), the wave record in refs/notes/gstack-trains, the branch pushed, a frozen notice
+2 -2
View File
@@ -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)`;
+1 -1
View File
@@ -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",
+20
View File
@@ -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)
+79
View File
@@ -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<string, string>, 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<string, string | null>, 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', () => {