gh-stack 0.19.6 → 0.19.8

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.
package/README.md CHANGED
@@ -24,12 +24,12 @@ bun install -g gh-stack
24
24
 
25
25
  ## Terms
26
26
 
27
- | Term | Definition |
28
- |------|-----------|
29
- | **trunk** | Base branch of the repository (usually `main`) |
30
- | **stack** | A chain of dependent branches |
31
- | **upstack** | Branches that depend on the current branch (children) |
32
- | **downstack** | Branches the current branch depends on (ancestors) |
27
+ | Term | Definition |
28
+ | ------------- | ----------------------------------------------------- |
29
+ | **trunk** | Base branch of the repository (usually `main`) |
30
+ | **stack** | A chain of dependent branches |
31
+ | **upstack** | Branches that depend on the current branch (children) |
32
+ | **downstack** | Branches the current branch depends on (ancestors) |
33
33
 
34
34
  ## Quick Start
35
35
 
@@ -268,9 +268,11 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
268
268
  If the bottom PR(s) already merged outside gh-stack (e.g. the GitHub
269
269
  web UI, or the base of a `merge --approved` landing with the PRs
270
270
  collapsed into it), sync detects this and advances the stack past
271
- them: it drops every merged branch and re-roots only the first
272
- unmerged one onto main with `--onto`, so only its own commits replay,
273
- instead of replaying the now-squashed commits and conflicting. It
271
+ them: it drops every merged branch and re-roots each unmerged branch
272
+ sitting on them onto main with `--onto`, so only their own commits
273
+ replay, instead of replaying the now-squashed commits and conflicting.
274
+ When a merged branch had several children, each child (with the
275
+ branches above it) becomes its own stack and is synced in turn. It
274
276
  warns about a merged PR whose changes never reached main (merged
275
277
  into a branch after that branch's own PR had landed).
276
278
 
@@ -287,7 +289,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
287
289
  via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
288
290
  into main. The whole stack lands on main as a single squash commit.
289
291
  All merges happen on GitHub so PRs show as "Merged", Linear tickets
290
- close automatically, and GitHub Actions fire normally. Skips already-
292
+ close automatically, and GitHub Actions fire normally. Squash commit
293
+ subjects leave out the ` (N/M)` stack position that `submit` adds to
294
+ PR titles. Skips already-
291
295
  merged PRs (safe to re-run). Waits for GitHub between merges, and
292
296
  self-heals its spurious "Head branch is out of date" (a stale PR
293
297
  head pointer after a child squash) by re-syncing and retrying. Before
@@ -326,8 +330,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
326
330
  full merge.
327
331
  --base Squash-merge ONLY the bottom PR into main as its own
328
332
  commit, then re-root the next branch onto main (replaying
329
- just its own commits). One PR per run — run it again to
330
- land the next. Approval is enforced by GitHub branch
333
+ just its own commits). If the bottom PR has several
334
+ children, each is re-rooted and becomes its own stack.
335
+ One PR per run — run it again to land the next. Approval is enforced by GitHub branch
331
336
  protection, not gh-stack. A base PR already merged outside
332
337
  gh-stack is detected and advanced past, along with
333
338
  PRs already collapsed into it. The re-root waits
@@ -433,7 +438,8 @@ delete [<branch>] [-k|--keep-branch] [--no-remote]
433
438
  delete the underlying local git branch (and the remote branch if
434
439
  it was pushed). The <branch> argument names its own stack, so this
435
440
  works from anywhere. Interactive selector (current stack) if none
436
- specified.
441
+ specified. Deleting a stack's root that has several children leaves
442
+ them side by side on the base, so each becomes its own stack.
437
443
  --keep-branch leaves the git branches untouched (metadata only);
438
444
  --no-remote deletes the local branch but keeps the remote.
439
445
  ```
@@ -490,9 +496,12 @@ config [--json]
490
496
  only). Read-only; edit the TOML files directly.
491
497
 
492
498
  doctor
493
- Migrate old (v2) metadata to the v3 layout, reconcile git branch
494
- config against the topology files, and flag stacks whose base
495
- stack appears already-merged into main. Safe to run repeatedly.
499
+ Migrate old (v2) metadata to the v3 layout, split any stack left
500
+ with several root branches into one stack per root, reconcile git
501
+ branch config against the topology files, flag stacks whose base
502
+ stack appears already-merged into main, and report stacks that
503
+ still fail the checks other commands run before loading a stack.
504
+ Safe to run repeatedly.
496
505
 
497
506
  learn [--skill] [--harness <name>] [--global] [--force]
498
507
  Teach a coding agent how to drive gh-stack. By default prints the
@@ -553,10 +562,10 @@ remember to type them:
553
562
  # .gh-stack.toml at the repo root (commit it), or
554
563
  # ~/.config/gh-stack/config.toml for personal defaults (honors XDG_CONFIG_HOME)
555
564
  [submit]
556
- draft = true # new PRs open as drafts; --no-draft overrides for one PR
565
+ draft = true # new PRs open as drafts; --no-draft overrides for one PR
557
566
 
558
567
  [restack]
559
- push = false # restack and sync stay local; --push overrides
568
+ push = false # restack and sync stay local; --push overrides
560
569
  ```
561
570
 
562
571
  Precedence, highest first: **CLI flag > env var > repo config > user config >
@@ -670,7 +679,7 @@ A single `active/<stack>.json` looks like:
670
679
  }
671
680
  ```
672
681
 
673
- Per-branch membership is *also* recorded in git's own config, so renaming or
682
+ Per-branch membership is _also_ recorded in git's own config, so renaming or
674
683
  deleting a branch updates/cleans it automatically:
675
684
 
676
685
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.6",
3
+ "version": "0.19.8",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  // gh-stack delete — Delete a branch from the stack (re-link parent chain)
2
2
  import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
- import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
4
+ import { removeBranchFromStack, getChildren, stackBase } from "../lib/metadata.ts";
5
5
  import {
6
6
  ensureMetadata,
7
7
  ensureCurrentStack,
@@ -24,6 +24,9 @@ Removes the branch from the stack and deletes the underlying git branch,
24
24
  re-parenting any children to the removed branch's parent. If the deleted
25
25
  branch was pushed, its remote counterpart is deleted too.
26
26
 
27
+ Deleting a stack's root that has several children leaves them side by side on
28
+ the base, so each child (with the branches above it) becomes its own stack.
29
+
27
30
  If no branch is specified, shows an interactive selector.
28
31
 
29
32
  OPTIONS
@@ -84,6 +87,13 @@ OPTIONS
84
87
  for (const child of children) {
85
88
  console.log(` ${pc.dim("→")} ${child}`);
86
89
  }
90
+ if (children.length > 1 && branch.parent === stackBase(stack)) {
91
+ p.log.info(
92
+ pc.dim(
93
+ `They'll sit side by side on ${branch.parent}, so each becomes its own stack (${children[0]} keeps ${stackName}).`,
94
+ ),
95
+ );
96
+ }
87
97
  }
88
98
 
89
99
  if (keepBranch) {
@@ -116,8 +126,13 @@ OPTIONS
116
126
  p.log.info(`Switched to ${pc.blue(fallback)} (was on the deleted branch)`);
117
127
  }
