gh-stack 0.7.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,18 +5,16 @@ import pc from "picocolors";
5
5
  import * as git from "../lib/git.ts";
6
6
  import {
7
7
  findStackForBranch,
8
+ stackBase,
8
9
  buildRebaseChain,
10
+ getOrderedBranches,
9
11
  saveRestackState,
10
12
  loadRestackState,
11
13
  clearRestackState,
12
14
  readMetadata,
15
+ writeMetadata,
13
16
  } from "../lib/metadata.ts";
14
- import {
15
- ensureMetadata,
16
- ensureCleanWorkingTree,
17
- ensureNotMain,
18
- ensureValidStack,
19
- } from "../lib/safety.ts";
17
+ import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
20
18
  import { takeSnapshot, findPreRewriteSha } from "../lib/snapshot.ts";
21
19
  import { confirmAction } from "../lib/ui.ts";
22
20
  import type { StackMetadata } from "../types.ts";
@@ -26,14 +24,23 @@ export default async function restack(args: string[]): Promise<void> {
26
24
  const resume = args.includes("--resume");
27
25
  const verbose = args.includes("--verbose") || args.includes("-v");
28
26
 
27
+ // --onto <branch>: re-root the current stack onto a new base (e.g. move a
28
+ // split stack from its parent branch onto main once the parent has merged).
29
+ let ontoFlag: string | undefined;
30
+ for (let i = 0; i < args.length; i++) {
31
+ if (args[i] === "--onto" && args[i + 1]) ontoFlag = args[++i];
32
+ }
33
+
29
34
  if (args.includes("--help")) {
30
35
  console.log(`
31
36
  gh-stack restack — Rebase children onto updated parents
32
37
 
33
38
  USAGE
34
39
  gh-stack restack [options]
40
+ gh-stack restack --onto <branch> Re-root the current stack onto <branch>
35
41
 
36
42
  OPTIONS
43
+ --onto <ref> Re-root the stack onto a new base ref (changes stack base)
37
44
  --yes, -y Skip confirmations (auto-accept all rebases and pushes)
38
45
  --resume Resume after resolving rebase conflicts
39
46
  --dry-run Show what would happen without executing
@@ -43,6 +50,8 @@ EXAMPLES
43
50
  gh-stack restack Interactive (prompts before each rebase)
44
51
  gh-stack restack --yes Non-interactive (for agents/CI)
45
52
  gh-stack restack --resume Continue after resolving conflicts
53
+ gh-stack restack --onto main Re-root a split stack onto main after its
54
+ parent stack has merged
46
55
 
47
56
  ALIASES
48
57
  gh-stack rebase
@@ -67,6 +76,9 @@ ALIASES
67
76
 
68
77
  const meta = await ensureMetadata();
69
78
  const rebasedBranches: string[] = [];
79
+ // Every branch restack is responsible for this run (rebased OR already
80
+ // up to date) — we push each one and verify its ref landed at the end.
81
+ const touched: string[] = [];
70
82
 
71
83
  p.intro(pc.cyan("Git Stack Restack"));
72
84
 
@@ -74,10 +86,12 @@ ALIASES
74
86
  // (pre-snapshot). Keeps users upgrading from <0.4 from seeing stale tags.
75
87
  await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
76
88
 
77
- if (resume) {
78
- await handleResume(meta, rebasedBranches, verbose);
89
+ if (ontoFlag) {
90
+ await handleReroot(meta, ontoFlag, rebasedBranches, touched, dryRun, verbose);
91
+ } else if (resume) {
92
+ await handleResume(meta, rebasedBranches, touched, verbose);
79
93
  } else {
80
- await handleFreshRestack(meta, rebasedBranches, dryRun, verbose);
94
+ await handleFreshRestack(meta, rebasedBranches, touched, dryRun, verbose);
81
95
  }
82
96
 
83
97
  // Summary
@@ -91,12 +105,60 @@ ALIASES
91
105
  }
92
106
  }
93
107
 
108
+ // Verification pass: assert every touched branch's ref actually landed on
109
+ // origin. Fail loudly (listing the stale ones) rather than reporting success
110
+ // when a push didn't take. (#12)
111
+ if (!dryRun) {
112
+ const stale = await findStaleRefs(touched);
113
+ if (stale.length > 0) {
114
+ console.log();
115
+ p.log.error("Some branch refs are NOT in sync with origin:");
116
+ for (const { branch, local, remote } of stale) {
117
+ console.log(` ${pc.red("✗")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
118
+ }
119
+ console.log();
120
+ console.log(
121
+ ` Reconcile with: ${pc.green("gh-stack restack")} (re-runs the push for stale refs)`,
122
+ );
123
+ p.outro(pc.red("Restack finished with un-pushed refs"));
124
+ process.exit(1);
125
+ }
126
+ }
127
+
94
128
  p.outro(pc.green("Stack Restack Complete!"));
95
129
  }
