gh-stack 0.19.5 → 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
 
@@ -92,6 +92,9 @@ add [branch] [--parent <branch>]
92
92
  stack — without pushing or opening a PR. Walks local ancestry: if the
93
93
  branch sits on top of an existing stack it's adopted into it (parent
94
94
  auto-detected); otherwise a new stack is created from the chain.
95
+ A PR that gh-stack already recorded for a branch in git config (in
96
+ another worktree of the repo) is kept, so a stack submitted from a
97
+ worktree can be tracked in the main checkout and merged from there.
95
98
 
96
99
  --parent <branch>: stack it directly on <branch> (which it must
97
100
  already be based on), tracking <branch> first if needed. Use this
@@ -265,9 +268,11 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
265
268
  If the bottom PR(s) already merged outside gh-stack (e.g. the GitHub
266
269
  web UI, or the base of a `merge --approved` landing with the PRs
267
270
  collapsed into it), sync detects this and advances the stack past
268
- them: it drops every merged branch and re-roots only the first
269
- unmerged one onto main with `--onto`, so only its own commits replay,
270
- 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
271
276
  warns about a merged PR whose changes never reached main (merged
272
277
  into a branch after that branch's own PR had landed).
273
278
 
@@ -323,8 +328,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
323
328
  full merge.
324
329
  --base Squash-merge ONLY the bottom PR into main as its own
325
330
  commit, then re-root the next branch onto main (replaying
326
- just its own commits). One PR per run — run it again to
327
- 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
328
334
  protection, not gh-stack. A base PR already merged outside
329
335
  gh-stack is detected and advanced past, along with
330
336
  PRs already collapsed into it. The re-root waits
@@ -430,7 +436,8 @@ delete [<branch>] [-k|--keep-branch] [--no-remote]
430
436
  delete the underlying local git branch (and the remote branch if
431
437
  it was pushed). The <branch> argument names its own stack, so this
432
438
  works from anywhere. Interactive selector (current stack) if none
433
- 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.
434
441
  --keep-branch leaves the git branches untouched (metadata only);
435
442
  --no-remote deletes the local branch but keeps the remote.
436
443
  ```
@@ -487,9 +494,12 @@ config [--json]
487
494
  only). Read-only; edit the TOML files directly.
488
495
 
489
496
  doctor
490
- Migrate old (v2) metadata to the v3 layout, reconcile git branch
491
- config against the topology files, and flag stacks whose base
492
- 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.
493
503
 
494
504
  learn [--skill] [--harness <name>] [--global] [--force]
495
505
  Teach a coding agent how to drive gh-stack. By default prints the
@@ -550,10 +560,10 @@ remember to type them:
550
560
  # .gh-stack.toml at the repo root (commit it), or
551
561
  # ~/.config/gh-stack/config.toml for personal defaults (honors XDG_CONFIG_HOME)
552
562
  [submit]
553
- 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
554
564
 
555
565
  [restack]
556
- push = false # restack and sync stay local; --push overrides
566
+ push = false # restack and sync stay local; --push overrides
557
567
  ```
558
568
 
559
569
  Precedence, highest first: **CLI flag > env var > repo config > user config >
@@ -667,7 +677,7 @@ A single `active/<stack>.json` looks like:
667
677
  }