118
128
 
119
- // Remove from metadata (re-parents children, fixes last_branch)
120
- await removeBranchFromStack(meta, stackName, branchName);
129
+ // Remove from metadata (re-parents children, fixes last_branch, splits siblings)
130
+ const split = await removeBranchFromStack(meta, stackName, branchName);
131
+ if (split.length > 0) {
132
+ p.log.success(
133
+ `Split off ${split.map((s) => pc.yellow(s)).join(", ")} beside ${pc.blue(stackName)}`,
134
+ );
135
+ }
121
136
 
122
137
  // Delete the actual git branch(es)
123
138
  if (hasRemote) {
@@ -11,7 +11,10 @@ import {
11
11
  v3Exists,
12
12
  legacyMetadataExists,
13
13
  stackBase,
14
+ stackRoots,
15
+ splitMultiRootStack,
14
16
  } from "../lib/metadata.ts";
17
+ import { validateStack } from "../lib/safety.ts";
15
18
  import { legacyMetadataPath, legacyRestackStatePath, restackStateFile } from "../lib/paths.ts";
16
19
  import { writeSnapshotFile } from "../lib/snapshot.ts";
17
20
  import { allBranchMemberships } from "../lib/branch-config.ts";
@@ -23,9 +26,11 @@ USAGE
23
26
  gh-stack doctor
24
27
 
25
28
  Migrates old (v2) metadata to the v3 layout (per-stack files under
26
- .git/.gh-stack/ + git branch config), then reconciles branch config
27
- against the topology files and flags stacks whose base stack looks
28
- already-merged into main.
29
+ .git/.gh-stack/ + git branch config), splits any stack left with several
30
+ root branches into one stack per root, reconciles branch config against
31
+ the topology files, flags stacks whose base stack looks already-merged
32
+ into main, and reports stacks that still fail the checks other commands
33
+ run before they load a stack.
29
34
 
30
35
  Safe to run repeatedly — it's idempotent.
31
36
  `;
@@ -54,19 +59,59 @@ export default async function doctor(args: string[]): Promise<void> {
54
59
  process.exit(1);
55
60
  }
56
61
 
57
- // ── 2. Reconcile branch config ↔ topology ──
62
+ // ── 2. Split stacks left with several roots (#60) ──
63
+ splitMultiRootStacks(meta);
64
+
65
+ // ── 3. Reconcile branch config ↔ topology (also writes the split) ──
58
66
  await reconcile(meta);
59
67
 
60
- // ── 3. Flag base-stack-merged situations (issue #13) ──
68
+ // ── 4. Flag base-stack-merged situations (issue #13) ──
61
69
  await flagMergedBaseStacks(meta);
62
70
 
63
- if (migrated) {
71
+ // ── 5. Run the checks every command runs before it loads a stack ──
72
+ const invalid = await reportInvalidStacks(meta);
73
+
74
+ if (invalid > 0) {
75
+ p.outro(pc.yellow(`${invalid} stack(s) still need repair (see above).`));
76
+ } else if (migrated) {
64
77
  p.outro(pc.green("Migration complete — metadata is now v3."));
65
78
  } else {
66
79
  p.outro(pc.green("Metadata looks healthy."));
67
80
  }
68
81
  }
69
82
 
83
+ // ── Multi-root repair (#60) ──
84
+
85
+ /**
86
+ * A stack whose root left with several children has several roots, which every
87
+ * command refuses. Siblings on the same base are independent stacks, so give
88
+ * each its own: lossless, and only metadata changes. The caller writes.
89
+ */
90
+ function splitMultiRootStacks(meta: StackMetadata): void {
91
+ for (const name of Object.keys(meta.stacks)) {
92
+ const roots = stackRoots(meta.stacks[name]!);
93
+ const split = splitMultiRootStack(meta, name);
94
+ if (split.length === 0) continue;
95
+ p.log.success(
96
+ `Split ${pc.yellow(name)}, which had ${roots.length} root branches: ${pc.yellow(roots[0]!)} stays, ${split.map((s) => pc.yellow(s)).join(", ")} split off`,
97
+ );
98
+ }
99
+ }
100
+
101
+ /** Report the stacks that still fail validation; returns how many. */
102
+ async function reportInvalidStacks(meta: StackMetadata): Promise<number> {
103
+ let invalid = 0;
104
+ for (const name of Object.keys(meta.stacks)) {
105
+ const errors = await validateStack(meta, name);
106
+ if (errors.length === 0) continue;
107
+ invalid++;
108
+ console.log();
109
+ p.log.warn(`Stack ${pc.yellow(name)} is invalid:`);
110
+ for (const error of errors) console.log(` ${pc.red("•")} ${error}`);
111
+ }
112
+ return invalid;
113
+ }
114
+
70
115
  // ── Migration ──
71
116
 
72
117
  async function migrateIfNeeded(): Promise<boolean> {
@@ -6,7 +6,6 @@ import * as git from "../lib/git.ts";
6
6
  import {
7
7
  findStackForBranch,
8
8
  getOrderedBranches,
9
- removeBranchFromStack,
10
9
  stackBase,
11
10
  writeMetadata,
12
11
  } from "../lib/metadata.ts";
@@ -26,9 +25,11 @@ import type { Stack } from "../types.ts";
26
25
  import { confirmAction, isAutoYes } from "../lib/ui.ts";
27
26
  import { guardRemoteContent } from "../lib/remote-guard.ts";
28
27
  import { ensureNoPendingRun } from "./restack.ts";
28
+ import { stripSeqSuffix } from "./update-prs.ts";
29
29
  import {
30
30
  planMerges,
31
- advanceNewBottom,
31
+ advancePastMerged,
32
+ survivorsOf,
32
33
  type BranchReview,
33
34
  type MergeAction,
34
35
  } from "../lib/advance.ts";
@@ -62,6 +63,24 @@ export function isTransientMergeError(stderr: string): boolean {
62
63
  );
63
64
  }
64
65
 
66
+ /**
67
+ * The squash commit subject for a PR titled `title`, without the ` (N/M)`
68
+ * stack position `submit` adds to PR titles, which GitHub would otherwise put
69
+ * on the trunk's commit. Null when the title has no position, so GitHub's own
70
+ * "<title> (#N)" stands.
71
+ */
72
+ export function squashSubject(title: string, prNumber: number): string | null {
73
+ const base = stripSeqSuffix(title);
74
+ return base === title.trimEnd() ? null : `${base} (#${prNumber})`;
75
+ }
76
+
77
+ /** `gh pr merge` args setting the squash subject (see squashSubject); empty to keep GitHub's. */
78
+ async function squashSubjectArgs(prNumber: number): Promise<string[]> {
79
+ const title = (await getPrInfo(prNumber))?.title;
80
+ const subject = title ? squashSubject(title, prNumber) : null;
81
+ return subject ? ["--subject", subject] : [];
82
+ }
83
+
65
84
  /**
66
85
  * Squash-merge a PR, healing the two timing artifacts a top-down cascade
67
86
  * provokes — neither of which is a real conflict:
@@ -87,8 +106,9 @@ async function squashMergeWithRetry(
87
106
  s: ReturnType<typeof p.spinner>,
88
107
  ): Promise<{ ok: true } | { ok: false; stderr: string; genuine: boolean }> {
89
108
  const maxAttempts = MERGE_RETRY_BACKOFF_MS.length + 1;
109
+ const subject = await squashSubjectArgs(prNumber);
90
110
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
91
- const ghArgs = ["pr", "merge", String(prNumber), "--squash"];
111
+ const ghArgs = ["pr", "merge", String(prNumber), "--squash", ...subject];
92
112
  if (deleteFlag) ghArgs.push("--delete-branch");
93
113
 
94
114
  const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
@@ -423,7 +443,8 @@ OPTIONS
423
443
  : "Single branch stack — enabling auto-merge on GitHub.",
424
444
  );
425
445
  if (!dryRun) {
426
- const auto = await $`gh pr merge ${basePr} --squash --auto`.nothrow().quiet();
446
+ const subject = await squashSubjectArgs(basePr);
447
+ const auto = await $`gh pr merge ${basePr} --squash --auto ${subject}`.nothrow().quiet();
427
448
  if (auto.exitCode === 0) {
428
449
  p.log.success(`Auto-merge enabled for PR #${basePr}`);
429
450
  } else {
@@ -712,6 +733,7 @@ OPTIONS
712
733
 
713
734
  const enableAutoMerge = async (): Promise<{ ok: boolean; stderr: string }> => {
714
735
  const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
736
+ if (basePr != null) ghArgs.push(...(await squashSubjectArgs(basePr)));
715
737
  if (deleteFlag) ghArgs.push("--delete-branch");
716
738
  const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
717
739
  const stderr = await new Response(proc.stderr).text();
@@ -1032,7 +1054,8 @@ function resolveMergePush(args: string[]): Resolved {
1032
1054
  /**
1033
1055
  * `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
1034
1056
  * re-root the next branch onto main (replaying just its own commits, via
1035
- * advance.ts's fork-point resolution). One PR per run — run it again to land the
1057
+ * advance.ts's fork-point resolution); every child, each its own stack, when
1058
+ * the bottom PR has several (#60). One PR per run — run it again to land the
1036
1059
  * next. Approval is NOT checked here; GitHub branch protection enforces it.
1037
1060
  * A base PR already merged outside gh-stack is detected and advanced past.
1038
1061
  */