96
130
 
131
+ /**
132
+ * For each branch, compare its local tip against origin. Returns the ones that
133
+ * differ (or whose remote ref is missing), so restack can report exactly which
134
+ * refs didn't land instead of falsely claiming success. (#12)
135
+ */
136
+ async function findStaleRefs(
137
+ branches: string[],
138
+ ): Promise<{ branch: string; local: string; remote: string }[]> {
139
+ const stale: { branch: string; local: string; remote: string }[] = [];
140
+ const seen = new Set<string>();
141
+ for (const branch of branches) {
142
+ if (seen.has(branch) || branch === "main" || branch === "master") continue;
143
+ seen.add(branch);
144
+ if (!(await git.localBranchExists(branch))) continue;
145
+ const local = await git.revParse(branch);
146
+ if (!(await git.remoteBranchExists(branch))) {
147
+ stale.push({ branch, local, remote: "(missing)" });
148
+ continue;
149
+ }
150
+ const remote = await git.revParse(`origin/${branch}`).catch(() => "");
151
+ if (remote !== local) {
152
+ stale.push({ branch, local, remote: remote ? remote.slice(0, 8) : "(unknown)" });
153
+ }
154
+ }
155
+ return stale;
156
+ }
157
+
97
158
  async function handleResume(
98
159
  meta: StackMetadata,
99
160
  rebasedBranches: string[],
161
+ touched: string[],
100
162
  verbose: boolean,
101
163
  ): Promise<void> {
102
164
  p.log.warn("Resuming from saved state...");
@@ -135,10 +197,11 @@ async function handleResume(
135
197
  // Rebase completed successfully
136
198
  p.log.success("Rebase completed successfully");
137
199
  rebasedBranches.push(expectedBranch);
200
+ touched.push(expectedBranch);
138
201
  await clearRestackState();
139
202
 
140
203
  // Prompt to push the resumed branch
141
- await promptPush(expectedBranch);
204
+ await pushBranchRef(expectedBranch);
142
205
 
143
206
  // Continue with remaining branches in chain
144
207
  const stackName = state.stack_name;
@@ -149,13 +212,23 @@ async function handleResume(
149
212
  const currentIndex = state.current_index + 1;
150
213
 
151
214
  if (currentIndex < chain.length) {
152
- await processChain(meta, stackName, chain, currentIndex, rebasedBranches, false, verbose);
215
+ await processChain(
216
+ meta,
217
+ stackName,
218
+ chain,
219
+ currentIndex,
220
+ rebasedBranches,
221
+ touched,
222
+ false,
223
+ verbose,
224
+ );
153
225
  }
154
226
  }
155
227
 
156
228
  async function handleFreshRestack(
157
229
  meta: StackMetadata,
158
230
  rebasedBranches: string[],
231
+ touched: string[],
159
232
  dryRun: boolean,
160
233
  verbose: boolean,
161
234
  ): Promise<void> {
@@ -189,26 +262,40 @@ async function handleFreshRestack(
189
262
  }
190
263
  console.log();
191
264
 
192
- // Skip base branches (parent = main) unless this is a sync
193
- const baseBranches = chain.filter((b) => stack.branches[b]?.parent === "main");
265
+ // Skip the stack's root branch (parent = base) when the base is the trunk —
266
+ // rebasing onto trunk is `sync`'s job, not restack's. For a split stack
267
+ // whose base is a sibling branch, the root SHOULD be rebased onto that base
268
+ // so changes propagate, so we don't skip it.
269
+ const base = stackBase(stack);
270
+ const baseIsTrunk = base === "main" || base === "master";
271
+ const baseBranches = baseIsTrunk ? chain.filter((b) => stack.branches[b]?.parent === base) : [];
194
272
  if (baseBranches.length > 0) {
195
- p.log.warn("Skipping base branch(es) (parent: main):");
196
- for (const base of baseBranches) {
197
- console.log(` ${pc.cyan("Skip:")} ${base} ${pc.blue("(parent: main)")}`);
273
+ p.log.warn(`Skipping base branch(es) (parent: ${base}):`);
274
+ for (const baseBranch of baseBranches) {
275
+ console.log(` ${pc.cyan("Skip:")} ${baseBranch} ${pc.blue(`(parent: ${base})`)}`);
198
276
  }
199
277
  console.log();
200
278
  console.log(` ${pc.dim("This is a restack — propagating changes from parent to children.")}`);
201
- console.log(` ${pc.dim(`To include rebasing onto main, use: ${pc.green("gh-stack sync")}`)}`);
279
+ console.log(
280
+ ` ${pc.dim(`To include rebasing onto ${base}, use: ${pc.green("gh-stack sync")}`)}`,
281
+ );
202
282
  console.log();
203
283
 
204
- chain = chain.filter((b) => stack.branches[b]?.parent !== "main");
284
+ chain = chain.filter((b) => stack.branches[b]?.parent !== base);
285
+
286
+ // We don't rebase the base branch, but if you committed on it its ref is
287
+ // now stale on origin — push it so the PR reflects the new commit. (#12)
288
+ if (!dryRun) {
289
+ for (const baseBranch of baseBranches) {
290
+ if (await git.localBranchExists(baseBranch)) {
291
+ touched.push(baseBranch);
292
+ await pushBranchRef(baseBranch);
293
+ }
294
+ }
295
+ }
205
296
 
206
297
  if (chain.length === 0) {
207
- p.log.warn("No branches to process after skipping base branches");
208
- console.log();
209
- console.log(" Either:");
210
- console.log(" 1. Run from a child branch (e.g., PR2)");
211
- console.log(` 2. Use ${pc.green("gh-stack sync")} to include rebasing base onto main`);
298
+ p.log.info("No child branches to rebase — base branch ref reconciled above.");
212
299
  return;
213
300
  }
214
301
 
@@ -228,7 +315,130 @@ async function handleFreshRestack(
228
315
  }
229
316
 
230
317
  // Process the chain
231
- await processChain(meta, stackName, chain, 0, rebasedBranches, dryRun, verbose);
318
+ await processChain(meta, stackName, chain, 0, rebasedBranches, touched, dryRun, verbose);
319
+ }
320
+
321
+ /**
322
+ * Re-root the current stack onto a new base (`restack --onto <newBase>`).
323
+ *
324
+ * Used to move a split stack off its parent-stack branch and onto the trunk
325
+ * once that parent stack has merged. The root branch is rebased with
326
+ * `git rebase --onto <newBase> <oldBaseTip> <root>`, where oldBaseTip is the
327
+ * tip of the current base branch — so only the root's own commits replay onto
328
+ * the new base (the now-merged parent commits are dropped). Children then
329
+ * restack onto the moved root via the normal chain logic.
330
+ */
331
+ async function handleReroot(
332
+ meta: StackMetadata,
333
+ newBase: string,
334
+ rebasedBranches: string[],
335
+ touched: string[],
336
+ dryRun: boolean,
337
+ verbose: boolean,
338
+ ): Promise<void> {
339
+ const currentBranch = await git.currentBranch();
340
+ const stackName = findStackForBranch(meta, currentBranch);
341
+
342
+ if (!stackName) {
343
+ p.cancel(`Branch ${pc.blue(currentBranch)} is not in any stack.`);
344
+ process.exit(1);
345
+ }
346
+
347
+ const stack = meta.stacks[stackName]!;
348
+ await ensureValidStack(meta, stackName);
349
+
350
+ const oldBase = stackBase(stack);
351
+ if (oldBase === newBase) {
352
+ p.log.warn(
353
+ `${pc.yellow(stackName)} is already based on ${pc.yellow(newBase)} — nothing to do.`,
354
+ );
355
+ return;
356
+ }
357
+
358
+ // The root is the branch whose parent is the current base.
359
+ const ordered = getOrderedBranches(stack);
360
+ const root = ordered[0];
361
+ if (!root) {
362
+ p.cancel("Stack has no branches");
363
+ process.exit(1);
364
+ }
365
+
366
+ // The rebase base = the tip the root was built on (the old base branch).
367
+ // Prefer the live branch tip; fall back to merge-base if it's already gone.
368
+ let oldBaseTip: string | null = null;
369
+ let oldBaseSource = "old-base branch tip";
370
+ if (await git.localBranchExists(oldBase)) {
371
+ oldBaseTip = await git.revParse(oldBase);
372
+ } else {
373
+ oldBaseTip = await git.mergeBase(root, newBase);
374
+ oldBaseSource = "merge-base (old base branch is gone — verify the result)";
375
+ }
376
+
377
+ if (!oldBaseTip) {
378
+ p.cancel(
379
+ `Could not determine the old base for ${pc.yellow(root)}.\n\n Re-root manually with: ${pc.green(
380
+ `git rebase --onto ${newBase} <old-base> ${root}`,
381
+ )}`,
382
+ );
383
+ process.exit(1);
384
+ }
385
+
386
+ p.log.info(`Re-rooting ${pc.yellow(stackName)}: ${pc.dim(oldBase)} → ${pc.green(newBase)}`);
387
+ p.log.info(`Root branch: ${pc.yellow(root)}`);
388
+ if (verbose) {
389
+ p.log.info(`Rebase base: ${oldBaseTip.slice(0, 8)} (${oldBaseSource})`);
390
+ }
391
+ console.log();
392
+
393
+ if (dryRun) {
394
+ p.log.warn(`[DRY RUN] Would rebase ${root} onto ${newBase} (from ${oldBaseTip.slice(0, 8)})`);
395
+ const children = ordered.slice(1);
396
+ if (children.length > 0) {
397
+ p.log.info(`[DRY RUN] Would restack ${children.length} child branch(es) onto the moved root`);
398
+ }
399
+ return;
400
+ }
401
+
402
+ const confirmed = await confirmAction(
403
+ `Re-root ${pc.yellow(stackName)} onto ${pc.yellow(newBase)}?`,
404
+ );
405
+ if (!confirmed) {
406
+ p.cancel("Cancelled");
407
+ process.exit(0);
408
+ }
409
+
410
+ // Snapshot before rewriting, then commit the new base into metadata.
411
+ await takeSnapshot(meta, stackName, "reroot");
412
+ stack.base = newBase;
413
+ stack.branches[root]!.parent = newBase;
414
+ await writeMetadata(meta);
415
+
416
+ // Rebase the root onto the new base, dropping the old base's commits.
417
+ p.log.info(`Checking out ${pc.yellow(root)}...`);
418
+ await git.checkout(root);
419
+ p.log.info(`Rebasing ${pc.yellow(root)} onto ${pc.yellow(newBase)}...`);
420
+ const ok = await git.rebaseOnto(newBase, oldBaseTip, root);
421
+
422
+ if (!ok) {
423
+ console.log();
424
+ p.log.error("Rebase conflict — resolve and run:");
425
+ console.log(` ${pc.green("git rebase --continue")}`);
426
+ console.log(
427
+ ` ${pc.green("gh-stack restack")} ${pc.dim("(from the first child, to restack the rest)")}`,
428
+ );
429
+ process.exit(2);
430
+ }
431
+
432
+ rebasedBranches.push(root);
433
+ touched.push(root);
434
+ await pushBranchRef(root);
435
+
436
+ // Restack the children onto the moved root.
437
+ const children = ordered.slice(1);
438
+ if (children.length > 0) {
439
+ const chain = buildRebaseChain(stack, children[0]!);
440
+ await processChain(meta, stackName, chain, 0, rebasedBranches, touched, false, verbose);
441
+ }
232
442
  }
233
443
 
234
444
  async function processChain(
@@ -237,6 +447,7 @@ async function processChain(
237
447
  chain: string[],
238
448
  startIndex: number,
239
449
  rebasedBranches: string[],
450
+ touched: string[],
240
451
  dryRun: boolean,
241
452
  verbose: boolean,
242
453
  ): Promise<void> {
@@ -258,9 +469,16 @@ async function processChain(
258
469
  console.log(pc.cyan("━".repeat(40)));
259
470
  console.log();
260
471
 
261
- // Check if already up to date
472
+ // Check if already up to date. The branch doesn't need rebasing, but its
473
+ // remote ref may still be stale — e.g. this is the branch you just
474
+ // committed on, which triggered the restack. Push it so origin (and its
475
+ // PR) reflects the new commit. (#12)
262
476
  if (await git.isAncestor(parent, branch)) {
263
477
  p.log.success("Already up to date with parent");
478
+ if (!dryRun) {
479
+ touched.push(branch);
480
+ await pushBranchRef(branch);
481
+ }
264
482
  continue;
265
483
  }
266
484
 
@@ -352,10 +570,12 @@ async function processChain(
352
570
  if (success) {
353
571
  p.log.success("Rebase successful");
354
572
  rebasedBranches.push(branch);
573
+ touched.push(branch);
355
574
  await clearRestackState();
356
575
 
357
- // Prompt to push immediately (don't batch)
358
- await promptPush(branch);
576
+ // Push immediately (don't batch) so an interrupt can't leave later
577
+ // branches un-pushed.
578
+ await pushBranchRef(branch);
359
579
  } else {
360
580
  // Conflict!
361
581
  console.log();
@@ -376,36 +596,42 @@ async function processChain(
376
596
  }
377
597
  }
378
598
 
379
- async function promptPush(branch: string): Promise<void> {
380
- ensureNotMain(branch);
381
-
382
- if (!(await git.remoteBranchExists(branch))) return;
599
+ /**
600
+ * Reconcile a single branch's ref with origin. Pushes when the local tip
601
+ * differs from origin OR the remote branch doesn't exist yet (so a branch
602
+ * that was never pushed still lands). No-op when already in sync. (#12)
603
+ */
604
+ async function pushBranchRef(branch: string): Promise<void> {
605
+ if (branch === "main" || branch === "master") return;
383
606
 
384
607
  const localSha = await git.revParse(branch);
385
- let remoteSha: string;
386
- try {
387
- remoteSha = await git.revParse(`origin/${branch}`);
388
- } catch {
389
- return;
390
- }
608
+ const hasRemote = await git.remoteBranchExists(branch);
609
+ const remoteSha = hasRemote ? await git.revParse(`origin/${branch}`).catch(() => null) : null;
391
610
 
392
- if (localSha === remoteSha) return;
611
+ if (hasRemote && remoteSha === localSha) return; // already in sync — nothing to do
393
612
 
394
613
  console.log();
395
614
  console.log(pc.cyan("━".repeat(40)));
396
- console.log(pc.blue(" Push Rebased Branch?"));
615
+ console.log(pc.blue(hasRemote ? " Push Rebased Branch?" : " Push New Branch?"));
397
616
  console.log(pc.cyan("━".repeat(40)));
398
617
  console.log();
399
618
  console.log(`${pc.blue("Branch:")} ${branch}`);
400
- console.log(pc.yellow("⚠ Branch is out of sync with remote"));
401
- console.log(` Local: ${localSha.slice(0, 8)}`);
402
- console.log(` Remote: ${remoteSha.slice(0, 8)}`);
619
+ if (hasRemote) {
620
+ console.log(pc.yellow("⚠ Branch is out of sync with remote"));
621
+ console.log(` Local: ${localSha.slice(0, 8)}`);
622
+ console.log(` Remote: ${remoteSha ? remoteSha.slice(0, 8) : "(unknown)"}`);
623
+ } else {
624
+ console.log(pc.yellow("⚠ Branch has no remote ref yet"));
625
+ console.log(` Local: ${localSha.slice(0, 8)}`);
626
+ }
403
627
  console.log();
404
628
 
405
- const confirmed = await confirmAction("Push with --force-with-lease?");
629
+ const confirmed = await confirmAction(
630
+ hasRemote ? "Push with --force-with-lease?" : `Push ${branch} to origin?`,
631
+ );
406
632
  if (confirmed) {
407
633
  p.log.info(`Pushing ${branch}...`);
408
- const ok = await git.forcePushWithLease(branch);
634
+ const ok = await git.pushStackedBranch(branch);
409
635
  if (ok) {
410
636
  p.log.success("Pushed successfully");
411
637
  } else {
@@ -0,0 +1,159 @@
1
+ // gh-stack split — Cut a stack into two at a given branch.
2
+ //
3
+ // The branch you cut at (and everything above it) moves into a NEW stack whose
4
+ // base is the cut branch's parent — a branch that stays in the original stack.
5
+ // This is a pure metadata operation: no branches are moved or rebased in git,
6
+ // since stack membership is just bookkeeping on top of the real branch graph.
7
+ //
8
+ // Typical use: a long chain is in review and can't merge yet, but new work is
9
+ // piling on top. Split at the first "new work" branch so the original stack
10
+ // stays the review unit and the new stack rides on its tip. Once the original
11
+ // stack merges, re-root the new stack onto main with `gh-stack restack --onto main`.
12
+ import * as p from "../lib/output.ts";
13
+ import pc from "picocolors";
14
+ import { buildRebaseChain, getOrderedBranches, stackBase, writeMetadata } from "../lib/metadata.ts";
15
+ import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
16
+ import { selectBranch, confirmAction } from "../lib/ui.ts";
17
+ import * as git from "../lib/git.ts";
18
+ import type { Stack } from "../types.ts";
19
+
20
+ const HELP = `
21
+ gh-stack split — Cut a stack into two at a branch
22
+
23
+ USAGE
24
+ gh-stack split [<branch>] [options]
25
+
26
+ The branch you cut at — and every branch above it — moves into a new stack
27
+ whose base is the cut branch's parent (which stays in the original stack).
28
+ Purely a metadata operation: no git branches are moved or rebased.
29
+
30
+ If no branch is given, shows an interactive selector. You cannot split at the
31
+ stack's root branch (that would just rename the whole stack).
32
+
33
+ OPTIONS
34
+ --name <name> Name for the new stack (default: the cut branch name)
35
+
36
+ EXAMPLES
37
+ gh-stack split feat-12 # feat-12.. become a new stack based on feat-11
38
+ gh-stack split feat-12 --name phase-2 # ...named "phase-2"
39
+
40
+ AFTER THE ORIGINAL STACK MERGES
41
+ gh-stack restack --onto main # re-root the split stack onto main
42
+ `;
43
+
44
+ export default async function split(args: string[]): Promise<void> {
45
+ if (args.includes("--help")) {
46
+ console.log(HELP);
47
+ return;
48
+ }
49
+
50
+ // Parse flags
51
+ let nameFlag: string | undefined;
52
+ for (let i = 0; i < args.length; i++) {
53
+ if (args[i] === "--name" && args[i + 1]) nameFlag = args[++i];
54
+ }
55
+ const positional = args.filter((a) => !a.startsWith("-"));
56
+
57
+ const meta = await ensureMetadata();
58
+ const stackName = ensureCurrentStack(meta);
59
+ await ensureValidStack(meta, stackName);
60
+ const stack = meta.stacks[stackName]!;
61
+ const currentBranch = await git.currentBranch();
62
+ const base = stackBase(stack);
63
+
64
+ p.intro(pc.cyan("Split Stack"));
65
+
66
+ // Determine the cut branch
67
+ let cutBranch = positional[0];
68
+ if (!cutBranch) {
69
+ cutBranch =
70
+ (await selectBranch(
71
+ stack,
72
+ "Select the branch to cut at (it becomes the new stack's root)",
73
+ currentBranch,
74
+ )) ?? undefined;
75
+ if (!cutBranch) {
76
+ p.cancel("Cancelled");
77
+ process.exit(0);
78
+ }
79
+ }
80
+
81
+ // Validate
82
+ const cut = stack.branches[cutBranch];
83
+ if (!cut) {
84
+ p.cancel(`Branch ${pc.yellow(cutBranch)} not found in stack ${pc.blue(stackName)}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ if (cut.parent === base) {
89
+ p.cancel(
90
+ `${pc.yellow(cutBranch)} is the root of ${pc.blue(stackName)} — there's nothing below it to split off.\n\n Pick a branch higher up the stack.`,
91
+ );
92
+ process.exit(1);
93
+ }
94
+
95
+ // The new stack is the subtree rooted at the cut branch.
96
+ const moving = buildRebaseChain(stack, cutBranch);
97
+ const newBase = cut.parent; // stays in the original stack
98
+
99
+ // Pick a name for the new stack (default = cut branch, auto-suffix on clash)
100
+ let newName = nameFlag || cutBranch;
101
+ if (!nameFlag) {
102
+ let n = 2;
103
+ while (meta.stacks[newName]) newName = `${cutBranch}-${n++}`;
104
+ }
105
+ if (meta.stacks[newName]) {
106
+ p.cancel(`Stack "${newName}" already exists`);
107
+ process.exit(1);
108
+ }
109
+
110
+ // Report the plan
111
+ p.log.info(`Stack: ${pc.yellow(stackName)} (base: ${pc.dim(base)})`);
112
+ p.log.info(`Cut at: ${pc.yellow(cutBranch)} (parent: ${pc.dim(newBase)})`);
113
+ p.log.info(
114
+ `Moving ${moving.length} branch${moving.length > 1 ? "es" : ""} into new stack ${pc.yellow(newName)} (base: ${pc.dim(newBase)})`,
115
+ );
116
+ for (const b of moving) {
117
+ console.log(` ${pc.dim("→")} ${b}`);
118
+ }
119
+ console.log();
120
+
121
+ const confirmed = await confirmAction(
122
+ `Split ${moving.length} branch(es) into ${pc.yellow(newName)} based on ${pc.yellow(newBase)}?`,
123
+ );
124
+ if (!confirmed) {
125
+ p.cancel("Cancelled");
126
+ process.exit(0);
127
+ }
128
+
129
+ // ── Pure metadata move ──
130
+ const newStack: Stack = {
131
+ description: stack.description ? `Split from ${stackName}` : "",
132
+ last_branch: null,
133
+ branches: {},
134
+ base: newBase,
135
+ };
136
+
137
+ for (const b of moving) {
138
+ newStack.branches[b] = stack.branches[b]!; // parent links stay valid within the subtree
139
+ delete stack.branches[b];
140
+ }
141
+
142
+ meta.stacks[newName] = newStack;
143
+
144
+ // Recompute last_branch for both stacks from their now-final graphs
145
+ newStack.last_branch = getOrderedBranches(newStack).at(-1) ?? null;
146
+ stack.last_branch = getOrderedBranches(stack).at(-1) ?? null;
147
+
148
+ // Follow the user: if they're standing on a moved branch, make the new stack
149
+ // current; otherwise leave the original stack current.
150
+ meta.current_stack = moving.includes(currentBranch) ? newName : stackName;
151
+
152
+ await writeMetadata(meta);
153
+
154
+ p.outro(
155
+ pc.green(
156
+ `Split ${pc.yellow(stackName)} → ${pc.yellow(newName)} (${moving.length} branch(es), based on ${pc.yellow(newBase)})`,
157
+ ),
158
+ );
159
+ }