gh-stack 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,877 @@
1
+ // gh-stack join — splice an existing branch (usually one with a PR) into a stack.
2
+ //
3
+ // The three things this has to get right (#32):
4
+ //
5
+ // 1. Sync the incoming branch onto the stack's base BEFORE anything is rebased
6
+ // onto it. The incoming branch is often behind main; rebasing children onto
7
+ // it as-is drags already-merged main commits into every child's diff, and
8
+ // nothing errors. So the incoming branch replays only its OWN commits — the
9
+ // range above its fork point with the trunk — onto the current tip of its
10
+ // new parent (`git rebase --onto <parent-tip> <fork-point> <incoming>`).
11
+ // Only then are children restacked onto it.
12
+ // 2. Retarget PR bases on GitHub. Metadata alone doesn't move them. A base
13
+ // change never closes a PR, so it's safe to do automatically — but only once
14
+ // the synced incoming branch is on origin, or the child PR's diff would show
15
+ // the stale incoming history.
16
+ // 3. Metadata. A new bottom changes the stack's identity: a stack named after
17
+ // its old bottom branch is re-keyed under the new bottom (the old file is
18
+ // tombstoned by writeMetadata, never left behind as a second live copy).
19
+ //
20
+ // Children are restacked by restack's own chain loop, so a conflict stops the
21
+ // same way and resumes with `gh-stack restack --resume`.
22
+ import * as p from "../lib/output.ts";
23
+ import pc from "picocolors";
24
+ import { $ } from "bun";
25
+ import * as git from "../lib/git.ts";
26
+ import {
27
+ findStackForBranch,
28
+ stackBase,
29
+ getChildren,
30
+ getOrderedBranches,
31
+ buildRebaseChain,
32
+ readMetadata,
33
+ writeMetadata,
34
+ } from "../lib/metadata.ts";
35
+ import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
36
+ import { takeSnapshot, resolveRestackBoundary } from "../lib/snapshot.ts";
37
+ import { findOpenPrForBranch, setPrBase } from "../lib/github.ts";
38
+ import { confirmAction, renderStackTree } from "../lib/ui.ts";
39
+ import {
40
+ ensureNoPendingRun,
41
+ findStaleRefs,
42
+ publishBranch,
43
+ resolvePush,
44
+ restackChain,
45
+ } from "./restack.ts";
46
+ import type { Branch, Stack, StackMetadata } from "../types.ts";
47
+
48
+ const HELP = `
49
+ gh-stack join — Splice an existing branch (and its PR) into a stack
50
+
51
+ USAGE
52
+ gh-stack join <branch> Insert as the new bottom of the current stack
53
+ gh-stack join <branch> --above <branch> Insert directly above a branch in the stack
54
+
55
+ The incoming branch is first synced onto its new parent, replaying only its own
56
+ commits (those above its fork point with main), so the branches restacked onto
57
+ it don't inherit unrelated main commits. Then the branches above it are
58
+ restacked onto it, pushed, and their PR bases retargeted on GitHub.
59
+
60
+ Joining at the bottom of a stack named after its old bottom branch renames the
61
+ stack after the new bottom.
62
+
63
+ OPTIONS
64
+ --above <branch> Insert between <branch> and its children (default: below the
65
+ whole stack). --above main is the same as the default.
66
+ --dry-run Show the resulting stack and, for each affected PR, its
67
+ projected commits / files vs its new parent. Changes nothing.
68
+ --no-push Rebase and update metadata locally only — never push or
69
+ retarget PRs (also GH_STACK_NO_PUSH=1). Wins over --yes
70
+ --push Push even when GH_STACK_NO_PUSH or [restack] push = false
71
+ in .gh-stack.toml / user config says not to
72
+ --yes, -y Skip confirmations
73
+ --verbose, -v Show rebase-boundary diagnostics
74
+
75
+ The branch must exist locally and, unless --no-push, on origin. A branch alone
76
+ in a one-branch stack (what ${"`"}submit${"`"} makes of a standalone PR) is absorbed; a
77
+ branch in a larger stack is refused. A branch above the insertion point that was
78
+ cut from an older, since-rewritten version of the incoming branch is refused
79
+ with the rebase that drops its stale copies.
80
+
81
+ CONFLICTS
82
+ Syncing the incoming branch: the stack is untouched until it succeeds.
83
+ Resolve, ${"`"}git rebase --continue${"`"}, and re-run the same join (or
84
+ ${"`"}git rebase --abort${"`"} to drop it).
85
+ Restacking the branches above it: same as restack. Resolve,
86
+ ${"`"}git rebase --continue${"`"}, then ${"`"}gh-stack restack --resume${"`"}.
87
+
88
+ EXAMPLES
89
+ gh-stack join fix-auth --dry-run Preview splicing fix-auth in at the bottom
90
+ gh-stack join fix-auth Splice it in at the bottom
91
+ gh-stack join fix-auth --above feat-1 Splice it in between feat-1 and its child
92
+ `;
93
+
94
+ interface Projection {
95
+ commits: number;
96
+ files: number;
97
+ insertions: number;
98
+ deletions: number;
99
+ }
100
+
101
+ interface ChildPlan {
102
+ branch: string;
103
+ pr: number | null;
104
+ oldParent: string;
105
+ /** First commit below the child's own commits: `boundary..child` is what restack replays. */
106
+ boundary: string;
107
+ projection: Projection;
108
+ }
109
+
110
+ interface JoinPlan {
111
+ stackName: string;
112
+ /** Stack name after the join (differs when a new bottom renames the stack). */
113
+ finalName: string;
114
+ incoming: string;
115
+ incomingPr: number | null;
116
+ /** One-branch stack that held the incoming branch, absorbed by the join. */
117
+ absorbedStack: string | null;
118
+ position: "bottom" | "above";
119
+ newParent: string;
120
+ newParentRef: string;
121
+ newParentTip: string;
122
+ trunkRef: string;
123
+ incomingTip: string;
124
+ /** Fork point: `incomingBoundary..incoming` are the incoming branch's own commits. */
125
+ incomingBoundary: string;
126
+ needsRebase: boolean;
127
+ own: Projection;
128
+ /** Trunk commits the incoming branch lacks (what an unsynced join would leak). */
129
+ behindTrunk: number;
130
+ children: ChildPlan[];
131
+ /** Branches above the children: restacked, their diffs vs their parents unchanged. */
132
+ upstack: string[];
133
+ /** The resulting stack, for display. */
134
+ resulting: Stack;
135
+ }
136
+
137
+ export default async function join(args: string[]): Promise<void> {
138
+ if (args.includes("--help")) {
139
+ console.log(HELP);
140
+ return;
141
+ }
142
+
143
+ const { incoming, above, dryRun, verbose } = parseArgs(args);
144
+ // Same push setting as restack: flag > env > repo/user config ([restack] push).
145
+ const noPush = !(await resolvePush(args)).value;
146
+
147
+ if (await git.isRebaseInProgress()) {
148
+ fail(
149
+ `Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}`,
150
+ );
151
+ }
152
+ await ensureCleanWorkingTree();
153
+ if (!dryRun) await ensureNoPendingRun("join");
154
+
155
+ const meta = await ensureMetadata();
156
+ const startBranch = await git.currentBranch().catch(() => null);
157
+
158
+ p.intro(pc.cyan(dryRun ? "Join Branch into Stack (dry run)" : "Join Branch into Stack"));
159
+
160
+ // Real runs measure against the latest trunk; a dry run stays read-only and
161
+ // uses the last fetch.
162
+ const trunk = await git.trunkBranch();
163
+ if (!dryRun) await $`git fetch origin ${trunk}`.nothrow().quiet();
164
+
165
+ const plan = await buildPlan(meta, { incoming, above, noPush, startBranch, trunk });
166
+ const rerun = rerunCommand(incoming, above, noPush);
167
+ printPlan(plan, startBranch);
168
+
169
+ if (dryRun) {
170
+ p.outro(pc.green("Dry run complete — nothing changed"));
171
+ return;
172
+ }
173
+
174
+ const confirmed = await confirmAction(
175
+ `Join ${pc.yellow(plan.incoming)} into stack ${pc.yellow(plan.stackName)}?`,
176
+ );
177
+ if (!confirmed) {
178
+ p.cancel("Cancelled");
179
+ process.exit(0);
180
+ }
181
+
182
+ // Snapshot the joined stack before any ref moves. It records the incoming
183
+ // branch's pre-sync tip too, so `gh-stack undo` restores it with the rest.
184
+ const joined = await joinedMetadata(plan);
185
+ await takeSnapshot(joined, plan.finalName, "join");
186
+ const stackName = plan.finalName;
187
+
188
+ // ── 1. Sync the incoming branch onto its new parent (own commits only) ──
189
+ // Done BEFORE the metadata is written: a conflict here leaves the stack as it
190
+ // was, so aborting needs nothing else and finishing just means re-running.
191
+ if (plan.needsRebase) {
192
+ p.log.info(
193
+ `Syncing ${pc.yellow(plan.incoming)} onto ${pc.yellow(plan.newParentRef)} — replaying its ${plan.own.commits} own commit(s) ${pc.dim(`(above ${short(plan.incomingBoundary)})`)}`,
194
+ );
195
+ await git.checkout(plan.incoming);
196
+ const ok = await git.rebaseOnto(plan.newParentTip, plan.incomingBoundary, plan.incoming);
197
+ if (!ok) {
198
+ console.log();
199
+ p.log.error(`Rebase conflict syncing ${pc.yellow(plan.incoming)} onto ${plan.newParentRef}.`);
200
+ console.log(" The stack is unchanged. Resolve the conflicts, then re-run the join:");
201
+ console.log(` ${pc.green("git rebase --continue")}`);
202
+ if (startBranch) console.log(` ${pc.green(`git checkout ${startBranch}`)}`);
203
+ console.log(` ${pc.green(rerun)}`);
204
+ console.log();
205
+ console.log(
206
+ ` Or drop it: ${pc.red("git rebase --abort")} ${pc.dim("(nothing else to undo)")}`,
207
+ );
208
+ process.exit(2);
209
+ }
210
+ p.log.success(`${plan.incoming} synced`);
211
+ } else {
212
+ p.log.success(`${plan.incoming} already sits on ${plan.newParentRef} — no sync needed`);
213
+ }
214
+
215
+ // ── 2. Check each child will replay exactly its own commits ──
216
+ // Restack resolves each child's boundary itself; make sure it lands where
217
+ // the plan (and the dry run) said. Checked against the in-memory join (its
218
+ // snapshot included) before the metadata is written, so a stop here leaves
219
+ // the stack as it was and `undo` just puts the incoming branch back.
220
+ for (const child of plan.children) {
221
+ const resolved = await resolveRestackBoundary(joined, plan.incoming, child.branch);
222
+ if (resolved?.sha !== child.boundary) {
223
+ p.log.error(
224
+ `Restack would rebase ${pc.yellow(child.branch)} from ${resolved ? short(resolved.sha) : "(none)"}, not its fork point ${short(child.boundary)} — its diff would be wrong. Stopping.`,
225
+ );
226
+ console.log(` The stack is unchanged. Put ${plan.incoming} back with:`);
227
+ console.log(` ${pc.green("gh-stack undo")}`);
228
+ process.exit(1);
229
+ }
230
+ if (verbose) {
231
+ p.log.info(`${child.branch}: boundary ${short(child.boundary)} (${resolved.source})`);
232
+ }
233
+ }
234
+
235
+ // ── 3. Publish the incoming branch — and confirm it landed before anything
236
+ // is built on it. Still before the metadata write: a declined or
237
+ // rejected push leaves the stack unchanged.
238
+ await publishBranch(joined, plan.incoming, { noPush });
239
+ if (!noPush) {
240
+ const remote = (await git.remoteHeads()).get(plan.incoming);
241
+ if (remote !== (await git.revParse(plan.incoming))) {
242
+ p.log.error(
243
+ `${pc.yellow(plan.incoming)} isn't on origin at its synced tip (push declined or rejected), so nothing was built on it.`,
244
+ );
245
+ console.log(" The stack is unchanged. Publish it and re-run, or re-run locally only:");
246
+ console.log(` ${pc.green(`git push --force-with-lease origin ${plan.incoming}`)}`);
247
+ console.log(` ${pc.green(rerun)}`);
248
+ console.log(` ${pc.green(rerunCommand(incoming, above, true))}`);
249
+ console.log(` Or put ${plan.incoming} back: ${pc.green("gh-stack undo")}`);
250
+ process.exit(1);
251
+ }
252
+ }
253
+
254
+ await writeMetadata(joined);
255
+ const chain = buildRebaseChain(joined.stacks[stackName]!, plan.incoming);
256
+
257
+ // ── 4. Retarget PR bases onto it ──
258
+ const retargeted = noPush ? [] : await retargetPrs(plan);
259
+ if (noPush && expectedRetargets(plan) > 0) {
260
+ p.log.info(
261
+ `PR bases left alone (--no-push) — ${pc.green("gh-stack submit")} publishes and retargets them together.`,
262
+ );
263
+ }
264
+
265
+ // ── 5. Restack everything above it (restack's own loop: conflicts → --resume) ──
266
+ let touched: string[] = [plan.incoming];
267
+ if (chain.length > 1) {
268
+ console.log();
269
+ p.log.info(
270
+ `Restacking ${chain.length - 1} branch(es) onto ${pc.yellow(plan.incoming)}. On a conflict: resolve, ${pc.green("git rebase --continue")}, ${pc.green("gh-stack restack --resume")}${retargeted.length < expectedRetargets(plan) && !noPush ? `, then ${pc.green("gh-stack submit")}` : ""}.`,
271
+ );
272
+ const res = await restackChain(await freshMeta(), stackName, chain, 1, { noPush, verbose });
273
+ touched = [...touched, ...res.touched];
274
+ }
275
+
276
+ // ── 6. Verify: no child carries more than its own commits ──
277
+ await verifyResult(plan);
278
+
279
+ if (startBranch && startBranch !== (await git.currentBranch().catch(() => null))) {
280
+ await $`git checkout ${startBranch}`.nothrow().quiet();
281
+ }
282
+
283
+ const endMeta = await freshMeta();
284
+ const stale = await findStaleRefs(touched, endMeta);
285
+ if (stale.length > 0) {
286
+ console.log();
287
+ if (noPush) {
288
+ p.log.info("Not pushed (--no-push) — these refs are ahead of origin:");
289
+ } else {
290
+ p.log.error("Some branch refs are NOT in sync with origin:");
291
+ }
292
+ for (const { branch, local, remote } of stale) {
293
+ console.log(` ${pc.yellow("↑")} ${branch} local ${short(local)} origin ${remote}`);
294
+ }
295
+ console.log();
296
+ if (noPush) {
297
+ console.log(` Publish and retarget PRs with: ${pc.green("gh-stack submit")}`);
298
+ } else {
299
+ // The join's restack run stays pending until these pushes land, and a
300
+ // pending run blocks merge/join; --resume is what retries and clears it.
301
+ console.log(` Retry the pushes (parents first): ${pc.green("gh-stack restack --resume")}`);
302
+ console.log(` Then retarget any PR bases it couldn't: ${pc.green("gh-stack submit")}`);
303
+ }
304
+ if (!noPush) {
305
+ p.outro(pc.red("Join finished with un-pushed refs"));
306
+ process.exit(1);
307
+ }
308
+ }
309
+
310
+ console.log();
311
+ p.log.info(
312
+ `Refresh the stack diagram in PR descriptions with ${pc.green("gh-stack update-prs")}.`,
313
+ );
314
+ p.outro(
315
+ pc.green(
316
+ `Joined ${plan.incoming} into ${stackName}${noPush ? " (local only)" : ""}${plan.finalName !== plan.stackName ? ` (renamed from ${plan.stackName})` : ""}`,
317
+ ),
318
+ );
319
+ }
320
+
321
+ // ── Planning (read-only) ──
322
+
323
+ async function buildPlan(
324
+ meta: StackMetadata,
325
+ opts: {
326
+ incoming: string;
327
+ above: string | undefined;
328
+ noPush: boolean;
329
+ startBranch: string | null;
330
+ trunk: string;
331
+ },
332
+ ): Promise<JoinPlan> {
333
+ const { incoming, above, noPush, startBranch, trunk } = opts;
334
+ if (incoming === trunk || incoming === "main" || incoming === "master") {
335
+ fail(`${pc.yellow(incoming)} is the trunk — it can't join a stack.`);
336
+ }
337
+ if (!(await git.localBranchExists(incoming))) {
338
+ fail(
339
+ `Branch ${pc.yellow(incoming)} doesn't exist locally.\n\n Fetch it first:\n ${pc.green(`git fetch origin ${incoming}:${incoming}`)}`,
340
+ );
341
+ }
342
+ // Pushing children onto a branch origin doesn't have would leave their PRs
343
+ // (and the incoming PR's base) pointing at nothing. Local-only is fine under
344
+ // --no-push, which publishes nothing.
345
+ if (!noPush && !(await git.remoteBranchExists(incoming))) {
346
+ fail(
347
+ `${pc.yellow(incoming)} isn't on origin, so the branches restacked onto it couldn't be pushed or retargeted.\n\n` +
348
+ ` Publish it first, then re-run:\n ${pc.green(`git push -u origin ${incoming}`)}\n ${pc.green(rerunCommand(incoming, above, false))}\n\n` +
349
+ ` Or join locally only:\n ${pc.green(rerunCommand(incoming, above, true))}`,
350
+ );
351
+ }
352
+
353
+ // Which stack: the one --above names, else the current branch's.
354
+ const aboveIsBase = above !== undefined && isTrunkName(above);
355
+ const anchor = above !== undefined && !aboveIsBase ? above : startBranch;
356
+ let stackName = anchor ? findStackForBranch(meta, anchor) : null;
357
+ if (!stackName && above !== undefined && !aboveIsBase) {
358
+ // --above may name the base of a split stack (a branch in another stack).
359
+ stackName = Object.entries(meta.stacks).find(([, s]) => stackBase(s) === above)?.[0] ?? null;
360
+ }
361
+ if (!stackName) {
362
+ fail(
363
+ above !== undefined && !aboveIsBase
364
+ ? `Branch ${pc.yellow(above)} is not in any stack.`
365
+ : `The current branch is not in a stack.\n\n Check out a branch of the stack to join, or pass ${pc.green("--above <branch>")}.`,
366
+ );
367
+ }
368
+ await ensureValidStack(meta, stackName);
369
+ const stack = meta.stacks[stackName]!;
370
+ const base = stackBase(stack);
371
+
372
+ // Where the incoming branch comes from.
373
+ const owner = findStackForBranch(meta, incoming);
374
+ if (owner === stackName) fail(`${pc.yellow(incoming)} is already in stack ${pc.yellow(owner)}.`);
375
+ let absorbedStack: string | null = null;
376
+ let absorbedEntry: Branch | null = null;
377
+ if (owner) {
378
+ const ownerStack = meta.stacks[owner]!;
379
+ if (Object.keys(ownerStack.branches).length > 1) {
380
+ fail(
381
+ `${pc.yellow(incoming)} belongs to stack ${pc.yellow(owner)}, which has other branches — joining would strand them.\n\n` +
382
+ ` Move the branches separately, or untrack it there first:\n ${pc.green(`gh-stack delete ${incoming} --keep-branch`)}`,
383
+ );
384
+ }
385
+ absorbedStack = owner;
386
+ absorbedEntry = ownerStack.branches[incoming]!;
387
+ }
388
+ const dependent = Object.entries(meta.stacks).find(([, s]) => stackBase(s) === incoming);
389
+ if (dependent) {
390
+ fail(
391
+ `Stack ${pc.yellow(dependent[0])} is built on ${pc.yellow(incoming)}; joining would rewrite its base.\n\n Re-root that stack first (${pc.green("gh-stack restack --onto main")} from it).`,
392
+ );
393
+ }
394
+
395
+ // Where it goes.
396
+ let newParent: string;
397
+ if (above === undefined || aboveIsBase || above === base) {
398
+ newParent = base;
399
+ } else if (above === incoming) {
400
+ fail("A branch can't join above itself.");
401
+ } else if (!stack.branches[above]) {
402
+ fail(`${pc.yellow(above)} is not in stack ${pc.yellow(stackName)}.`);
403
+ } else {
404
+ newParent = above;
405
+ }
406
+ const position = newParent === base ? "bottom" : "above";
407
+ const children = getChildren(stack, newParent);
408
+
409
+ // Children are restacked from their fork point with their current parent,
410
+ // which is only well-defined if they sit on that parent's tip. Trunk-parented
411
+ // roots may lag main — that's sync's business and the fork point is still exact.
412
+ for (const child of children) {
413
+ if (!isTrunkName(newParent) && !(await git.isAncestor(newParent, child))) {
414
+ fail(
415
+ `${pc.yellow(child)} isn't on ${pc.yellow(newParent)}'s current tip — restack the stack before joining:\n ${pc.green("gh-stack restack")}`,
416
+ );
417
+ }
418
+ }
419
+
420
+ const trunkRef = (await git.refExists(`origin/${trunk}`)) ? `origin/${trunk}` : trunk;
421
+ const newParentRef = isTrunkName(newParent)
422
+ ? (await git.refExists(`origin/${newParent}`))
423
+ ? `origin/${newParent}`
424
+ : newParent
425
+ : newParent;
426
+ const newParentTip = await git.revParse(newParentRef);
427
+ const incomingTip = await git.revParse(incoming);
428
+
429
+ // The incoming branch's own commits start above its fork point with the
430
+ // trunk — or above its fork point with the new parent, when that's higher
431
+ // (it was branched off that parent). Never replay what's below.
432
+ const needsRebase = !(await git.isAncestor(newParentTip, incomingTip));
433
+ let incomingBoundary = newParentTip;
434
+ if (needsRebase) {
435
+ const higher = await higherOf(
436
+ await git.mergeBase(incomingTip, trunkRef),
437
+ await git.mergeBase(incomingTip, newParentTip),
438
+ );
439
+ if (!higher) {
440
+ fail(`${pc.yellow(incoming)} shares no history with ${pc.yellow(trunkRef)} — can't sync it.`);
441
+ }
442
+ incomingBoundary = higher;
443
+ }
444
+ const own = await project(incomingBoundary, incomingTip);
445
+ const behindTrunk = await git.commitCount(incomingTip, trunkRef);
446
+
447
+ const childPlans: ChildPlan[] = [];
448
+ const upstack: string[] = [];
449
+ for (const child of children) {
450
+ // A child built on (an earlier tip of) the incoming branch keeps those
451
+ // commits below its own: its fork point with incoming wins when higher.
452
+ // restack's resolver lands on the same commit (the guard checks).
453
+ const boundary = await higherOf(
454
+ await git.mergeBase(child, newParentTip),
455
+ await git.mergeBase(child, incomingTip),
456
+ );
457
+ if (!boundary) fail(`${pc.yellow(child)} shares no history with ${pc.yellow(newParent)}.`);
458
+
459
+ const stale = await staleIncomingCommits(meta, {
460
+ child,
461
+ boundary,
462
+ incoming,
463
+ incomingTip,
464
+ incomingBoundary,
465
+ });
466
+ if (stale.commits.length > 0) {
467
+ const chain = buildRebaseChain(stack, child);
468
+ const linear = chain.every((b) => getChildren(stack, b).length <= 1);
469
+ const top = linear ? chain.at(-1)! : child;
470
+ const updateRefs = top !== child ? "--update-refs " : "";
471
+ const fix = stale.oldTip
472
+ ? `git rebase ${updateRefs}--onto ${incoming} ${short(stale.oldTip)} ${top}`
473
+ : `git rebase -i ${updateRefs}--onto ${incoming} ${short(boundary)} ${top} ${pc.dim("(drop the commits above)")}`;
474
+ fail(
475
+ `${pc.yellow(child)} was cut from an older version of ${pc.yellow(incoming)} that has since been rewritten, and still carries ${stale.commits.length} stale commit(s) from it:\n` +
476
+ stale.commits.map((c) => ` ${pc.dim(c)}`).join("\n") +
477
+ `\n\n Restacking would replay those old copies on top of the current ${incoming}.` +
478
+ `\n Move ${child} onto the current ${incoming} first, then re-run the join:\n` +
479
+ ` ${pc.green(fix)}\n ${pc.green(rerunCommand(incoming, above, noPush))}`,
480
+ );
481
+ }
482
+
483
+ childPlans.push({
484
+ branch: child,
485
+ pr: stack.branches[child]!.pr ?? null,
486
+ oldParent: newParent,
487
+ boundary,
488
+ projection: await project(boundary, child),
489
+ });
490
+ upstack.push(...buildRebaseChain(stack, child).slice(1));
491
+ }
492
+
493
+ // Renaming: a stack named after its old bottom branch follows the bottom —
494
+ // unless that name is taken by another active stack or an archived one
495
+ // (`archive --restore` would then collide).
496
+ const oldRoot = getOrderedBranches(stack)[0];
497
+ const nameFree =
498
+ (!meta.stacks[incoming] || incoming === absorbedStack) && !meta.archive?.[incoming];
499
+ const finalName =
500
+ position === "bottom" && stackName === oldRoot && nameFree ? incoming : stackName;
501
+
502
+ const incomingPr = absorbedEntry?.pr ?? (await findOpenPrForBranch(incoming));
503
+
504
+ const splice = { stackName, finalName, incoming, incomingPr, absorbedStack, newParent };
505
+ const preview = structuredClone(meta);
506
+ applyJoin(preview, { ...splice, children: childPlans });
507
+ const resulting = preview.stacks[finalName]!;
508
+
509
+ return {
510
+ stackName,
511
+ finalName,
512
+ incoming,
513
+ incomingPr,
514
+ absorbedStack,
515
+ position,
516
+ newParent,
517
+ newParentRef,
518
+ newParentTip,
519
+ trunkRef,
520
+ incomingTip,
521
+ incomingBoundary,
522
+ needsRebase,
523
+ own,
524
+ behindTrunk,
525
+ children: childPlans,
526
+ upstack,
527
+ resulting,
528
+ };
529
+ }
530
+
531
+ function printPlan(plan: JoinPlan, startBranch: string | null): void {
532
+ const where =
533
+ plan.position === "bottom"
534
+ ? `as the new bottom of ${pc.yellow(plan.stackName)}`
535
+ : `above ${pc.yellow(plan.newParent)} in ${pc.yellow(plan.stackName)}`;
536
+ p.log.info(`Joining ${pc.yellow(plan.incoming)} ${where}`);
537
+ if (plan.absorbedStack) {
538
+ console.log(
539
+ ` ${pc.dim(`It's alone in stack ${plan.absorbedStack}; that stack is absorbed into this one.`)}`,
540
+ );
541
+ }
542
+ if (plan.finalName !== plan.stackName) {
543
+ console.log(
544
+ ` ${pc.dim(`New bottom: the stack is renamed ${plan.stackName} → ${plan.finalName}.`)}`,
545
+ );
546
+ }
547
+
548
+ console.log();
549
+ p.log.info("Resulting stack:");
550
+ console.log(renderStackTree(plan.resulting, startBranch ?? "", { showNumbers: false }));
551
+ console.log();
552
+
553
+ p.log.info("Projected changes (vs new parent):");
554
+ const inPr = plan.incomingPr ? `#${plan.incomingPr}` : "(no PR)";
555
+ console.log(` ${pc.yellow(plan.incoming)} ${inPr} — parent ${plan.newParent}`);
556
+ console.log(` ${formatProjection(plan.own)}`);
557
+ if (plan.needsRebase) {
558
+ const lag =
559
+ plan.behindTrunk > 0 ? `it's ${plan.behindTrunk} commit(s) behind ${plan.trunkRef}; ` : "";
560
+ console.log(
561
+ ` ${pc.dim(`${lag}synced onto ${plan.newParentRef} first, replaying only its own commits`)}`,
562
+ );
563
+ }
564
+ for (const child of plan.children) {
565
+ const pr = child.pr ? `#${child.pr}` : "(no PR)";
566
+ console.log(
567
+ ` ${pc.yellow(child.branch)} ${pr} — parent ${child.oldParent} → ${pc.green(plan.incoming)}`,
568
+ );
569
+ console.log(` ${formatProjection(child.projection)}`);
570
+ }
571
+ if (plan.upstack.length > 0) {
572
+ console.log(` ${pc.dim(`${plan.upstack.join(", ")}: restacked, diff vs parent unchanged`)}`);
573
+ }
574
+
575
+ const retargets = plannedRetargets(plan);
576
+ if (retargets.length > 0) {
577
+ console.log();
578
+ p.log.info("PR bases to retarget on GitHub:");
579
+ for (const r of retargets) console.log(` #${r.pr} (${r.branch}) → ${pc.green(r.base)}`);
580
+ }
581
+ console.log();
582
+ }
583
+
584
+ // ── Mutation ──
585
+
586
+ /**
587
+ * Fresh metadata with the join applied in memory: the incoming branch inserted,
588
+ * its one-branch stack absorbed, the stack renamed for a new bottom. Not
589
+ * written — the caller writes it once the incoming branch is synced.
590
+ */
591
+ async function joinedMetadata(plan: JoinPlan): Promise<StackMetadata> {
592
+ const meta = await freshMeta();
593
+ if (!meta.stacks[plan.stackName]) {
594
+ fail(`Stack ${pc.yellow(plan.stackName)} disappeared — re-run the join.`);
595
+ }
596
+ applyJoin(meta, plan);
597
+ return meta;
598
+ }
599
+
600
+ /** What `applyJoin` needs from a plan. */
601
+ type JoinSplice = Pick<
602
+ JoinPlan,
603
+ "stackName" | "finalName" | "incoming" | "incomingPr" | "absorbedStack" | "newParent"
604
+ > & { children: { branch: string }[] };
605
+
606
+ /**
607
+ * Apply the join to `meta` in place — no I/O. The dry-run preview and the real
608
+ * write both go through this, so what the preview shows is what gets written.
609
+ */
610
+ export function applyJoin(meta: StackMetadata, plan: JoinSplice): void {
611
+ const stack = meta.stacks[plan.stackName]!;
612
+ const entry: Branch = {
613
+ ...(plan.absorbedStack ? meta.stacks[plan.absorbedStack]?.branches[plan.incoming] : {}),
614
+ parent: plan.newParent,
615
+ };
616
+ if (plan.incomingPr != null) entry.pr = plan.incomingPr;
617
+ // The PR's recorded base is stale now; submit/join re-record it once it's set.
618
+ if (entry.prBase !== plan.newParent) delete entry.prBase;
619
+
620
+ // Dropped, not archived: restoring an archived copy would track the branch in
621
+ // two stacks. writeMetadata tombstones the file under deleted/ (recoverable).
622
+ if (plan.absorbedStack) delete meta.stacks[plan.absorbedStack];
623
+
624
+ for (const child of plan.children) stack.branches[child.branch]!.parent = plan.incoming;
625
+ stack.branches[plan.incoming] = entry;
626
+ if (plan.children.length === 0) stack.last_branch = plan.incoming;
627
+
628
+ if (plan.finalName !== plan.stackName) {
629
+ delete meta.stacks[plan.stackName];
630
+ meta.stacks[plan.finalName] = stack;
631
+ }
632
+ meta.current_stack = plan.finalName;
633
+ }
634
+
635
+ interface Retarget {
636
+ branch: string;
637
+ pr: number;
638
+ base: string;
639
+ }
640
+
641
+ function plannedRetargets(plan: JoinPlan): Retarget[] {
642
+ const out: Retarget[] = [];
643
+ if (plan.incomingPr != null) {
644
+ out.push({ branch: plan.incoming, pr: plan.incomingPr, base: plan.newParent });
645
+ }
646
+ for (const child of plan.children) {
647
+ if (child.pr != null) out.push({ branch: child.branch, pr: child.pr, base: plan.incoming });
648
+ }
649
+ return out;
650
+ }
651
+
652
+ function expectedRetargets(plan: JoinPlan): number {
653
+ return plannedRetargets(plan).length;
654
+ }
655
+
656
+ /**
657
+ * Point the incoming PR at its new parent and each child PR at the incoming
658
+ * branch. The caller has confirmed the synced incoming tip is on origin:
659
+ * against a stale remote copy a child PR's diff would show the very commits
660
+ * the sync dropped.
661
+ */
662
+ async function retargetPrs(plan: JoinPlan): Promise<Retarget[]> {
663
+ const targets = plannedRetargets(plan);
664
+ const done: Retarget[] = [];
665
+ for (const t of targets) {
666
+ if (await setPrBase(t.pr, t.base)) {
667
+ p.log.success(`PR #${t.pr} (${t.branch}) base → ${pc.green(t.base)}`);
668
+ done.push(t);
669
+ } else {
670
+ p.log.warn(`Could not retarget PR #${t.pr} — run ${pc.green("gh-stack submit")} to retry`);
671
+ }
672
+ }
673
+
674
+ // Record the bases we set, so submit skips the redundant edit.
675
+ if (done.length > 0) {
676
+ const meta = await freshMeta();
677
+ const stack = meta.stacks[plan.finalName];
678
+ if (stack) {
679
+ for (const t of done) {
680
+ const entry = stack.branches[t.branch];
681
+ if (entry) entry.prBase = t.base;
682
+ }
683
+ await writeMetadata(meta);
684
+ }
685
+ }
686
+ return done;
687
+ }
688
+
689
+ /**
690
+ * After the run, no branch may carry more commits than planned: extra commits
691
+ * vs the new parent are exactly the #32 failure (merged main commits leaking
692
+ * into a child's diff). Fewer is fine — a rebase drops already-applied patches.
693
+ */
694
+ async function verifyResult(plan: JoinPlan): Promise<void> {
695
+ const checks = [
696
+ { branch: plan.incoming, parent: plan.newParentTip, expected: plan.own.commits },
697
+ ...plan.children.map((c) => ({
698
+ branch: c.branch,
699
+ parent: plan.incoming,
700
+ expected: c.projection.commits,
701
+ })),
702
+ ];
703
+ for (const { branch, parent, expected } of checks) {
704
+ if (!(await git.localBranchExists(branch))) continue;
705
+ const actual = await git.commitCount(parent, branch);
706
+ if (actual > expected) {
707
+ p.log.warn(
708
+ `${pc.yellow(branch)} has ${actual} commit(s) vs its parent; ${expected} expected. Check its diff before review (${pc.green("gh-stack undo")} restores the previous state).`,
709
+ );
710
+ }
711
+ }
712
+ }
713
+
714
+ // ── Helpers ──
715
+
716
+ const USAGE =
717
+ "gh-stack join <branch> [--above <branch>] [--dry-run] [--push|--no-push] [--verbose]";
718
+
719
+ /**
720
+ * Strict parsing: an unknown flag or a stray positional is an error, never
721
+ * ignored — a typo like `--abvoe pr1` must not quietly become a bottom join.
722
+ */
723
+ export function parseArgs(args: string[]): {
724
+ incoming: string;
725
+ above: string | undefined;
726
+ dryRun: boolean;
727
+ verbose: boolean;
728
+ } {
729
+ let incoming: string | undefined;
730
+ let above: string | undefined;
731
+ let dryRun = false;
732
+ let verbose = false;
733
+ for (let i = 0; i < args.length; i++) {
734
+ const arg = args[i]!;
735
+ if (arg === "--above") {
736
+ above = args[++i];
737
+ if (!above || above.startsWith("-")) usageError("--above needs a branch name.");
738
+ } else if (arg === "--dry-run") dryRun = true;
739
+ else if (arg === "--push" || arg === "--no-push") continue; // read by resolvePush
740
+ else if (arg === "--verbose" || arg === "-v") verbose = true;
741
+ else if (arg.startsWith("-")) usageError(`Unknown option ${pc.yellow(arg)}.`);
742
+ else if (incoming === undefined) incoming = arg;
743
+ else usageError(`Unexpected argument ${pc.yellow(arg)} — join takes one branch.`);
744
+ }
745
+ if (!incoming) usageError("Which branch should join the stack?");
746
+ return { incoming, above, dryRun, verbose };
747
+ }
748
+
749
+ function usageError(message: string): never {
750
+ fail(`${message}\n\n Usage: ${pc.green(USAGE)}\n See ${pc.green("gh-stack join --help")}.`);
751
+ }
752
+
753
+ function fail(message: string): never {
754
+ p.cancel(message);
755
+ process.exit(1);
756
+ }
757
+
758
+ function isTrunkName(name: string): boolean {
759
+ return name === "main" || name === "master";
760
+ }
761
+
762
+ function short(sha: string): string {
763
+ return sha.slice(0, 8);
764
+ }
765
+
766
+ async function freshMeta(): Promise<StackMetadata> {
767
+ const meta = await readMetadata();
768
+ if (!meta) fail("Stack metadata disappeared.");
769
+ return meta;
770
+ }
771
+
772
+ /** Of two commits, the one that descends from the other (the safer rebase boundary). */
773
+ async function higherOf(a: string | null, b: string | null): Promise<string | null> {
774
+ if (!a || !b || a === b) return a ?? b;
775
+ if (await git.isAncestor(a, b)) return b;
776
+ if (await git.isAncestor(b, a)) return a;
777
+ return a; // unrelated forks: the first (the parent/trunk fork point) is the defined one
778
+ }
779
+
780
+ /**
781
+ * The join command to re-run, with the user's --above kept and the push
782
+ * decision spelled out, so a config default can't flip it on the re-run.
783
+ */
784
+ function rerunCommand(incoming: string, above: string | undefined, noPush: boolean): string {
785
+ return `gh-stack join ${incoming}${above !== undefined ? ` --above ${above}` : ""}${noPush ? " --no-push" : " --push"}`;
786
+ }
787
+
788
+ /**
789
+ * Commits in `boundary..child` that are stale copies of the incoming branch's
790
+ * history: the child was cut from incoming, then incoming was rewritten.
791
+ * Restacking would replay them on top of the rewritten incoming.
792
+ *
793
+ * Two signals: an old incoming tip (from its reflog, origin/<incoming>'s
794
+ * reflog, or a snapshot) that the child contains beyond its boundary — which
795
+ * also pins the right fork point — and commits whose patch-id matches one of
796
+ * incoming's own commits (rewritten history with no surviving old tip).
797
+ */
798
+ async function staleIncomingCommits(
799
+ meta: StackMetadata,
800
+ s: {
801
+ child: string;
802
+ boundary: string;
803
+ incoming: string;
804
+ incomingTip: string;
805
+ incomingBoundary: string;
806
+ },
807
+ ): Promise<{ oldTip: string | null; commits: string[] }> {
808
+ const tips = new Set<string>();
809
+ for (const ref of [`refs/heads/${s.incoming}`, `refs/remotes/origin/${s.incoming}`]) {
810
+ const { stdout } = await $`git reflog show --format=%H ${ref}`.nothrow().quiet();
811
+ for (const sha of stdout.toString().split("\n").filter(Boolean).slice(0, 100)) tips.add(sha);
812
+ }
813
+ for (const snap of meta.snapshots ?? []) {
814
+ const sha = snap.branches[s.incoming];
815
+ if (sha) tips.add(sha);
816
+ }
817
+ tips.delete(s.incomingTip);
818
+
819
+ let oldTip: string | null = null;
820
+ for (const tip of tips) {
821
+ if (!(await git.isAncestor(tip, s.child)) || (await git.isAncestor(tip, s.boundary))) continue;
822
+ if (!oldTip || (await git.isAncestor(oldTip, tip))) oldTip = tip;
823
+ }
824
+
825
+ const commits: string[] = [];
826
+ const seen = new Set<string>();
827
+ if (oldTip) {
828
+ const { stdout } = await $`git log --reverse --format=${"%h %s"} ${s.boundary}..${oldTip}`
829
+ .nothrow()
830
+ .quiet();
831
+ for (const line of stdout.toString().split("\n").filter(Boolean)) {
832
+ commits.push(line);
833
+ seen.add(line.split(" ")[0]!);
834
+ }
835
+ }
836
+
837
+ const incomingPatches = new Set((await patchIds(s.incomingBoundary, s.incomingTip)).keys());
838
+ for (const [patch, sha] of await patchIds(s.boundary, s.child)) {
839
+ if (!incomingPatches.has(patch)) continue;
840
+ const { stdout } = await $`git log -1 --format=${"%h %s"} ${sha}`.nothrow().quiet();
841
+ const line = stdout.toString().trim();
842
+ if (line && !seen.has(line.split(" ")[0]!)) commits.push(line);
843
+ }
844
+ return { oldTip, commits };
845
+ }
846
+
847
+ /** patch-id → commit sha for the non-merge commits in `from..to`, oldest first. */
848
+ async function patchIds(from: string, to: string): Promise<Map<string, string>> {
849
+ const { stdout } =
850
+ await $`git log -p --reverse --no-merges ${from}..${to} | git patch-id --stable`
851
+ .nothrow()
852
+ .quiet();
853
+ const ids = new Map<string, string>();
854
+ for (const line of stdout.toString().split("\n").filter(Boolean)) {
855
+ const [patch, sha] = line.split(" ");
856
+ if (patch && sha && !ids.has(patch)) ids.set(patch, sha);
857
+ }
858
+ return ids;
859
+ }
860
+
861
+ /** Commits and diff size of `from..to` — what a branch's PR shows vs its parent. */
862
+ async function project(from: string, to: string): Promise<Projection> {
863
+ const commits = await git.commitCount(from, to);
864
+ const { stdout } = await $`git diff --shortstat ${from} ${to}`.nothrow().quiet();
865
+ const text = stdout.toString();
866
+ const num = (re: RegExp) => Number(re.exec(text)?.[1] ?? 0);
867
+ return {
868
+ commits,
869
+ files: num(/(\d+) files? changed/),
870
+ insertions: num(/(\d+) insertions?\(\+\)/),
871
+ deletions: num(/(\d+) deletions?\(-\)/),
872
+ };
873
+ }
874
+
875
+ function formatProjection(pr: Projection): string {
876
+ return `${pr.commits} commit(s), ${pr.files} file(s) changed, ${pc.green(`+${pr.insertions}`)} ${pc.red(`-${pr.deletions}`)}`;
877
+ }