@@ -1107,7 +1130,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1107
1130
  // own) go with it: dropped, never re-rooted, since replaying their commits
1108
1131
  // onto a trunk that holds them squashed conflicts (#54).
1109
1132
  const mergedAbove = await mergedRunAbove(stack, ordered);
1110
- const nextChild = ordered[1 + mergedAbove.length] ?? null;
1133
+ const mergedRun = [baseBranch, ...mergedAbove];
1134
+ // Every unmerged branch on the run is re-rooted: one per child (#60).
1135
+ const survivors = survivorsOf(stack, mergedRun);
1111
1136
 
1112
1137
  console.log();
1113
1138
  console.log(` ${pc.bold("Plan:")}`);
@@ -1123,10 +1148,16 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1123
1148
  ` ${pc.dim(`drop ${mergedAbove.join(", ")} (already merged into the branch below)`)}`,
1124
1149
  );
1125
1150
  }
1126
- if (nextChild) {
1151
+ if (survivors.length === 1) {
1152
+ console.log(
1153
+ ` ${pc.dim(`then re-root ${survivors[0]} onto ${trunk} — the rest stays a stack`)}`,
1154
+ );
1155
+ } else if (survivors.length > 1) {
1127
1156
  console.log(
1128
- ` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
1157
+ ` ${pc.dim(`then re-root ${survivors.join(", ")} onto ${trunk} — each becomes its own stack`)}`,
1129
1158
  );
1159
+ }
1160
+ if (survivors.length > 0) {
1130
1161
  if (!push) {
1131
1162
  console.log(
1132
1163
  ` ${pc.dim(`(--no-push: re-rooted branches stay local; ${pc.green("gh-stack submit")} publishes them)`)}`,
@@ -1141,8 +1172,8 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1141
1172
  }
1142
1173
 
1143
1174
  const confirmed = await confirmAction(
1144
- nextChild
1145
- ? `Merge base PR #${basePr} into ${trunk} and re-root ${nextChild}?`
1175
+ survivors.length > 0
1176
+ ? `Merge base PR #${basePr} into ${trunk} and re-root ${survivors.join(", ")}?`
1146
1177
  : `Merge base PR #${basePr} into ${trunk}?`,
1147
1178
  );
1148
1179
  if (!confirmed) {
@@ -1201,12 +1232,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1201
1232
  await takeSnapshot(meta, stackName, "merge-base");
1202
1233
  // Keep a copy of the original topology to archive if the stack fully drains.
1203
1234
  const originalStack = structuredClone(stack);
1235
+ let split: string[] = [];
1204
1236
 
1205
1237
  for (let i = 0; i < plan.steps.length; i++) {
1206
1238
  const step = plan.steps[i]!;
1207
1239
  const mergedBranch = step.branch;
1208
- const child = nextChild;
1209
- const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
1210
1240
 
1211
1241
  console.log();
1212
1242
  console.log(pc.cyan("━".repeat(40)));
@@ -1253,7 +1283,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1253
1283
  p.log.info(`PR #${step.pr} already merged — advancing the stack past it.`);
1254
1284
  }
1255
1285
 
1256
- if (child) {
1286
+ if (survivors.length > 0) {
1257
1287
  // Re-root onto the trunk as it is now, with the merge in it (#51).
1258
1288
  const trunkState = await fetchTrunkWithMerge(trunk, step.pr!);
1259
1289
  if (!trunkState.ok) {
@@ -1266,56 +1296,53 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1266
1296
  } yet.`,
1267
1297
  );
1268
1298
  console.log(
1269
- ` Stopped before re-rooting ${pc.yellow(child)}: a ${trunk} without the merge would drop its changes from the stack.`,
1299
+ ` Stopped before re-rooting ${pc.yellow(survivors.join(", "))}: a ${trunk} without the merge would drop its changes from the stack.`,
1270
1300
  );
1271
1301
  console.log(` Once ${trunk} has it, finish with:`);
1272
1302
  console.log(` ${pc.green("gh-stack merge --base")}`);
1273
1303
  process.exit(2);
1274
1304
  }
1305
+ }
1275
1306
 
1276
- // The child sits on the last branch of the merged run.
1277
- const mergedParent = stack.branches[child]!.parent;
1278
- const res = await advanceNewBottom({
1279
- meta,
1280
- stackName,
1281
- mergedBranch: mergedParent,
1282
- alsoMerged: [mergedBranch, ...mergedAbove].filter((b) => b !== mergedParent),
1283
- child,
1284
- childPr,
1285
- targetRef,
1286
- recordedBase: trunk,
1287
- push,
1288
- expect: verified.get(child),
1289
- });
1290
- if (!res.ok) {
1307
+ const res = await advancePastMerged({
1308
+ meta,
1309
+ stackName,
1310
+ merged: mergedRun,
1311
+ survivors: survivors.map((branch) => ({
1312
+ branch,
1313
+ pr: stack.branches[branch]?.pr ?? null,
1314
+ expect: verified.get(branch),
1315
+ })),
1316
+ targetRef,
1317
+ recordedBase: trunk,
1318
+ push,
1319
+ });
1320
+ if (!res.ok) {
1321
+ console.log();
1322
+ if (res.reason === "conflict") {
1323
+ p.log.error(`Rebase conflict re-rooting ${pc.yellow(res.child)} onto ${trunk}.`);
1324
+ console.log(` Resolve the conflict, then finish with:`);
1325
+ console.log(` ${pc.green("git rebase --continue")}`);
1326
+ console.log(
1327
+ ` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${res.child}, retargets its PR, restacks the rest)`)}`,
1328
+ );
1291
1329
  console.log();
1292
- if (res.reason === "conflict") {
1293
- p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
1294
- console.log(` Resolve the conflict, then finish with:`);
1295
- console.log(` ${pc.green("git rebase --continue")}`);
1296
- console.log(
1297
- ` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${child}, retargets its PR, restacks the rest)`)}`,
1298
- );
1299
- console.log();
1300
- console.log(
1301
- pc.dim(` The merged PR(s) below ${child} are done; only the re-root needs finishing.`),
1302
- );
1303
- } else {
1304
- p.log.error(
1305
- `Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
1306
- );
1307
- console.log(
1308
- ` Re-root it manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${child}`)}`,
1309
- );
1310
- }
1311
- process.exit(2);
1312
- }
1313
- } else {
1314
- // Nothing unmerged rides above it. Just drop the merged run.
1315
- for (const branch of [mergedBranch, ...mergedAbove]) {
1316
- await removeBranchFromStack(meta, stackName, branch);
1330
+ console.log(
1331
+ pc.dim(
1332
+ ` The merged PR(s) below ${res.child} are done; only the re-root needs finishing.`,
1333
+ ),
1334
+ );
1335
+ } else {
1336
+ p.log.error(
1337
+ `Could not determine a safe rebase base for ${pc.yellow(res.child)} (${res.reason}).`,
1338
+ );
1339
+ console.log(
1340
+ ` Re-root it manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${res.child}`)}`,
1341
+ );
1317
1342
  }
