gh-stack 0.19.6 → 0.19.7

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
 
@@ -326,8 +328,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
326
328
  full merge.
327
329
  --base Squash-merge ONLY the bottom PR into main as its own
328
330
  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
331
+ just its own commits). If the bottom PR has several
332
+ children, each is re-rooted and becomes its own stack.
333
+ One PR per run — run it again to land the next. Approval is enforced by GitHub branch
331
334
  protection, not gh-stack. A base PR already merged outside
332
335
  gh-stack is detected and advanced past, along with
333
336
  PRs already collapsed into it. The re-root waits
@@ -433,7 +436,8 @@ delete [<branch>] [-k|--keep-branch] [--no-remote]
433
436
  delete the underlying local git branch (and the remote branch if
434
437
  it was pushed). The <branch> argument names its own stack, so this
435
438
  works from anywhere. Interactive selector (current stack) if none
436
- specified.
439
+ specified. Deleting a stack's root that has several children leaves
440
+ them side by side on the base, so each becomes its own stack.
437
441
  --keep-branch leaves the git branches untouched (metadata only);
438
442
  --no-remote deletes the local branch but keeps the remote.
439
443
  ```
@@ -490,9 +494,12 @@ config [--json]
490
494
  only). Read-only; edit the TOML files directly.
491
495
 
492
496
  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.
497
+ Migrate old (v2) metadata to the v3 layout, split any stack left
498
+ with several root branches into one stack per root, reconcile git
499
+ branch config against the topology files, flag stacks whose base
500
+ stack appears already-merged into main, and report stacks that
501
+ still fail the checks other commands run before loading a stack.
502
+ Safe to run repeatedly.
496
503
 
497
504
  learn [--skill] [--harness <name>] [--global] [--force]
498
505
  Teach a coding agent how to drive gh-stack. By default prints the
@@ -553,10 +560,10 @@ remember to type them:
553
560
  # .gh-stack.toml at the repo root (commit it), or
554
561
  # ~/.config/gh-stack/config.toml for personal defaults (honors XDG_CONFIG_HOME)
555
562
  [submit]
556
- draft = true # new PRs open as drafts; --no-draft overrides for one PR
563
+ draft = true # new PRs open as drafts; --no-draft overrides for one PR
557
564
 
558
565
  [restack]
559
- push = false # restack and sync stay local; --push overrides
566
+ push = false # restack and sync stay local; --push overrides
560
567
  ```
561
568
 
562
569
  Precedence, highest first: **CLI flag > env var > repo config > user config >
@@ -670,7 +677,7 @@ A single `active/<stack>.json` looks like:
670
677
  }
671
678
  ```
672
679
 
673
- Per-branch membership is *also* recorded in git's own config, so renaming or
680
+ Per-branch membership is _also_ recorded in git's own config, so renaming or
674
681
  deleting a branch updates/cleans it automatically:
675
682
 
676
683
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.6",
3
+ "version": "0.19.7",
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";
@@ -28,7 +27,8 @@ import { guardRemoteContent } from "../lib/remote-guard.ts";
28
27
  import { ensureNoPendingRun } from "./restack.ts";
29
28
  import {
30
29
  planMerges,
31
- advanceNewBottom,
30
+ advancePastMerged,
31
+ survivorsOf,
32
32
  type BranchReview,
33
33
  type MergeAction,
34
34
  } from "../lib/advance.ts";
@@ -1032,7 +1032,8 @@ function resolveMergePush(args: string[]): Resolved {
1032
1032
  /**
1033
1033
  * `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
1034
1034
  * 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
1035
+ * advance.ts's fork-point resolution); every child, each its own stack, when
1036
+ * the bottom PR has several (#60). One PR per run — run it again to land the
1036
1037
  * next. Approval is NOT checked here; GitHub branch protection enforces it.
1037
1038
  * A base PR already merged outside gh-stack is detected and advanced past.
1038
1039
  */
@@ -1107,7 +1108,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1107
1108
  // own) go with it: dropped, never re-rooted, since replaying their commits
1108
1109
  // onto a trunk that holds them squashed conflicts (#54).
1109
1110
  const mergedAbove = await mergedRunAbove(stack, ordered);
1110
- const nextChild = ordered[1 + mergedAbove.length] ?? null;
1111
+ const mergedRun = [baseBranch, ...mergedAbove];
1112
+ // Every unmerged branch on the run is re-rooted: one per child (#60).
1113
+ const survivors = survivorsOf(stack, mergedRun);
1111
1114
 
1112
1115
  console.log();
1113
1116
  console.log(` ${pc.bold("Plan:")}`);
@@ -1123,10 +1126,16 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1123
1126
  ` ${pc.dim(`drop ${mergedAbove.join(", ")} (already merged into the branch below)`)}`,
1124
1127
  );
1125
1128
  }
