gh-stack 0.11.0 → 0.14.1

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
@@ -85,8 +85,9 @@ create <branch-name> [--description <desc>]
85
85
 
86
86
  submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
87
87
  Push all downstack branches to GitHub, create PRs for branches
88
- that don't have them, and update all PR descriptions with stack
89
- visualization. Idempotent — safe to run repeatedly.
88
+ that don't have them, number each PR title with its stack position
89
+ `(N/M)`, and update all PR descriptions with stack visualization.
90
+ Idempotent — safe to run repeatedly.
90
91
 
91
92
  Self-healing: if the current branch isn't tracked in a stack yet,
92
93
  submit auto-detects the chain from trunk → current, registers (or
@@ -95,9 +96,14 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
95
96
  from the top of a bare chain of local branches converges to the
96
97
  expected end state (stack registered, branches pushed, PRs created).
97
98
 
98
- --title/-t and --body/-b provide PR details directly (skips prompts).
99
- --body-file reads body from a file. In --yes mode without --title,
100
- auto-generates PR titles from branch names.
99
+ --title/-t and --body/-b set the title/body for the branch you're on.
100
+ They work whether the PR is new OR already exists — on an existing PR
101
+ they update it in place: the title keeps its (N/M) stack position and
102
+ the body is replaced with the "📚 Stacked on" block re-merged in. Use
103
+ this instead of `gh pr edit` (which would wipe the stack viz block) to
104
+ let an agent or script regenerate a PR's title/description safely.
105
+ --body-file reads the body from a file. In --yes mode without --title,
106
+ titles are auto-generated from branch names.
101
107
  ```
102
108
 
103
109
  ### Stack Navigation
@@ -148,20 +154,41 @@ sync [--dry-run]
148
154
  Fetch main, rebase the base branch onto main, then restack all
149
155
  children. Snapshots every branch's pre-sync tip so children can be
150
156
  correctly rebased even though their parent's history was rewritten.
157
+ If the bottom PR(s) already merged on main outside gh-stack (e.g.
158
+ the GitHub web UI), sync detects this and advances the stack past
159
+ them — re-rooting the survivor onto main with `--onto` so only its
160
+ own commits replay — instead of replaying the now-squashed commits
161
+ and conflicting.
151
162
 
152
163
  merge [--dry-run] [-d|--delete-branch] [--collapse]
153
- Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
154
- then enables auto-merge for the base PR into main. All merges
155
- happen on GitHub so PRs show as "Merged", Linear tickets close
156
- automatically, and GitHub Actions fire normally. Skips already-
164
+ merge --approved [--dry-run] [-d|--delete-branch]
165
+ Default (top-down collapse): squash-merge the stack top-down via
166
+ GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
167
+ into main. The whole stack lands on main as a single squash commit.
168
+ All merges happen on GitHub so PRs show as "Merged", Linear tickets
169
+ close automatically, and GitHub Actions fire normally. Skips already-
157
170
  merged PRs (safe to re-run). Waits for GitHub between merges, and
158
171
  self-heals its spurious "Head branch is out of date" (a stale PR
159
- head pointer after a child squash) by re-syncing and retrying.
172
+ head pointer after a child squash) by re-syncing and retrying. Before
173
+ handing the base PR to auto-merge it confirms the PR is mergeable: a
174
+ real conflict with main stops the merge (the stack is left intact —
175
+ resolve with `gh-stack sync`, then re-run), while merely waiting on
176
+ pending checks is fine (auto-merge gates on them).
160
177
  --collapse Stop after collapsing the stack into the base PR;
161
178
  leaves the base PR open against main so you can
162
179
  review the cumulative diff on GitHub. Re-run
163
180
  `gh-stack merge` (without --collapse) to finish.
164
181
 
182
+ --approved (bottom-up, merge what's ready): merge approved PRs from
183
+ the BOTTOM up, each as its own squash commit on main, and leave the
184
+ unreviewed tail as a clean stack re-rooted on main. Stops at the
185
+ first PR that isn't approved. For each approved bottom PR it squash-
186
+ merges to main, re-roots the next branch onto main (replaying only
187
+ its own commits), pushes it, and repoints its PR base to main. A PR
188
+ already merged outside gh-stack is detected and advanced past, not
189
+ re-merged. For the "merge PRs as they get reviewed, keep stacking
190
+ the rest" workflow.
191
+
165
192
  split [<branch>] [--name <name>]
166
193
  Cut a stack into two at <branch>. The cut branch and every branch
167
194
  above it move into a NEW stack whose base is the cut branch's parent
@@ -279,19 +306,36 @@ gh-stack solves this by snapshotting every branch's tip **before** any destructi
279
306
 
280
307
  Snapshots also power `gh-stack undo`, so the same data structure does double duty.
281
308
 
309
+ ### Stack Position in PR Titles
310
+
311
+ `submit` keeps each PR's **title** suffixed with its stack position as `(N/M)`,
312
+ so you can see the order from a bare "needs your review" list — where only
313
+ titles show, not descriptions — and review bottom-up:
314
+
315
+ ```
316
+ feat(paid-subs): tiers overview list [BEE-20531] (1/4)
317
+ feat(paid-subs): tier detail page [BEE-20550] (2/4)
318
+ feat(paid-subs): tier create wizard [BEE-20579] (3/4)
319
+ feat(paid-subs): advanced settings step [BEE-20587] (4/4)
320
+ ```
321
+
322
+ Parentheses (not brackets) avoid colliding with `[BEE-1234]` ticket tags. It's
323
+ self-healing and idempotent: re-running `submit` after adding or reordering a
324
+ branch renumbers the whole stack, and a no-op re-submit changes nothing. The
325
+ position is the only managed part of the title — your text (and ticket tags)
326
+ are preserved. Single-PR stacks get no suffix.
327
+
282
328
  ### Stack Visualization
283
329
 
284
- `submit` automatically adds a stack section to all PR descriptions, with each
285
- branch numbered by its position in the stack (handy for spotting "which one is
286
- PR 6 of 12"):
330
+ `submit` also adds a stack section to every PR description:
287
331
 
288
332
  ```
289
333
  ### 📚 Stacked on
290
334
  ⚫ main
291
335
  ┃
292
- ┣━ 1. #123 Backend models
336
+ ┣━ #123 feat: backend models (1/2)
293
337
  ┃
294
- ┗━ 2. #124 Frontend UI 👈
338
+ ┗━ #124 feat: frontend UI (2/2) 👈
295
339
  ```
296
340
 
297
341
  The block is rendered entirely from local metadata plus one repo-identity
@@ -395,9 +439,12 @@ gh-stack top # jump to tip
395
439
  # Check status
396
440
  gh-stack status
397
441
 
398
- # When PRs are approved, merge the stack
442
+ # When the whole stack is approved, merge it down as one squash commit
399
443
  gh-stack merge # squash-merges down, pushes, enables auto-merge
400
444
 
445
+ # Or: merge PRs as they get approved, bottom-up, keep stacking the rest
446
+ gh-stack merge --approved # merges approved bottom PRs, re-roots the tail on main
447
+
401
448
  # Or: collapse first to review the cumulative diff before shipping
402
449
  gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
403
450
  # ...review base PR on GitHub...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.11.0",
3
+ "version": "0.14.1",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -6,13 +6,15 @@ import * as git from "../lib/git.ts";
6
6
  import {
7
7
  findStackForBranch,
8
8
  getOrderedBranches,
9
+ removeBranchFromStack,
9
10
  stackBase,
10
11
  writeMetadata,
11
12
  } from "../lib/metadata.ts";
12
- import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
13
+ import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
13
14
  import { takeSnapshot } from "../lib/snapshot.ts";
14
- import { getBranchRefSha, getPrMergeState, resyncPrHead } from "../lib/github.ts";
15
+ import { getBranchRefSha, getPrInfo, getPrMergeState, resyncPrHead } from "../lib/github.ts";
15
16
  import { confirmAction } from "../lib/ui.ts";
17
+ import { planMerges, advanceNewBottom, type BranchReview } from "../lib/advance.ts";
16
18
 
17
19
  const MAX_RETRIES = 12;
18
20
  const RETRY_DELAY_MS = 5000;
@@ -145,9 +147,44 @@ async function ensureFreshHead(
145
147
  return true;
146
148
  }
147
149
 
150
+ /** A PR's merge readiness, distilled from GitHub's `state` / `mergeable` /
151
+ * `mergeStateStatus` triple into the decision the merge flow actually needs. */
152
+ export type MergeVerdict = "merged" | "closed" | "conflict" | "ready" | "blocked" | "pending";
153
+
154
+ /**
155
+ * Classify a PR's merge state. The crucial distinction is **conflict vs.
156
+ * blocked**: a `CONFLICTING`/`DIRTY` PR can never merge until someone resolves
157
+ * the conflict (so auto-merge would silently never fire — see the base-PR path),
158
+ * whereas a `BLOCKED` PR (required check still running/failing) is merely not
159
+ * ready *yet* and is a legitimate target for `--auto`.
160
+ *
161
+ * merged — already merged
162
+ * closed — closed without merging
163
+ * conflict — mergeable=CONFLICTING or mergeStateStatus=DIRTY (won't self-resolve)
164
+ * pending — GitHub still computing mergeability (UNKNOWN)
165
+ * blocked — mergeable but gated on required checks/reviews
166
+ * ready — mergeable and clean (CLEAN/HAS_HOOKS/UNSTABLE/BEHIND)
167
+ */
168
+ export function classifyMergeState(state: {
169
+ state: string;
170
+ mergeable: string;
171
+ mergeStateStatus: string;
172
+ }): MergeVerdict {
173
+ if (state.state === "MERGED") return "merged";
174
+ if (state.state === "CLOSED") return "closed";
175
+ if (state.mergeable === "CONFLICTING" || state.mergeStateStatus === "DIRTY") return "conflict";
176
+ // Mergeability not computed yet — caller should keep polling.
177
+ if (state.mergeable === "UNKNOWN" || state.mergeStateStatus === "UNKNOWN") return "pending";
178
+ if (state.mergeStateStatus === "BLOCKED") return "blocked";
179
+ return "ready";
180
+ }
181
+
148
182
  /**
149
- * Wait for a PR to become mergeable after a previous merge lands.
150
- * GitHub needs time to process the new commit on the target branch.
183
+ * Wait for a PR to become cleanly mergeable after a previous merge lands.
184
+ * GitHub needs time to process the new commit on the target branch. Returns
185
+ * false fast on a definitive conflict (waiting can't help) and on a closed PR;
186
+ * keeps polling only while GitHub is still computing or the PR is blocked on
187
+ * pending checks.
151
188
  */
152
189
  async function waitForMergeable(
153
190
  prNumber: number,
@@ -157,13 +194,9 @@ async function waitForMergeable(
157
194
  const state = await getPrMergeState(prNumber);
158
195
  if (!state) return false;
159
196
 
160
- if (state.state === "MERGED") return true; // Already merged
161
- if (state.state === "CLOSED") return false;
162
-
163
- // "CLEAN", "HAS_HOOKS", "UNSTABLE" are all mergeable states
164
- if (state.mergeable === "MERGEABLE" && state.mergeStateStatus !== "BLOCKED") {
165
- return true;
166
- }
197
+ const verdict = classifyMergeState(state);
198
+ if (verdict === "merged" || verdict === "ready") return true;
199
+ if (verdict === "conflict" || verdict === "closed") return false; // waiting won't help
167
200
 
168
201
  spinner.message(`Waiting for PR #${prNumber} to be ready... (${attempt}/${MAX_RETRIES})`);
169
202
  await Bun.sleep(RETRY_DELAY_MS);
@@ -171,10 +204,36 @@ async function waitForMergeable(
171
204
  return false;
172
205
  }
173
206
 
207
+ /**
208
+ * Poll until GitHub has *computed* a PR's mergeability, then return the verdict.
209
+ * Unlike `waitForMergeable` this does NOT keep waiting on a `blocked` PR —
210
+ * blocked-on-pending-checks is a fine target for auto-merge, so the base-PR
211
+ * path wants to know that and proceed rather than burn the poll budget. Only a
212
+ * still-`pending` (UNKNOWN) state is polled; returns `pending` if it never
213
+ * settles within the budget.
214
+ */
215
+ async function pollMergeVerdict(
216
+ prNumber: number,
217
+ spinner: ReturnType<typeof p.spinner>,
218
+ ): Promise<MergeVerdict> {
219
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
220
+ const state = await getPrMergeState(prNumber);
221
+ if (!state) return "pending";
222
+
223
+ const verdict = classifyMergeState(state);
224
+ if (verdict !== "pending") return verdict;
225
+
226
+ spinner.message(`Checking PR #${prNumber} mergeability... (${attempt}/${MAX_RETRIES})`);
227
+ await Bun.sleep(RETRY_DELAY_MS);
228
+ }
229
+ return "pending";
230
+ }
231
+
174
232
  export default async function merge(args: string[]): Promise<void> {
175
233
  const dryRun = args.includes("--dry-run");
176
234
  const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
177
235
  const collapse = args.includes("--collapse") || args.includes("--stop-at-base");
236
+ const approved = args.includes("--approved");
178
237
 
179
238
  if (args.includes("--help")) {
180
239
  console.log(`
@@ -182,9 +241,23 @@ gh-stack merge — Squash-merge stack via GitHub
182
241
 
183
242
  USAGE
184
243
  gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
244
+ gh-stack merge --approved [--dry-run] [-d|--delete-branch]
245
+
246
+ DEFAULT (top-down collapse)
247
+ Squash-merges the stack from top to bottom via GitHub:
248
+ PR3 → squash into PR2, PR2 → squash into PR1, PR1 → auto-merge into main.
249
+ The whole stack lands on main as a single squash commit.
250
+
251
+ --approved (bottom-up, merge what's ready)
252
+ Merges approved PRs from the BOTTOM of the stack up, each as its own
253
+ squash commit on main, and leaves the unreviewed tail as a clean stack
254
+ re-rooted on main. Stops at the first PR that isn't approved. For the
255
+ "merge PRs as they get reviewed, keep stacking the rest" workflow.
185
256
 
186
- Squash-merges the stack from top to bottom via GitHub:
187
- PR3 → squash into PR2, PR2 → squash into PR1, PR1 → auto-merge into main.
257
+ For each approved bottom PR it: squash-merges to main, re-roots the next
258
+ branch onto main (replaying only its own commits — no replay of the
259
+ squashed work), pushes it, and repoints its PR base to main. A PR already
260
+ merged outside gh-stack is detected and advanced past, not re-merged.
188
261
 
189
262
  All merges happen on GitHub, so PRs show as "Merged", Linear tickets
190
263
  close automatically, and all GitHub Actions/webhooks fire normally.
@@ -195,8 +268,11 @@ spurious "Head branch is out of date" (a stale PR head pointer after a
195
268
  child squash) by re-syncing the branch and retrying — only a genuine
196
269
  conflict or failing required check is reported as a hard failure.
197
270
 
271
+ A snapshot is taken first; ${pc.green("gh-stack undo")} restores the prior state.
272
+
198
273
  OPTIONS
199
274
  -d, --delete-branch Delete remote branches after merging
275
+ --approved Merge approved PRs bottom-up; leave the rest as PRs
200
276
  --dry-run Show what would happen without doing anything
201
277
  --collapse Stop after collapsing the stack into the base PR;
202
278
  do NOT merge base PR into main. Lets you review the
@@ -207,6 +283,11 @@ OPTIONS
207
283
  return;
208
284
  }
209
285
 
286
+ if (approved) {
287
+ await mergeApproved(deleteFlag, dryRun);
288
+ return;
289
+ }
290
+
210
291
  const meta = await ensureMetadata();
211
292
  const currentBranch = await git.currentBranch();
212
293
  const stackName = findStackForBranch(meta, currentBranch);
@@ -460,15 +541,50 @@ OPTIONS
460
541
 
461
542
  const autoSpinner = p.spinner();
462
543
 
463
- // Wait for base PR to be ready (may need time after last intermediate merge).
464
- // The last intermediate merge moved this branch, so — like the cascade — the
465
- // base PR's head can be stuck behind the live ref; heal it before enabling
466
- // auto-merge, then once more if GitHub still rejects the first attempt.
467
- autoSpinner.start(`Waiting for PR #${basePr} to be ready...`);
544
+ // Confirm the base PR can ACTUALLY merge before handing off to auto-merge.
545
+ // A conflicting PR silently accepts `--auto` but never fires — so enabling
546
+ // auto-merge and archiving on a conflict reports a false success and leaves
547
+ // an un-mergeable PR stranded. Heal a possibly-stale PR head first (the last
548
+ // intermediate merge moved this branch), then poll until GitHub finishes
549
+ // computing mergeability and classify the result.
550
+ autoSpinner.start(`Checking PR #${basePr} is mergeable...`);
468
551
  await ensureFreshHead(basePr!, baseBranch, autoSpinner);
469
- await waitForMergeable(basePr!, autoSpinner);
470
-
471
- autoSpinner.message(`Enabling auto-merge for PR #${basePr}...`);
552
+ const verdict = await pollMergeVerdict(basePr!, autoSpinner);
553
+
554
+ // Conflict / closed / never-settled → STOP. Do not enable auto-merge, do not
555
+ // archive: leave the stack intact so a re-run finishes it after resolution.
556
+ if (verdict === "conflict" || verdict === "closed" || verdict === "pending") {
557
+ autoSpinner.stop(
558
+ pc.red(
559
+ verdict === "conflict"
560
+ ? `PR #${basePr} has merge conflicts with main`
561
+ : verdict === "closed"
562
+ ? `PR #${basePr} is closed — not merging`
563
+ : `Could not confirm PR #${basePr} is mergeable`,
564
+ ),
565
+ );
566
+ console.log();
567
+ if (verdict === "conflict") {
568
+ p.log.error(
569
+ `${pc.yellow(baseBranch)} conflicts with ${pc.green("main")} — auto-merge would never fire.`,
570
+ );
571
+ p.log.info("Resolve the conflict, then finish the merge:");
572
+ console.log(
573
+ ` ${pc.green("gh-stack sync")} ${pc.dim("# rebase the base onto main & resolve conflicts")}`,
574
+ );
575
+ console.log(
576
+ ` ${pc.green("gh-stack merge")} ${pc.dim("# finishes base PR → main + archives")}`,
577
+ );
578
+ } else if (verdict === "pending") {
579
+ p.log.warn(
580
+ "GitHub didn't settle the merge state in time (transient lag, not necessarily a conflict).",
581
+ );
582
+ p.log.info(`Verify the PR on GitHub, then re-run ${pc.green("gh-stack merge")} to finish.`);
583
+ }
584
+ console.log();
585
+ p.log.info(pc.dim("Stack NOT archived — your stack metadata is intact. Re-run to finish."));
586
+ process.exit(2);
587
+ }
472
588
 
473
589
  const enableAutoMerge = async (): Promise<{ ok: boolean; stderr: string }> => {
474
590
  const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
@@ -479,20 +595,29 @@ OPTIONS
479
595
  return { ok: exitCode === 0, stderr: stderr.trim() };
480
596
  };
481
597
 
482
- let auto = await enableAutoMerge();
483
- if (!auto.ok && isTransientMergeError(auto.stderr)) {
484
- // Same stale-head artifact as the cascade — re-sync and try once more.
485
- await ensureFreshHead(basePr!, baseBranch, autoSpinner);
486
- await waitForMergeable(basePr!, autoSpinner);
487
- auto = await enableAutoMerge();
488
- }
489
-
490
- if (auto.ok) {
491
- autoSpinner.stop(`Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`);
598
+ // verdict is "merged", "ready", or "blocked" (pending checks). All are safe to
599
+ // archive: the PR is either already merged or will merge once checks pass.
600
+ if (verdict === "merged") {
601
+ autoSpinner.stop(`PR #${basePr} already merged into main`);
492
602
  } else {
493
- autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
494
- if (auto.stderr) p.log.error(auto.stderr);
495
- p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
603
+ autoSpinner.message(`Enabling auto-merge for PR #${basePr}...`);
604
+ let auto = await enableAutoMerge();
605
+ if (!auto.ok && isTransientMergeError(auto.stderr)) {
606
+ // Same stale-head artifact as the cascade — re-sync and try once more.
607
+ await ensureFreshHead(basePr!, baseBranch, autoSpinner);
608
+ await pollMergeVerdict(basePr!, autoSpinner);
609
+ auto = await enableAutoMerge();
610
+ }
611
+
612
+ if (auto.ok) {
613
+ autoSpinner.stop(
614
+ `Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`,
615
+ );
616
+ } else {
617
+ autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
618
+ if (auto.stderr) p.log.error(auto.stderr);
619
+ p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
620
+ }
496
621
  }
497
622
 
498
623
  // Update local main and clean up
@@ -522,3 +647,262 @@ OPTIONS
522
647
 
523
648
  p.outro(pc.green("Stack merge complete! Stack archived."));
524
649
  }
650
+
651
+ /**
652
+ * `gh-stack merge --approved` — merge approved PRs from the bottom of the stack
653
+ * up, each as its own squash commit on main, advancing the rest of the stack
654
+ * onto main as it goes. Stops at the first PR that isn't approved, leaving the
655
+ * unreviewed tail as a clean stack re-rooted on main.
656
+ *
657
+ * The cascade is interleaved by necessity: each PR must be rebased off the
658
+ * just-merged commits and pushed before the next one can merge cleanly, so we
659
+ * merge → advance → merge → advance one branch at a time (see ./lib/advance.ts
660
+ * for the re-root mechanics and fork-point resolution).
661
+ */
662
+ async function mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void> {
663
+ // We rebase the surviving branches, so the same pre-flight guards as restack.
664
+ if (await git.isRebaseInProgress()) {
665
+ p.cancel(
666
+ `Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}`,
667
+ );
668
+ process.exit(1);
669
+ }
670
+ if (!dryRun) await ensureCleanWorkingTree();
671
+
672
+ const meta = await ensureMetadata();
673
+ const currentBranch = await git.currentBranch();
674
+ const stackName = findStackForBranch(meta, currentBranch);
675
+ if (!stackName) {
676
+ p.cancel(`Branch ${pc.blue(currentBranch)} not found in any stack`);
677
+ process.exit(1);
678
+ }
679
+
680
+ const stack = meta.stacks[stackName]!;
681
+ await ensureValidStack(meta, stackName);
682
+ const ordered = getOrderedBranches(stack);
683
+ if (ordered.length === 0) {
684
+ p.cancel("No branches in this stack");
685
+ process.exit(1);
686
+ }
687
+
688
+ // Like top-down merge: a split stack rooted off another stack can't drain to
689
+ // main until it's re-rooted. Require a trunk base.
690
+ const base = stackBase(stack);
691
+ if (base !== "main" && base !== "master") {
692
+ p.cancel(
693
+ `${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
694
+ ` Merge (or finish) the parent stack first, then re-root onto main:\n` +
695
+ ` ${pc.green("gh-stack restack --onto main")}`,
696
+ );
697
+ process.exit(1);
698
+ }
699
+ const trunk = base;
700
+ const targetRef = `origin/${trunk}`;
701
+
702
+ p.intro(pc.cyan("Merge Approved (bottom-up, via GitHub)"));
703
+ p.log.info(`Stack: ${pc.yellow(stackName)}`);
704
+
705
+ // Gather each branch's PR review state, bottom-up.
706
+ const reviewSpinner = p.spinner();
707
+ reviewSpinner.start("Checking PR review status...");
708
+ const reviews: BranchReview[] = [];
709
+ for (const branch of ordered) {
710
+ const pr = stack.branches[branch]?.pr ?? null;
711
+ let prState: string | null = null;
712
+ let reviewDecision: string | null = null;
713
+ if (pr != null) {
714
+ const info = await getPrInfo(pr);
715
+ prState = info?.state ?? null;
716
+ reviewDecision = info?.reviewDecision ?? null;
717
+ }
718
+ reviews.push({ branch, pr, prState, reviewDecision });
719
+ }
720
+ reviewSpinner.stop("Review status checked");
721
+
722
+ const plan = planMerges(reviews, { approveAndMerge: true });
723
+
724
+ // Show the plan.
725
+ console.log();
726
+ console.log(` ${pc.bold("Plan:")}`);
727
+ for (const step of plan.steps) {
728
+ const label =
729
+ step.action === "advance-only"
730
+ ? pc.dim("already merged — advance past")
731
+ : `${pc.green("squash-merge")} → ${pc.green(trunk)}`;
732
+ console.log(` ${pc.yellow(step.branch)}${step.pr ? ` (#${step.pr})` : ""} → ${label}`);
733
+ }
734
+ if (plan.stop) {
735
+ console.log(
736
+ ` ${pc.dim("⏹")} stop at ${pc.yellow(plan.stop.branch)} ${pc.dim(`(${plan.stop.reason}) — left as a stack on ${trunk}`)}`,
737
+ );
738
+ }
739
+ console.log();
740
+
741
+ const mergeSteps = plan.steps.filter((s) => s.action === "merge");
742
+ if (mergeSteps.length === 0 && !plan.steps.some((s) => s.action === "advance-only")) {
743
+ p.outro(
744
+ plan.stop
745
+ ? pc.yellow(`Nothing approved to merge — bottom PR is ${plan.stop.reason}.`)
746
+ : pc.yellow("Nothing to merge."),
747
+ );
748
+ return;
749
+ }
750
+
751
+ if (dryRun) {
752
+ p.outro(pc.yellow("[DRY RUN] No changes made"));
753
+ return;
754
+ }
755
+
756
+ const confirmed = await confirmAction(
757
+ `Merge ${mergeSteps.length} approved PR(s) bottom-up into ${trunk} and re-root the rest?`,
758
+ );
759
+ if (!confirmed) {
760
+ p.cancel("Cancelled");
761
+ process.exit(0);
762
+ }
763
+
764
+ // Snapshot first — this records every branch's pre-merge tip, which both the
765
+ // fork-point resolver and `gh-stack undo` rely on.
766
+ await takeSnapshot(meta, stackName, "merge-approved");
767
+ // Keep a copy of the original topology to archive if the stack fully drains.
768
+ const originalStack = structuredClone(stack);
769
+
770
+ await $`git fetch origin ${trunk}`.quiet();
771
+
772
+ for (let i = 0; i < plan.steps.length; i++) {
773
+ const step = plan.steps[i]!;
774
+ const mergedBranch = step.branch;
775
+ const child = ordered[i + 1] ?? null;
776
+ const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
777
+
778
+ console.log();
779
+ console.log(pc.cyan("━".repeat(40)));
780
+ console.log(
781
+ `${pc.blue(step.action === "merge" ? "Merge:" : "Advance:")} ${pc.yellow(mergedBranch)}${step.pr ? ` (#${step.pr})` : ""} → ${pc.green(trunk)}`,
782
+ );
783
+ console.log(pc.cyan("━".repeat(40)));
784
+
785
+ if (step.action === "merge") {
786
+ const prNum = step.pr!;
787
+ // Never stand on the branch we're about to merge (especially with
788
+ // --delete-branch, which removes the local branch too).
789
+ if ((await git.currentBranch().catch(() => "")) === mergedBranch) {
790
+ await git.checkout(trunk).catch(() => {});
791
+ }
792
+
793
+ const s = p.spinner();
794
+ s.start(`Checking PR #${prNum} is ready to merge...`);
795
+ await ensureFreshHead(prNum, mergedBranch, s);
796
+ const ready = await waitForMergeable(prNum, s);
797
+ if (!ready) {
798
+ s.stop(pc.red(`PR #${prNum} is not mergeable`));
799
+ p.log.info("Check the PR on GitHub for merge conflicts or required checks.");
800
+ p.log.info("Re-run with --approved to continue from where you left off.");
801
+ process.exit(2);
802
+ }
803
+
804
+ s.message(`Squash-merging PR #${prNum} into ${trunk}...`);
805
+ const result = await squashMergeWithRetry(prNum, mergedBranch, deleteFlag, s);
806
+ if (result.ok) {
807
+ s.stop(`Merged PR #${prNum} into ${pc.green(trunk)}`);
808
+ } else {
809
+ s.stop(pc.red(`Failed to merge PR #${prNum}`));
810
+ p.log.error(result.stderr);
811
+ p.log.info(
812
+ result.genuine
813
+ ? "Real conflict or failing required check — resolve it on GitHub."
814
+ : "GitHub's mergeability recompute didn't settle (transient lag).",
815
+ );
816
+ p.log.info("Re-run with --approved to continue from where you left off.");
817
+ process.exit(2);
818
+ }
819
+ } else {
820
+ p.log.info(`PR #${step.pr} already merged — advancing the stack past it.`);
821
+ }
822
+
823
+ if (child) {
824
+ const res = await advanceNewBottom({
825
+ meta,
826
+ stackName,
827
+ mergedBranch,
828
+ child,
829
+ childPr,
830
+ targetRef,
831
+ recordedBase: trunk,
832
+ });
833
+ if (!res.ok) {
834
+ console.log();
835
+ if (res.reason === "conflict") {
836
+ p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
837
+ console.log(` Resolve the conflict, then:`);
838
+ console.log(` ${pc.green("git rebase --continue")}`);
839
+ console.log(
840
+ ` ${pc.green("gh-stack restack")} ${pc.dim("(restacks the rest of the tail)")}`,
841
+ );
842
+ console.log();
843
+ console.log(
844
+ pc.dim(` The merged PR(s) below ${child} are done; only the re-root needs finishing.`),
845
+ );
846
+ } else {
847
+ p.log.error(
848
+ `Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
849
+ );
850
+ console.log(
851
+ ` Re-root it manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${child}`)}`,
852
+ );
853
+ }
854
+ process.exit(2);
855
+ }
856
+ } else {
857
+ // The merged branch was the top — nothing rides above it. Just drop it.
858
+ await removeBranchFromStack(meta, stackName, mergedBranch);
859
+ }
860
+ }
861
+
862
+ // Did the whole stack drain, or is there a tail to restack?
863
+ const survivingStack = meta.stacks[stackName];
864
+ const remaining = survivingStack ? getOrderedBranches(survivingStack) : [];
865
+
866
+ if (remaining.length === 0) {
867
+ // Fully drained — archive the original topology and land on the trunk.
868
+ console.log();
869
+ const pullSpinner = p.spinner();
870
+ pullSpinner.start("Syncing local trunk...");
871
+ try {
872
+ await git.checkout(trunk);
873
+ await $`git pull origin ${trunk}`.quiet();
874
+ pullSpinner.stop("Local trunk synced");
875
+ } catch {
876
+ pullSpinner.stop(pc.dim(`Could not sync ${trunk} — run git pull manually`));
877
+ }
878
+
879
+ if (!meta.archive) meta.archive = {};
880
+ meta.archive[stackName] = originalStack;
881
+ delete meta.stacks[stackName];
882
+ if (meta.current_stack === stackName) meta.current_stack = null;
883
+ await writeMetadata(meta);
884
+
885
+ p.outro(pc.green("All approved PRs merged — stack drained & archived."));
886
+ return;
887
+ }
888
+
889
+ // Restack the surviving tail onto the newly re-rooted bottom. The new bottom
890
+ // is itself already rebased onto main; restack skips it (base branch) and
891
+ // propagates onto its children using the snapshot taken above.
892
+ console.log();
893
+ p.log.info(
894
+ `Restacking ${remaining.length} remaining branch(es) onto ${pc.yellow(remaining[0]!)}...`,
895
+ );
896
+ const newBottom = remaining[0]!;
897
+ if (await git.localBranchExists(newBottom)) {
898
+ await git.checkout(newBottom);
899
+ const { default: restack } = await import("./restack.ts");
900
+ await restack([]);
901
+ }
902
+
903
+ p.outro(
904
+ pc.green(
905
+ `Merged ${mergeSteps.length} approved PR(s); ${remaining.length} branch(es) left as a stack on ${trunk}.`,
906
+ ),
907
+ );
908
+ }