1343
+ process.exit(2);
1318
1344
  }
1345
+ split = res.split;
1319
1346
  }
1320
1347
 
1321
1348
  // Did the whole stack drain, or is there a tail to restack?
@@ -1345,17 +1372,20 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1345
1372
  return;
1346
1373
  }
1347
1374
 
1348
- // Restack the surviving tail onto the newly re-rooted bottom. The new bottom
1349
- // is itself already rebased onto main; restack skips it (base branch) and
1350
- // propagates onto its children using the snapshot taken above.
1351
- console.log();
1352
- p.log.info(
1353
- `Restacking ${remaining.length} remaining branch(es) onto ${pc.yellow(remaining[0]!)}...`,
1354
- );
1355
- const newBottom = remaining[0]!;
1356
- if (await git.localBranchExists(newBottom)) {
1375
+ // Restack the surviving tail onto the newly re-rooted bottom, then each stack
1376
+ // split off beside it (#60). Each new bottom is already rebased onto main;
1377
+ // restack skips it (base branch) and propagates onto its children using the
1378
+ // snapshot taken above.
1379
+ const { default: restack } = await import("./restack.ts");
1380
+ for (const name of [stackName, ...split]) {
1381
+ const tail = meta.stacks[name] ? getOrderedBranches(meta.stacks[name]) : [];
1382
+ const newBottom = tail[0];
1383
+ if (!newBottom || !(await git.localBranchExists(newBottom))) continue;
1384
+ console.log();
1385
+ p.log.info(
1386
+ `Restacking ${pc.yellow(name)} (${tail.length} branch(es)) onto ${pc.yellow(newBottom)}...`,
1387
+ );
1357
1388
  await git.checkout(newBottom);
1358
- const { default: restack } = await import("./restack.ts");
1359
1389
  // Push unless this run is local-only (--no-push / GH_STACK_NO_PUSH): the
1360
1390
  // tail must match the re-root above. An explicit flag also means the
1361
1391
  // `[restack] push` config isn't read mid-merge.
@@ -1364,7 +1394,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1364
1394
 
1365
1395
  p.outro(
1366
1396
  pc.green(
1367
- `Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
1397
+ split.length > 0
1398
+ ? `Merged base PR #${basePr} into ${trunk}; the rest is now ${split.length + 1} stacks: ${[stackName, ...split].join(", ")}.`
1399
+ : `Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
1368
1400
  ),
1369
1401
  );
1370
1402
  }
@@ -393,13 +393,13 @@ async function runSubmit(args: string[], run: RunState, jsonMode: boolean): Prom
393
393
  const outcomes = run.outcomes;
394
394
 
395
395
  // The branch's PR: recorded in metadata, else looked up on GitHub. A PR
396
- // found that way (opened outside gh-stack) is recorded for next time.
396
+ // found that way (opened outside gh-stack) is recorded for next time, except
397
+ // in a dry run, which still looks so its plan doesn't offer to create it.
397
398
  const findPr = async (name: string): Promise<number | null> => {
398
399
  const bm = stack.branches[name]!;
399
400
  if (bm.pr) return bm.pr;
400
- if (dryRun) return null;
401
401
  const found = await getPrNumber(name);
402
- if (found) {
402
+ if (found && !dryRun) {
403
403
  bm.pr = found;
404
404
  metadataChanged = true;
405
405
  }
@@ -6,7 +6,6 @@ import * as git from "../lib/git.ts";
6
6
  import {
7
7
  findStackForBranch,
8
8
  getOrderedBranches,
9
- removeBranchFromStack,
10
9
  stackBase,
11
10
  writeMetadata,
12
11
  } from "../lib/metadata.ts";
@@ -17,8 +16,9 @@ import { guardRemoteContent } from "../lib/remote-guard.ts";
17
16
  import { getPrMergeState } from "../lib/github.ts";
18
17
  import {
19
18
  planMerges,
20
- advanceNewBottom,
19
+ advancePastMerged,
21
20
  strandedMerges,
21
+ survivorsOf,
22
22
  type BranchReview,
23
23
  type MergePlan,
24
24
  type MergedRunEntry,
@@ -198,9 +198,29 @@ adds on GitHub). Pull or cherry-pick them first; see \`gh-stack restack --help\`
198
198
  // need to find when restacking children. (See resolveRestackBoundary.)
199
199
  await takeSnapshot(meta, stackName, "sync");
200
200
 
201
+ if (advance.split.length > 0) {
202
+ p.log.info(
203
+ `Split off ${advance.split.map((s) => pc.yellow(s)).join(", ")} — each is synced after ${pc.yellow(stackName)}. ` +
204
+ pc.dim(`If a run stops, finish it, then run gh-stack sync on the stacks it didn't reach.`),
205
+ );
206
+ }
207
+
201
208
  // Rebase the root onto main, push it, then restack every branch above it —
202
209
  // one run, saved after every step, so `restack --resume` can finish it.
203
210
  await syncChain(meta, stackName, ordered, { trunkRef: "origin/main", push, verified });
211
+
212
+ // Stacks split off by the advance are already re-rooted onto the trunk, but
213
+ // the branches above their roots still sit on the old roots (#60).
214
+ for (const name of advance.split) {
215
+ const splitStack = meta.stacks[name];
216
+ if (!splitStack) continue;
217
+ await takeSnapshot(meta, name, "sync");
218
+ await syncChain(meta, name, getOrderedBranches(splitStack), {
219
+ trunkRef: "origin/main",
220
+ push,
221
+ verified,
222
+ });
223
+ }
204
224
  }
205
225
 
206
226
  /**
@@ -257,6 +277,8 @@ interface AdvanceOutcome {
257
277
  advanced: boolean;
258
278
  /** The whole stack drained (every branch was already merged) and was archived. */
259
279
  drained: boolean;
280
+ /** Stacks split off because several branches sat on the merged run (#60). */
281
+ split: string[];
260
282
  }