1126
- if (nextChild) {
1129
+ if (survivors.length === 1) {
1127
1130
  console.log(
1128
- ` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
1131
+ ` ${pc.dim(`then re-root ${survivors[0]} onto ${trunk} — the rest stays a stack`)}`,
1129
1132
  );
1133
+ } else if (survivors.length > 1) {
1134
+ console.log(
1135
+ ` ${pc.dim(`then re-root ${survivors.join(", ")} onto ${trunk} — each becomes its own stack`)}`,
1136
+ );
1137
+ }
1138
+ if (survivors.length > 0) {
1130
1139
  if (!push) {
1131
1140
  console.log(
1132
1141
  ` ${pc.dim(`(--no-push: re-rooted branches stay local; ${pc.green("gh-stack submit")} publishes them)`)}`,
@@ -1141,8 +1150,8 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1141
1150
  }
1142
1151
 
1143
1152
  const confirmed = await confirmAction(
1144
- nextChild
1145
- ? `Merge base PR #${basePr} into ${trunk} and re-root ${nextChild}?`
1153
+ survivors.length > 0
1154
+ ? `Merge base PR #${basePr} into ${trunk} and re-root ${survivors.join(", ")}?`
1146
1155
  : `Merge base PR #${basePr} into ${trunk}?`,
1147
1156
  );
1148
1157
  if (!confirmed) {
@@ -1201,12 +1210,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1201
1210
  await takeSnapshot(meta, stackName, "merge-base");
1202
1211
  // Keep a copy of the original topology to archive if the stack fully drains.
1203
1212
  const originalStack = structuredClone(stack);
1213
+ let split: string[] = [];
1204
1214
 
1205
1215
  for (let i = 0; i < plan.steps.length; i++) {
1206
1216
  const step = plan.steps[i]!;
1207
1217
  const mergedBranch = step.branch;
1208
- const child = nextChild;
1209
- const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
1210
1218
 
1211
1219
  console.log();
1212
1220
  console.log(pc.cyan("━".repeat(40)));
@@ -1253,7 +1261,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1253
1261
  p.log.info(`PR #${step.pr} already merged — advancing the stack past it.`);
1254
1262
  }
1255
1263
 
1256
- if (child) {
1264
+ if (survivors.length > 0) {
1257
1265
  // Re-root onto the trunk as it is now, with the merge in it (#51).
1258
1266
  const trunkState = await fetchTrunkWithMerge(trunk, step.pr!);
1259
1267
  if (!trunkState.ok) {
@@ -1266,56 +1274,53 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1266
1274
  } yet.`,
1267
1275
  );
1268
1276
  console.log(
1269
- ` Stopped before re-rooting ${pc.yellow(child)}: a ${trunk} without the merge would drop its changes from the stack.`,
1277
+ ` Stopped before re-rooting ${pc.yellow(survivors.join(", "))}: a ${trunk} without the merge would drop its changes from the stack.`,
1270
1278
  );
1271
1279
  console.log(` Once ${trunk} has it, finish with:`);
1272
1280
  console.log(` ${pc.green("gh-stack merge --base")}`);
1273
1281
  process.exit(2);
1274
1282
  }
1283
+ }
1275
1284
 
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) {
1285
+ const res = await advancePastMerged({
1286
+ meta,
1287
+ stackName,
1288
+ merged: mergedRun,
1289
+ survivors: survivors.map((branch) => ({
1290
+ branch,
1291
+ pr: stack.branches[branch]?.pr ?? null,
1292
+ expect: verified.get(branch),
1293
+ })),
1294
+ targetRef,
1295
+ recordedBase: trunk,
1296
+ push,
1297
+ });
1298
+ if (!res.ok) {
1299
+ console.log();
1300
+ if (res.reason === "conflict") {
1301
+ p.log.error(`Rebase conflict re-rooting ${pc.yellow(res.child)} onto ${trunk}.`);
1302
+ console.log(` Resolve the conflict, then finish with:`);
1303
+ console.log(` ${pc.green("git rebase --continue")}`);
1304
+ console.log(
1305
+ ` ${pc.green("gh-stack merge --base")} ${pc.dim(`(pushes ${res.child}, retargets its PR, restacks the rest)`)}`,
1306
+ );
1291
1307
  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);
1308
+ console.log(
1309
+ pc.dim(
1310
+ ` The merged PR(s) below ${res.child} are done; only the re-root needs finishing.`,
1311
+ ),
1312
+ );
1313
+ } else {
1314
+ p.log.error(
1315
+ `Could not determine a safe rebase base for ${pc.yellow(res.child)} (${res.reason}).`,
1316
+ );
1317
+ console.log(
1318
+ ` Re-root it manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${res.child}`)}`,
1319
+ );
1317
1320
  }
1321
+ process.exit(2);
1318
1322
  }
1323
+ split = res.split;
1319
1324
  }
1320
1325
 
1321
1326
  // Did the whole stack drain, or is there a tail to restack?
@@ -1345,17 +1350,20 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1345
1350
  return;
1346
1351
  }
1347
1352
 
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)) {
1353
+ // Restack the surviving tail onto the newly re-rooted bottom, then each stack
1354
+ // split off beside it (#60). Each new bottom is already rebased onto main;
1355
+ // restack skips it (base branch) and propagates onto its children using the
1356
+ // snapshot taken above.
1357
+ const { default: restack } = await import("./restack.ts");
1358
+ for (const name of [stackName, ...split]) {
1359
+ const tail = meta.stacks[name] ? getOrderedBranches(meta.stacks[name]) : [];
1360
+ const newBottom = tail[0];
1361
+ if (!newBottom || !(await git.localBranchExists(newBottom))) continue;
1362
+ console.log();
1363
+ p.log.info(
1364
+ `Restacking ${pc.yellow(name)} (${tail.length} branch(es)) onto ${pc.yellow(newBottom)}...`,
1365
+ );
1357
1366
  await git.checkout(newBottom);
1358
- const { default: restack } = await import("./restack.ts");
1359
1367
  // Push unless this run is local-only (--no-push / GH_STACK_NO_PUSH): the
1360
1368
  // tail must match the re-root above. An explicit flag also means the
1361
1369
  // `[restack] push` config isn't read mid-merge.
@@ -1364,7 +1372,9 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1364
1372
 
1365
1373
  p.outro(
1366
1374
  pc.green(
1367
- `Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
1375
+ split.length > 0
1376
+ ? `Merged base PR #${basePr} into ${trunk}; the rest is now ${split.length + 1} stacks: ${[stackName, ...split].join(", ")}.`
1377
+ : `Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
1368
1378
  ),
1369
1379
  );
1370
1380
  }
@@ -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