668
678
  ```
669
679
 
670
- 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
671
681
  deleting a branch updates/cleans it automatically:
672
682
 
673
683
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.5",
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",
@@ -38,7 +38,8 @@ OPTIONS
38
38
  its stack and has no PR (e.g. a mistaken ${"`"}init${"`"} at trunk).
39
39
 
40
40
  Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
41
- you're ready to publish.
41
+ you're ready to publish. A PR already recorded for the branch in git config
42
+ (by gh-stack in another worktree of this repo) is kept.
42
43
 
43
44
  EXAMPLES
44
45
  gh-stack add Track the current branch
@@ -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
  }
package/src/lib/chain.ts CHANGED
@@ -4,9 +4,25 @@
4
4
  // auto-create/register a stack from a bare set of local branches so the
5
5
  // end state is always stack registered + branches pushed + PRs created).
6
6
  import * as git from "./git.ts";
7
+ import { allBranchMemberships, type BranchMembership } from "./branch-config.ts";
7
8
  import { findStackForBranch, getOrderedBranches, stackBase } from "./metadata.ts";
8
9
  import type { StackMetadata, Branch } from "../types.ts";
9
10
 
11
+ /**
12
+ * A new stack entry for a branch, keeping the PR number and id already
13
+ * recorded for it in git config. Git config is shared by every worktree of a
14
+ * repo, but stack files are not: a branch submitted in one worktree and then
15
+ * tracked in another (the dependency sweep's handoff) would otherwise lose its
16
+ * PR, and `sync` and `merge --base` would not see it merge.
17
+ */
18
+ function newBranchEntry(parent: string, recorded: BranchMembership | undefined): Branch {
19
+ return {
20
+ parent,
21
+ ...(recorded?.pr != null && { pr: recorded.pr }),
22
+ ...(recorded?.id && { id: recorded.id }),
23
+ };
24
+ }
25
+
10
26
  /**
11
27
  * Detect a chain of branches from trunk to the current branch.
12
28
  *
@@ -107,6 +123,7 @@ export async function resolveOrCreateStack(
107
123
  trunk: string,
108
124
  ): Promise<StackResolution> {
109
125
  const fullChain = await detectBranchChain(branch, trunk); // trunk→current
126
+ const recorded = await allBranchMemberships();
110
127
 
111
128
  // Walk from just below `branch` downward; the first tracked ancestor is the
112
129
  // attachment point. Everything above it (up to `branch`) is the untracked
@@ -143,7 +160,7 @@ export async function resolveOrCreateStack(
143
160
  }
144
161
 
145
162
  if (stack.branches[name]) continue; // already tracked — leave as-is
146
- stack.branches[name] = { parent };
163
+ stack.branches[name] = newBranchEntry(parent, recorded.get(name));
147
164
  stack.last_branch = name;
148
165
  addedBranches.push(name);
149
166
  }
@@ -177,8 +194,7 @@ export async function resolveOrCreateStack(
177
194
  const name = fullChain[i]!;
178
195
  if (stack.branches[name]) continue;
179
196
  const parent = i === 0 ? trunk : fullChain[i - 1]!;
180
- const branchData: Branch = { parent };
181
- stack.branches[name] = branchData;
197
+ stack.branches[name] = newBranchEntry(parent, recorded.get(name));
182
198
  stack.last_branch = name;
183
199
  addedBranches.push(name);
184
200
  }
@@ -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
 
@@ -129,6 +129,11 @@ This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
129
129
  tip you hadn't submitted yet. (`submit` still auto-tracks too — `add` is just the
130
130
  no-publish path.)
131
131
 
132
+ **Handing a stack over from another worktree.** Stack files are per worktree,
133
+ but the PR numbers gh-stack records in git config are shared. So after
134
+ submitting from a worktree, `gh-stack add <top-branch>` in the main checkout
135
+ tracks the whole chain _with_ its PRs, and `sync`/`merge --base` work there.
136
+
132
137
  **Splice an existing branch/PR into a stack — `gh-stack join`.** For a branch
133
138
  that should have been stacked but was cut from main on its own (often with its
134
139
  own PR already open):
@@ -310,8 +315,10 @@ deletes them from the PR.
310
315
 
311
316
  **`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI, or
312
317
  the base of a `merge --approved` landed with the PRs collapsed into it, `sync`
313
- detects it, drops every merged branch, and re-roots only the first unmerged one
314
- 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
315
322
  onto `origin/main` (fetched itself), so a stale local `main` doesn't matter.
316
323
 
317
324
  ### 5. Status
@@ -343,7 +350,8 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
343
350
  allow auto-merge, merge the base PR yourself once its checks pass
344
351
  (`gh pr merge <N> --squash`), then `gh-stack sync`.
345
352
  - **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
346
- 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
347
355
  land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
348
356
  outside gh-stack. If the re-root stops (a conflict, or "origin/main doesn't
349
357
  show its merge commit"), fix it (`git rebase --continue`) and **re-run
@@ -372,13 +380,13 @@ cherry-pick them, don't force past it.
372
380
  ### 7. Maintenance
373
381
 
374
382
  ```bash
375
- 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)
376
384
  gh-stack split # cut a stack into two at a branch
377
385
  gh-stack join <b> # splice an existing branch/PR into the stack (--above <b>, --dry-run)
378
386
  gh-stack rename # rename a tracked branch (keeps metadata in sync)
379
387
  gh-stack undo # restore from the last snapshot
380
388
  gh-stack archive # manage archived stacks
381
- gh-stack doctor # migrate & repair stack metadata
389
+ gh-stack doctor # migrate & repair stack metadata (splits a stack with several roots)
382
390
  gh-stack update-prs # repair stack viz in PR descriptions + adopt PRs opened with gh pr create
383
391
  ```
384
392