261
283
 
262
284
  /** The contiguous merged bottom branches, and how each one's PR merged. */
@@ -312,16 +334,15 @@ async function advanceMergedBottoms(
312
334
  verified: Map<string, string>,
313
335
  ): Promise<AdvanceOutcome> {
314
336
  const stack = meta.stacks[stackName]!;
315
- const ordered = getOrderedBranches(stack);
316
337
  const { plan, run } = merged;
317
338
 
318
339
  if (plan.steps.length === 0) {
319
- return { detected: false, advanced: false, drained: false };
340
+ return { detected: false, advanced: false, drained: false, split: [] };
320
341
  }
321
342
 
322
343
  const mergedBranches = plan.steps.map((step) => step.branch);
323
- // The first unmerged branch. Ordered parents-first, so its parent is in the run.
324
- const survivor = ordered[mergedBranches.length] ?? null;
344
+ // Every unmerged branch sitting on the run: one per child of a merged branch.
345
+ const survivors = survivorsOf(stack, mergedBranches);
325
346
 
326
347
  p.log.warn(`${plan.steps.length} bottom PR(s) already merged:`);
327
348
  for (const step of plan.steps) {
@@ -329,17 +350,22 @@ async function advanceMergedBottoms(
329
350
  }
330
351
  console.log(
331
352
  ` ${pc.dim(
332
- survivor
333
- ? `Dropping them and re-rooting ${survivor} onto ${trunk}, so only its own commits replay.`
334
- : `Dropping them: nothing is left above them.`,
353
+ survivors.length === 0
354
+ ? `Dropping them: nothing is left above them.`
355
+ : `Dropping them and re-rooting ${survivors.join(", ")} onto ${trunk}, so only their own commits replay.`,
335
356
  )}`,
336
357
  );
358
+ if (survivors.length > 1) {
359
+ console.log(
360
+ ` ${pc.dim(`Each of ${survivors.join(", ")} becomes its own stack: siblings on ${trunk} don't depend on each other.`)}`,
361
+ );
362
+ }
337
363
  console.log();
338
364
  warnStranded(strandedMerges(run, trunk), trunk);
339
365
 
340
366
  if (dryRun) {
341
367
  p.log.warn("[DRY RUN] Would advance the stack past the merged bottom PR(s)");
342
- return { detected: true, advanced: false, drained: false };
368
+ return { detected: true, advanced: false, drained: false, split: [] };
343
369
  }
344
370
 
345
371
  const targetRef = `origin/${trunk}`;
@@ -347,41 +373,37 @@ async function advanceMergedBottoms(
347
373
  await takeSnapshot(meta, stackName, "sync-advance");
348
374
  const originalStack = structuredClone(stack);
349
375
 
350
- if (survivor) {
351
- const mergedParent = stack.branches[survivor]!.parent;
352
- const res = await advanceNewBottom({
353
- meta,
354
- stackName,
355
- mergedBranch: mergedParent,
356
- alsoMerged: mergedBranches.filter((b) => b !== mergedParent),
357
- child: survivor,
358
- childPr: stack.branches[survivor]?.pr ?? null,
359
- targetRef,
360
- recordedBase: trunk,
361
- push: !noPush,
362
- expect: verified.get(survivor),
363
- });
364
- if (!res.ok) {
365
- console.log();
366
- if (res.reason === "conflict") {
367
- p.log.error(`Rebase conflict re-rooting ${pc.yellow(survivor)} onto ${trunk}.`);
368
- console.log(` Resolve it, then finish the sync:`);
369
- console.log(` ${pc.green("git rebase --continue")}`);
370
- console.log(
371
- ` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${survivor}; not restack, which would put it back on ${mergedParent})`)}`,
372
- );
373
- } else {
374
- p.log.error(
375
- `Could not determine a safe rebase base for ${pc.yellow(survivor)} (${res.reason}).`,
376
- );
377
- console.log(
378
- ` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <${mergedParent}'s pre-merge tip> ${survivor}`)}`,
379
- );
380
- }
381
- process.exit(2);
376
+ const res = await advancePastMerged({
377
+ meta,
378
+ stackName,
379
+ merged: mergedBranches,
380
+ survivors: survivors.map((branch) => ({
381
+ branch,
382
+ pr: stack.branches[branch]?.pr ?? null,
383
+ expect: verified.get(branch),
384
+ })),
385
+ targetRef,
386
+ recordedBase: trunk,
387
+ push: !noPush,
388
+ });
389
+ if (!res.ok) {
390
+ console.log();
391
+ if (res.reason === "conflict") {
392
+ p.log.error(`Rebase conflict re-rooting ${pc.yellow(res.child)} onto ${trunk}.`);
393
+ console.log(` Resolve it, then finish the sync:`);
394
+ console.log(` ${pc.green("git rebase --continue")}`);
395
+ console.log(
396
+ ` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${res.child}; not restack, which would put it back on ${res.mergedBranch})`)}`,
397
+ );
398
+ } else {
399
+ p.log.error(
400
+ `Could not determine a safe rebase base for ${pc.yellow(res.child)} (${res.reason}).`,
401
+ );
402
+ console.log(
403
+ ` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <${res.mergedBranch}'s pre-merge tip> ${res.child}`)}`,
404
+ );
382
405
  }
383
- } else {
384
- for (const branch of mergedBranches) await removeBranchFromStack(meta, stackName, branch);
406
+ process.exit(2);
385
407
  }
386
408
 
387
409
  // Whole stack drained? Archive the original topology and land on the trunk.
@@ -398,10 +420,10 @@ async function advanceMergedBottoms(
398
420
  delete meta.stacks[stackName];
399
421
  if (meta.current_stack === stackName) meta.current_stack = null;
400
422
  await writeMetadata(meta);
401
- return { detected: true, advanced: true, drained: true };
423
+ return { detected: true, advanced: true, drained: true, split: [] };
402
424
  }
403
425
 
404
- return { detected: true, advanced: true, drained: false };
426
+ return { detected: true, advanced: true, drained: false, split: res.split };
405
427
  }
406
428
 
407
429
  /**
@@ -4,6 +4,8 @@
4
4
  // `gh-stack merge --approved` or squash-merged outside the tool (the GitHub UI)
5
5
  // — the rest of the stack must be *advanced*: drop the merged branch from the
6
6
  // stack and re-root the new bottom onto `main`, replaying ONLY its own commits.
7
+ // A merged branch with several children leaves several new bottoms: each is
8
+ // re-rooted and becomes its own stack (#60).
7
9
  //
8
10
  // The subtle part is the rebase fork-point. After a squash-merge, `main` carries
9
11
  // a brand-new commit that is NOT in the child's history, so `merge-base(child,
@@ -19,8 +21,8 @@
19
21
  import * as p from "./output.ts";
20
22
  import pc from "picocolors";
21
23
  import * as git from "./git.ts";
22
- import type { StackMetadata } from "../types.ts";
23
- import { removeBranchFromStack, writeMetadata } from "./metadata.ts";
24
+ import type { Stack, StackMetadata } from "../types.ts";
25
+ import { getOrderedBranches, removeBranchFromStack, writeMetadata } from "./metadata.ts";
24
26
  import { resolveRestackBoundary } from "./snapshot.ts";
25
27
  import { setPrBase } from "./github.ts";
26
28
 
@@ -182,119 +184,153 @@ export async function resolveForkPoint(
182
184
  return boundary?.sha ?? null;
183
185
  }
184
186
 
185
- /** Outcome of advancing a single new bottom onto the trunk. */
187
+ /** An unmerged branch sitting on a merged run: it gets re-rooted onto the trunk. */
188
+ export interface Survivor {
189
+ branch: string;
190
+ pr: number | null;
191
+ /**
192
+ * Its origin tip as the remote guard checked and cleared it: the push
193
+ * leases on exactly that sha (see git.forcePushWithLease).
194
+ */
195
+ expect?: string;
196
+ }
197
+
198
+ /**
199
+ * The unmerged branches whose parent is in the merged run, parents first.
200
+ * Usually one, but a merged branch with several children leaves one per child,
201
+ * and each needs its own re-root (#60).
202
+ */
203
+ export function survivorsOf(stack: Stack, merged: string[]): string[] {
204
+ const run = new Set(merged);
205
+ return getOrderedBranches(stack).filter((b) => !run.has(b) && run.has(stack.branches[b]!.parent));
206
+ }
207
+
208
+ /** Outcome of advancing a stack past a merged run. */
186
209
  export type AdvanceResult =
187
- | { ok: true }
188
- | { ok: false; reason: "conflict" | "no-fork-point" | "bad-fork-point"; forkPoint?: string };
210
+ | {
211
+ ok: true;
212
+ /** Stacks split off because the run left several branches on the trunk (#60). */
213
+ split: string[];
214
+ }
215
+ | {
216
+ ok: false;
217
+ /** The survivor that couldn't be re-rooted, and the merged branch it sat on. */
218
+ child: string;
219
+ mergedBranch: string;
220
+ reason: "conflict" | "no-fork-point" | "bad-fork-point";
221
+ forkPoint?: string;
222
+ };
189
223
 
190
224
  /**
191
- * Re-root `child` (the branch that becomes the new bottom) onto `targetRef`,
192
- * dropping `mergedBranch`'s now-squashed commits, then drop `mergedBranch` (and
193
- * `alsoMerged`) from the stack metadata (re-parenting `child` and any siblings
194
- * onto the trunk), push the rebased child, and repoint its PR base to
195
- * `recordedBase`.
225
+ * Re-root each survivor onto `targetRef`, dropping its merged parent's
226
+ * now-squashed commits, push it, and repoint its PR base to `recordedBase`.
227
+ * Then drop the whole merged run from the stack metadata. Survivors left side
228
+ * by side on the trunk become one stack each (see splitMultiRootStack).
196
229
  *
197
230
  * The caller MUST have taken a snapshot first — on a rebase conflict this leaves
198
231
  * the in-progress `git rebase` for the user to resolve and returns `conflict`.
199
- * Metadata still names the merged branch as the child's parent then, so the
200
- * fix is to re-run the caller: this skips the finished rebase and does the rest.
201
- * (`restack` would rebase the child back onto the merged branch.)
232
+ * Metadata still names the merged branches then, so the fix is to re-run the
233
+ * caller: survivors already on the target skip their rebase and finish the
234
+ * rest. (`restack` would rebase them back onto the merged branch.)
202
235
  */
203
- export async function advanceNewBottom(opts: {
236
+ export async function advancePastMerged(opts: {
204
237
  meta: StackMetadata;
205
238
  stackName: string;
206
- /** The merged branch `child` sits on: its pre-merge tip is the fork point. */
207
- mergedBranch: string;
208
239
  /**
209
- * The rest of a merged run below `mergedBranch` (#54). Their commits are in
210
- * the trunk too, so they're dropped without being re-rooted themselves.
240
+ * The merged run, every branch of it. Their commits are in the trunk, so
241
+ * they're dropped without being re-rooted themselves (#54).
211
242
  */
212
- alsoMerged?: string[];
213
- child: string;
214
- childPr: number | null;
243
+ merged: string[];
244
+ survivors: Survivor[];
215
245
  targetRef: string;
216
246
  recordedBase: string;
217
247
  /** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
218
248
  push?: boolean;
219
- /**
220
- * The child's origin tip as the remote guard checked and cleared it: the
221
- * push leases on exactly that sha (see git.forcePushWithLease).
222
- */
223
- expect?: string;
224
249
  }): Promise<AdvanceResult> {
225
- const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
250
+ const { meta, stackName, targetRef, recordedBase } = opts;
226
251
  const push = opts.push ?? true;
227
- let retargeted = false;
252
+ const prBases = new Map<string, string>();
228
253
 
229
- // Already on the target: an earlier run stopped on a conflict the user then
230
- // resolved, or the branch was re-rooted by hand (#51). Its fork point is
231
- // behind it now, so skip the rebase and finish the rest (push, PR base,
232
- // metadata). Before a re-root the child can't contain the target, which
233
- // holds the merged PR's new squash commit.
234
- if (await git.isAncestor(targetRef, child)) {
235
- p.log.info(
236
- `${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
237
- );
238
- } else {
239
- const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
240
- if (!forkPoint) return { ok: false, reason: "no-fork-point" };
254
+ for (const survivor of opts.survivors) {
255
+ const child = survivor.branch;
256
+ const childPr = survivor.pr;
257
+ const mergedBranch = meta.stacks[stackName]!.branches[child]!.parent;
258
+ let retargeted = false;
241
259
 
242
- // Guard: the fork-point must be an ancestor of the child, else `--onto` would
243
- // replay an unrelated range. Bail loudly rather than mangle history.
244
- if (!(await git.isAncestor(forkPoint, child))) {
245
- return { ok: false, reason: "bad-fork-point", forkPoint };
246
- }
260
+ // Already on the target: an earlier run stopped on a conflict the user then
261
+ // resolved, or the branch was re-rooted by hand (#51). Its fork point is
262
+ // behind it now, so skip the rebase and finish the rest (push, PR base,
263
+ // metadata). Before a re-root the child can't contain the target, which
264
+ // holds the merged PR's new squash commit.
265
+ if (await git.isAncestor(targetRef, child)) {
266
+ p.log.info(
267
+ `${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
268
+ );
269
+ } else {
270
+ const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
271
+ if (!forkPoint) return { ok: false, child, mergedBranch, reason: "no-fork-point" };
247
272
 
248
- p.log.info(
249
- `Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
250
- );
251
- const ok = await git.rebaseOnto(targetRef, forkPoint, child);
252
- if (!ok) return { ok: false, reason: "conflict", forkPoint };
253
- }
273
+ // Guard: the fork-point must be an ancestor of the child, else `--onto` would
274
+ // replay an unrelated range. Bail loudly rather than mangle history.
275
+ if (!(await git.isAncestor(forkPoint, child))) {
276
+ return { ok: false, child, mergedBranch, reason: "bad-fork-point", forkPoint };
277
+ }
254
278
 
255
- if (push) {
256
- // Push the rebased head first so the PR diff is clean before its base flips.
257
- // If it didn't land, leave the base alone: retargeting onto the trunk with
258
- // the old head on origin would show the merged commits in the PR (#29).
259
- const pushed = await git.forcePushWithLease(child, { expect: opts.expect });
260
- if (!pushed.ok) {
261
- p.log.error(
262
- `Push of ${pc.yellow(child)} failed — PR${childPr != null ? ` #${childPr}` : ""} left on ${mergedBranch} for now. The next push of ${child} that lands (gh-stack restack --resume, sync) retargets it.`,
279
+ p.log.info(
280
+ `Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
263
281
  );
264
- for (const line of git.pushFailureHint(child, pushed)) console.log(` ${line}`);
265
- } else if (childPr != null) {
266
- const moved = await setPrBase(childPr, recordedBase);
267
- if (moved) {
268
- retargeted = true;
269
- p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
270
- } else {
271
- p.log.warn(
272
- `Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
282
+ const ok = await git.rebaseOnto(targetRef, forkPoint, child);
283
+ if (!ok) return { ok: false, child, mergedBranch, reason: "conflict", forkPoint };
284
+ }
285
+
286
+ if (push) {
287
+ // Push the rebased head first so the PR diff is clean before its base flips.
288
+ // If it didn't land, leave the base alone: retargeting onto the trunk with
289
+ // the old head on origin would show the merged commits in the PR (#29).
290
+ const pushed = await git.forcePushWithLease(child, { expect: survivor.expect });
291
+ if (!pushed.ok) {
292
+ p.log.error(
293
+ `Push of ${pc.yellow(child)} failed — PR${childPr != null ? ` #${childPr}` : ""} left on ${mergedBranch} for now. The next push of ${child} that lands (gh-stack restack --resume, sync) retargets it.`,
273
294
  );
295
+ for (const line of git.pushFailureHint(child, pushed)) console.log(` ${line}`);
296
+ } else if (childPr != null) {
297
+ const moved = await setPrBase(childPr, recordedBase);
298
+ if (moved) {
299
+ retargeted = true;
300
+ p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
301
+ } else {
302
+ p.log.warn(
303
+ `Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
304
+ );
305
+ }
274
306
  }
307
+ } else if (childPr != null) {
308
+ // Retargeting the base without pushing the rebased head would show the
309
+ // merged commits in the PR diff, so leave both for `submit` to reconcile.
310
+ p.log.info(
311
+ `Not pushing ${pc.yellow(child)} or retargeting PR #${childPr} (--no-push) — ${pc.green("gh-stack submit")} publishes both`,
312
+ );
275
313
  }
276
- } else if (childPr != null) {
277
- // Retargeting the base without pushing the rebased head would show the
278
- // merged commits in the PR diff, so leave both for `submit` to reconcile.
279
- p.log.info(
280
- `Not pushing ${pc.yellow(child)} or retargeting PR #${childPr} (--no-push) — ${pc.green("gh-stack submit")} publishes both`,
281
- );
282
- }
283
314
 
284
- // Metadata last: drop the merged run, which re-parents child (and siblings)
285
- // onto the trunk, and write. Done after the git work succeeds so a failure
286
- // never leaves metadata describing a state git didn't reach.
287
- for (const merged of [...(opts.alsoMerged ?? []), mergedBranch]) {
288
- await removeBranchFromStack(meta, stackName, merged);
315
+ // Where the child's PR really points. Until it's retargeted it still
316
+ // targets the merged branch, and the next successful push of the child
317
+ // retargets it (restack's pushStep), so it's never left there.
318
+ if (childPr != null) prBases.set(child, retargeted ? recordedBase : mergedBranch);
289
319
  }
290
320
 
291
- // Record where the child's PR really points. Until it's retargeted it still
292
- // targets the merged branch, and the next successful push of the child
293
- // retargets it (restack's pushStep), so it's never left there.
294
- const entry = meta.stacks[stackName]?.branches[child];
295
- if (entry && childPr != null) {
296
- entry.prBase = retargeted ? recordedBase : mergedBranch;
321
+ // Metadata last: drop the merged run in one go (survivors move onto the
322
+ // trunk) and write. Done after the git work succeeds so a failure never
323
+ // leaves metadata describing a state git didn't reach.
324
+ const split = await removeBranchFromStack(meta, stackName, opts.merged);
325
+
326
+ if (prBases.size > 0) {
327
+ for (const stack of [stackName, ...split].map((name) => meta.stacks[name])) {
328
+ for (const [branch, prBase] of prBases) {
329
+ const entry = stack?.branches[branch];
330
+ if (entry) entry.prBase = prBase;
331
+ }
332
+ }
297
333
  await writeMetadata(meta);
298
334
  }
299
- return { ok: true };
335
+ return { ok: true, split };
300
336
  }
@@ -535,48 +535,92 @@ export async function addBranchToStack(
535
535
  }
536
536
 
537
537
  /**
538
- * Remove a branch from a stack, re-parenting children to the removed branch's parent.
538
+ * Remove branches from a stack, re-parenting each one's children to its
539
+ * parent. If that leaves several branches on the stack's base (a removed root
540
+ * had more than one child), they're split into one stack each; see
541
+ * splitMultiRootStack. Returns the names of the stacks split off.
542
+ *
543
+ * Pass every branch that leaves at once (e.g. a whole merged run): splitting
544
+ * after each removal could move a branch still to be removed into another stack.
539
545
  */
540
546
  export async function removeBranchFromStack(
541
547
  meta: StackMetadata,
542
548
  stackName: string,
543
- branchName: string,
544
- ): Promise<StackMetadata> {
549
+ branchNames: string | string[],
550
+ ): Promise<string[]> {
545
551
  const stack = meta.stacks[stackName];
546
552
  if (!stack) {
547
553
  throw new Error(`Stack "${stackName}" not found`);
548
554
  }
549
555
 
550
- const branch = stack.branches[branchName];
551
- if (!branch) {
552
- throw new Error(`Branch "${branchName}" not found in stack "${stackName}"`);
553
- }
556
+ for (const branchName of typeof branchNames === "string" ? [branchNames] : branchNames) {
557
+ const branch = stack.branches[branchName];
558
+ if (!branch) {
559
+ throw new Error(`Branch "${branchName}" not found in stack "${stackName}"`);
560
+ }
554
561
 
555
- const removedParent = branch.parent;
562
+ for (const b of Object.values(stack.branches)) {
563
+ if (b.parent === branchName) b.parent = branch.parent;
564
+ }
565
+ delete stack.branches[branchName];
556
566
 
557
- // Re-parent children
558
- for (const [, b] of Object.entries(stack.branches)) {
559
- if (b.parent === branchName) {
560
- b.parent = removedParent;
567
+ // The removed branch may still exist in git but is no longer a member —
568
+ // drop its membership config explicitly (syncBranchConfig also would, but
569
+ // be direct since the branch is gone from `meta`).
570
+ await clearBranchMembership(branchName);
571
+
572
+ if (stack.last_branch === branchName) {
573
+ const remaining = Object.keys(stack.branches);
574
+ stack.last_branch = remaining.length > 0 ? remaining[remaining.length - 1]! : null;
561
575
  }
562
576
  }
563
577
 
564
- // Remove the branch
565
- delete stack.branches[branchName];
578
+ const split = splitMultiRootStack(meta, stackName);
579
+ await writeMetadata(meta);
580
+ return split;
581
+ }
566
582
 
567
- // The removed branch may still exist in git but is no longer a member —
568
- // drop its membership config explicitly (syncBranchConfig also would, but
569
- // be direct since the branch is gone from `meta`).
570
- await clearBranchMembership(branchName);
583
+ /** The branches sitting directly on the stack's base, in insertion order. */
584
+ export function stackRoots(stack: Stack): string[] {
585
+ const base = stackBase(stack);
586
+ return Object.keys(stack.branches).filter((b) => stack.branches[b]!.parent === base);
587
+ }
571
588
 
572
- // Update last_branch if it was the removed one
573
- if (stack.last_branch === branchName) {
574
- const remaining = Object.keys(stack.branches);
575
- stack.last_branch = remaining.length > 0 ? remaining[remaining.length - 1]! : null;
589
+ /**
590
+ * Give each root of a stack with several roots its own stack (#60). A stack has
591
+ * exactly one root, so this happens when a removed root had several children:
592
+ * siblings on the same base are independent stacks. The first root keeps the
593
+ * stack; each other root's subtree moves to a new stack named after that root
594
+ * (suffixed `-2`, `-3`… on a clash), on the same base. Metadata only: no git
595
+ * branch moves. The caller writes. Returns the new stacks' names.
596
+ */
597
+ export function splitMultiRootStack(meta: StackMetadata, stackName: string): string[] {
598
+ const stack = meta.stacks[stackName];
599
+ if (!stack) return [];
600
+ const [, ...others] = stackRoots(stack);
601
+ const created: string[] = [];
602
+
603
+ for (const root of others) {
604
+ let name = root;
605
+ for (let n = 2; meta.stacks[name]; n++) name = `${root}-${n}`;
606
+
607
+ const moved: Stack = {
608
+ description: stack.description ? `Split from ${stackName}` : "",
609
+ last_branch: null,
610
+ branches: {},
611
+ base: stackBase(stack),
612
+ };
613
+ for (const b of buildRebaseChain(stack, root)) {
614
+ moved.branches[b] = stack.branches[b]!;
615
+ delete stack.branches[b];
616
+ }
617
+ moved.last_branch = getOrderedBranches(moved).at(-1) ?? null;
618
+ meta.stacks[name] = moved;
619
+ created.push(name);
576
620
  }
577
621
 
578
- await writeMetadata(meta);
579
- return meta;
622
+ if (created.length > 0) stack.last_branch = getOrderedBranches(stack).at(-1) ?? null;
623
+ return created;
580
624
  }
581
625
 
582
626
  /**
package/src/lib/safety.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  readMetadata,
7
7
  findStackForBranch,
8
8
  stackBase,
9
+ stackRoots,
9
10
  } from "./metadata.ts";
10
11
  import type { StackMetadata, Stack } from "../types.ts";
11
12
  import * as p from "./output.ts";
@@ -219,9 +220,15 @@ export async function ensureValidStack(
219
220
  console.log(` ${pc.red("•")} ${error}`);
220
221
  }
221
222
  console.log();
222
- console.log(
223
- pc.dim(`Repair the local branches, or run ${pc.green("gh-stack doctor")}, before retrying.`),
224
- );
223
+ if (stackRoots(meta.stacks[stackName]!).length > 1) {
224
+ console.log(
225
+ pc.dim(`Run ${pc.green("gh-stack doctor")} to split it into one stack per root, then retry.`),
226
+ );
227
+ } else {
228
+ console.log(
229
+ pc.dim(`Repair the local branches, or run ${pc.green("gh-stack doctor")}, before retrying.`),
230
+ );
231
+ }
225
232
  process.exit(1);
226
233
  }
227
234
 
@@ -315,8 +315,10 @@ deletes them from the PR.
315
315
 
316
316
  **`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI, or
317
317
  the base of a `merge --approved` landed with the PRs collapsed into it, `sync`
318
- detects it, drops every merged branch, and re-roots only the first unmerged one
319
- onto trunk instead of replaying squashed commits and conflicting. It rebases
318
+ detects it, drops every merged branch, and re-roots each unmerged branch sitting
319
+ on them onto trunk instead of replaying squashed commits and conflicting. A
320
+ merged branch with several children (the dependency sweep's batch PR with one
321
+ breaking PR per upgrade) leaves one stack per child, and sync syncs each. It rebases
320
322
  onto `origin/main` (fetched itself), so a stale local `main` doesn't matter.
321
323
 
322
324
  ### 5. Status
@@ -348,7 +350,8 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
348
350
  allow auto-merge, merge the base PR yourself once its checks pass
349
351
  (`gh pr merge <N> --squash`), then `gh-stack sync`.
350
352
  - **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
351
- the next branch onto trunk — one PR per run, re-run for the next. Use this to
353
+ the next branch onto trunk (every child, each as its own stack, when the
354
+ bottom PR has several) — one PR per run, re-run for the next. Use this to
352
355
  land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
353
356
  outside gh-stack. If the re-root stops (a conflict, or "origin/main doesn't
354
357
  show its merge commit"), fix it (`git rebase --continue`) and **re-run
@@ -377,13 +380,13 @@ cherry-pick them, don't force past it.
377
380
  ### 7. Maintenance
378
381
 
379
382
  ```bash
380
- gh-stack delete # remove a branch from the stack (re-parents children)
383
+ gh-stack delete # remove a branch from the stack (re-parents children; a root's several children become one stack each)
381
384
  gh-stack split # cut a stack into two at a branch
382
385
  gh-stack join <b> # splice an existing branch/PR into the stack (--above <b>, --dry-run)
383
386
  gh-stack rename # rename a tracked branch (keeps metadata in sync)
384
387
  gh-stack undo # restore from the last snapshot
385
388
  gh-stack archive # manage archived stacks
386
- gh-stack doctor # migrate & repair stack metadata
389
+ gh-stack doctor # migrate & repair stack metadata (splits a stack with several roots)
387
390
  gh-stack update-prs # repair stack viz in PR descriptions + adopt PRs opened with gh pr create
388
391
  ```
389
392