gh-stack 0.12.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
@@ -96,9 +96,14 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
96
96
  from the top of a bare chain of local branches converges to the
97
97
  expected end state (stack registered, branches pushed, PRs created).
98
98
 
99
- --title/-t and --body/-b provide PR details directly (skips prompts).
100
- --body-file reads body from a file. In --yes mode without --title,
101
- 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.
102
107
  ```
103
108
 
104
109
  ### Stack Navigation
@@ -149,20 +154,41 @@ sync [--dry-run]
149
154
  Fetch main, rebase the base branch onto main, then restack all
150
155
  children. Snapshots every branch's pre-sync tip so children can be
151
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.
152
162
 
153
163
  merge [--dry-run] [-d|--delete-branch] [--collapse]
154
- Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
155
- then enables auto-merge for the base PR into main. All merges
156
- happen on GitHub so PRs show as "Merged", Linear tickets close
157
- 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-
158
170
  merged PRs (safe to re-run). Waits for GitHub between merges, and
159
171
  self-heals its spurious "Head branch is out of date" (a stale PR
160
- 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).
161
177
  --collapse Stop after collapsing the stack into the base PR;
162
178
  leaves the base PR open against main so you can
163
179
  review the cumulative diff on GitHub. Re-run
164
180
  `gh-stack merge` (without --collapse) to finish.
165
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
+
166
192
  split [<branch>] [--name <name>]
167
193
  Cut a stack into two at <branch>. The cut branch and every branch
168
194
  above it move into a NEW stack whose base is the cut branch's parent
@@ -413,9 +439,12 @@ gh-stack top # jump to tip
413
439
  # Check status
414
440
  gh-stack status
415
441
 
416
- # When PRs are approved, merge the stack
442
+ # When the whole stack is approved, merge it down as one squash commit
417
443
  gh-stack merge # squash-merges down, pushes, enables auto-merge
418
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
+
419
448
  # Or: collapse first to review the cumulative diff before shipping
420
449
  gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
421
450
  # ...review base PR on GitHub...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.12.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
+ }
@@ -33,11 +33,16 @@ chain. You never need to run \`gh-stack init\` first.
33
33
  OPTIONS
34
34
  -d, --draft Create new PRs as drafts
35
35
  -n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
36
- -t, --title <title> PR title for new PRs (skips prompt)
37
- -b, --body <body> PR body/description for new PRs
36
+ -t, --title <title> Set the PR title (current branch) — creates or updates
37
+ -b, --body <body> Set the PR body (current branch) — creates or updates
38
38
  --body-file <f> Read PR body from a file
39
39
  --dry-run Show what would happen without doing anything
40
40
 
41
+ Updating an existing PR: -t / -b also update the PR for the branch you're on
42
+ if it already exists — the title keeps its (N/M) stack position, and the body
43
+ is replaced with the "Stacked on" block re-merged in. Use this instead of
44
+ gh pr edit so the stack visualization is never clobbered.
45
+
41
46
  EXAMPLES
42
47
  gh-stack submit # Push + create PRs interactively
43
48
  gh-stack submit -n # Push + create PRs with auto-titles
@@ -357,9 +362,17 @@ export default async function submit(args: string[]): Promise<void> {
357
362
  // visualization. Titles first so the viz renders the numbered titles. Both
358
363
  // render locally and only hit the network for PRs that actually changed
359
364
  // (see issue #16), so a no-op re-submit is free. ──
365
+ // `-t`/`-b` (and `--body-file`) update the PR for the branch you're on, even
366
+ // if it already exists — title is renumbered, body is replaced with the viz
367
+ // block re-merged. This makes `submit` the one tool to update a PR without
368
+ // `gh pr edit` clobbering the `📚 Stacked on` block.
360
369
  p.log.info(pc.cyan("Updating PR titles & stack visualization..."));
361
- const titles = await reconcilePrTitles(stack, ordered);
362
- const viz = await refreshStackViz(meta, stack, ordered);
370
+ const titles = await reconcilePrTitles(stack, ordered, {
371
+ titleOverrides: titleFlag ? { [branch]: titleFlag } : undefined,
372
+ });
373
+ const viz = await refreshStackViz(meta, stack, ordered, {
374
+ bodyOverrides: bodyFlag ? { [branch]: bodyFlag } : undefined,
375
+ });
363
376
 
364
377
  // One persist for everything we cached this run (discovered PRs, base edits,
365
378
  // backfilled/renumbered titles, viz hashes).
@@ -1,16 +1,22 @@
1
1
  // gh-stack sync — Fetch main, rebase base branch onto main, then restack all
2
2
  import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
+ import { $ } from "bun";
4
5
  import * as git from "../lib/git.ts";
5
6
  import {
6
7
  findStackForBranch,
7
8
  getOrderedBranches,
9
+ removeBranchFromStack,
8
10
  saveRestackState,
9
11
  stackBase,
12
+ writeMetadata,
10
13
  } from "../lib/metadata.ts";
11
14
  import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
12
15
  import { takeSnapshot } from "../lib/snapshot.ts";
13
16
  import { confirmAction } from "../lib/ui.ts";
17
+ import { getPrInfo } from "../lib/github.ts";
18
+ import { planMerges, advanceNewBottom, type BranchReview } from "../lib/advance.ts";
19
+ import type { StackMetadata } from "../types.ts";
14
20
 
15
21
  export default async function sync(args: string[]): Promise<void> {
16
22
  const dryRun = args.includes("--dry-run");
@@ -50,9 +56,9 @@ with the base branch included.
50
56
  process.exit(1);
51
57
  }
52
58
 
53
- const stack = meta.stacks[stackName]!;
59
+ let stack = meta.stacks[stackName]!;
54
60
  await ensureValidStack(meta, stackName);
55
- const ordered = getOrderedBranches(stack);
61
+ let ordered = getOrderedBranches(stack);
56
62
 
57
63
  if (ordered.length === 0) {
58
64
  p.cancel("No branches in this stack");
@@ -64,8 +70,6 @@ with the base branch included.
64
70
  // Find the root branch (whose parent is the stack's base)
65
71
  const base = stackBase(stack);
66
72
  const baseIsTrunk = base === "main" || base === "master";
67
- const baseBranch = ordered[0]!;
68
- const baseParent = stack.branches[baseBranch]?.parent;
69
73
 
70
74
  // sync only makes sense for trunk-rooted stacks — it fetches main and
71
75
  // rebases onto it. A split stack is rooted on a branch in another stack, so
@@ -80,6 +84,31 @@ with the base branch included.
80
84
  process.exit(1);
81
85
  }
82
86
 
87
+ // Catch up to reality first: if the bottom PR(s) already landed on the trunk
88
+ // outside gh-stack (e.g. the GitHub web UI), the stack metadata still lists
89
+ // them. A plain rebase onto main would then replay their now-squashed commits
90
+ // and conflict — so advance past them (re-rooting the survivor onto main with
91
+ // `--onto`) before the normal sync runs. (#20)
92
+ const advance = await advanceMergedBottoms(meta, stackName, base, dryRun);
93
+ if (advance.drained) {
94
+ p.outro(pc.green("Bottom PR(s) already merged — stack fully drained & archived."));
95
+ return;
96
+ }
97
+ if (dryRun && advance.detected) {
98
+ // The advance plan was printed above; the normal rebase plan below would be
99
+ // misleading (it'd show replaying the merged commits), so stop here.
100
+ p.outro(pc.green("Sync dry run complete"));
101
+ return;
102
+ }
103
+ if (advance.advanced) {
104
+ // Topology changed — re-derive the root from the freshly-updated metadata.
105
+ stack = meta.stacks[stackName]!;
106
+ ordered = getOrderedBranches(stack);
107
+ }
108
+
109
+ const baseBranch = ordered[0]!;
110
+ const baseParent = stack.branches[baseBranch]?.parent;
111
+
83
112
  if (baseParent !== base) {
84
113
  p.cancel(`Root branch ${pc.yellow(baseBranch)} doesn't have ${base} as parent`);
85
114
  process.exit(1);
@@ -191,6 +220,129 @@ with the base branch included.
191
220
  }
192
221
  }
193
222
 
223
+ /** Result of the merged-bottom catch-up pass. */
224
+ interface AdvanceOutcome {
225
+ /** A merged bottom PR was found (and reported). */
226
+ detected: boolean;
227
+ /** The stack metadata + branches were actually advanced (not in dry-run). */
228
+ advanced: boolean;
229
+ /** The whole stack drained (every branch was already merged) and was archived. */
230
+ drained: boolean;
231
+ }
232
+
233
+ /**
234
+ * Detect bottom PR(s) that already merged on the trunk outside gh-stack and
235
+ * advance the stack past them — re-rooting the survivor onto the trunk with
236
+ * `--onto` so only its own commits replay (never the squashed work). Shares the
237
+ * planner and re-root mechanics with `merge --approved` (see ./lib/advance.ts).
238
+ *
239
+ * Returns what happened so the caller can re-derive the root or short-circuit.
240
+ * In dry-run it reports and makes no changes.
241
+ */
242
+ async function advanceMergedBottoms(
243
+ meta: StackMetadata,
244
+ stackName: string,
245
+ trunk: string,
246
+ dryRun: boolean,
247
+ ): Promise<AdvanceOutcome> {
248
+ const stack = meta.stacks[stackName]!;
249
+ const ordered = getOrderedBranches(stack);
250
+
251
+ // Walk bottom-up gathering PR state; stop at the first branch that isn't a
252
+ // merged PR (only contiguous merged bottoms can be advanced past).
253
+ const reviews: BranchReview[] = [];
254
+ for (const branch of ordered) {
255
+ const pr = stack.branches[branch]?.pr ?? null;
256
+ let prState: string | null = null;
257
+ if (pr != null) {
258
+ const info = await getPrInfo(pr);
259
+ prState = info?.state ?? null;
260
+ }
261
+ reviews.push({ branch, pr, prState, reviewDecision: null });
262
+ if (prState !== "MERGED") break;
263
+ }
264
+
265
+ const plan = planMerges(reviews, { approveAndMerge: false });
266
+ if (plan.steps.length === 0) {
267
+ return { detected: false, advanced: false, drained: false };
268
+ }
269
+
270
+ p.log.warn(`${plan.steps.length} bottom PR(s) already merged on ${trunk} outside gh-stack:`);
271
+ for (const step of plan.steps) {
272
+ console.log(` ${pc.dim("✓")} ${pc.yellow(step.branch)}${step.pr ? ` (#${step.pr})` : ""}`);
273
+ }
274
+ console.log(
275
+ ` ${pc.dim(`Advancing past them (re-rooting onto ${trunk}) instead of replaying squashed commits.`)}`,
276
+ );
277
+ console.log();
278
+
279
+ if (dryRun) {
280
+ p.log.warn("[DRY RUN] Would advance the stack past the merged bottom PR(s)");
281
+ return { detected: true, advanced: false, drained: false };
282
+ }
283
+
284
+ const targetRef = `origin/${trunk}`;
285
+ await $`git fetch origin ${trunk}`.quiet();
286
+ await takeSnapshot(meta, stackName, "sync-advance");
287
+ const originalStack = structuredClone(stack);
288
+
289
+ for (let i = 0; i < plan.steps.length; i++) {
290
+ const mergedBranch = plan.steps[i]!.branch;
291
+ const child = ordered[i + 1] ?? null;
292
+ const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
293
+
294
+ if (child) {
295
+ const res = await advanceNewBottom({
296
+ meta,
297
+ stackName,
298
+ mergedBranch,
299
+ child,
300
+ childPr,
301
+ targetRef,
302
+ recordedBase: trunk,
303
+ });
304
+ if (!res.ok) {
305
+ console.log();
306
+ if (res.reason === "conflict") {
307
+ p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
308
+ console.log(` Resolve it, then finish the restack:`);
309
+ console.log(` ${pc.green("git rebase --continue")}`);
310
+ console.log(` ${pc.green("gh-stack restack")}`);
311
+ } else {
312
+ p.log.error(
313
+ `Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
314
+ );
315
+ console.log(
316
+ ` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${child}`)}`,
317
+ );
318
+ }
319
+ process.exit(2);
320
+ }
321
+ } else {
322
+ await removeBranchFromStack(meta, stackName, mergedBranch);
323
+ }
324
+ }
325
+
326
+ // Whole stack drained? Archive the original topology and land on the trunk.
327
+ const surviving = meta.stacks[stackName];
328
+ if (!surviving || getOrderedBranches(surviving).length === 0) {
329
+ try {
330
+ await git.checkout(trunk);
331
+ await $`git pull origin ${trunk}`.quiet();
332
+ } catch {
333
+ // best-effort local sync
334
+ }
335
+ if (!meta.archive) meta.archive = {};
336
+ meta.archive[stackName] = originalStack;
337
+ delete meta.stacks[stackName];
338
+ if (meta.current_stack === stackName) meta.current_stack = null;
339
+ await writeMetadata(meta);
340
+ return { detected: true, advanced: true, drained: true };
341
+ }
342
+
343
+ return { detected: true, advanced: true, drained: false };
344
+ }
345
+
194
346
  async function promptForcePush(branch: string): Promise<void> {
195
347
  if (branch === "main" || branch === "master") return;
196
348
  if (!(await git.remoteBranchExists(branch))) return;
@@ -24,7 +24,7 @@ import {
24
24
  getRepoNwo,
25
25
  prUrlFor,
26
26
  } from "../lib/github.ts";
27
- import type { StackMetadata, Stack, Branch } from "../types.ts";
27
+ import type { StackMetadata, Stack } from "../types.ts";
28
28
 
29
29
  /** The ref a stack is rooted on, for rendering the base node of the viz. */
30
30
  export interface BaseRef {
@@ -138,31 +138,58 @@ export interface TitleResult {
138
138
  dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
139
139
  }
140
140
 
141
+ /**
142
+ * Decide which PR titles need editing and to what. Pure (no network) so it's
143
+ * unit-testable. `items` must be the whole stack in order — the index drives
144
+ * the `(N/M)` position. The base title is an explicit `override` (from
145
+ * `submit -t`) when given, else the cached `prTitle`; a branch with neither a
146
+ * PR nor a known base is skipped (never guess from the branch name). An edit is
147
+ * emitted only when the desired title differs from what's cached/on GitHub.
148
+ */
149
+ export function planTitleEdits<
150
+ T extends { pr?: number | null; prTitle?: string; override?: string },
151
+ >(items: T[]): { item: T; desired: string }[] {
152
+ const total = items.length;
153
+ const edits: { item: T; desired: string }[] = [];
154
+ for (let i = 0; i < items.length; i++) {
155
+ const it = items[i]!;
156
+ if (it.pr == null) continue;
157
+ const base = it.override ?? it.prTitle;
158
+ if (!base) continue;
159
+ const desired = numberedTitle(base, i + 1, total);
160
+ if (desired !== it.prTitle) edits.push({ item: it, desired });
161
+ }
162
+ return edits;
163
+ }
164
+
141
165
  /**
142
166
  * Reconcile every PR title in the stack to carry its `(N/M)` stack position.
143
167
  * Backfills any missing cached titles first (so we never clobber a real GitHub
144
- * title with a branch-name guess), renders the desired title from the cached
145
- * one, and only calls `gh pr edit --title` when it actually changed — a no-op
146
- * re-submit makes zero edits, while adding/reordering a branch renumbers the
147
- * whole stack. Run this BEFORE refreshStackViz so the viz shows numbered titles.
168
+ * title with a branch-name guess), then edits only the titles that actually
169
+ * changed — a no-op re-submit makes zero edits, while adding/reordering a
170
+ * branch renumbers the whole stack. Run BEFORE refreshStackViz so the viz shows
171
+ * numbered titles.
172
+ *
173
+ * `titleOverrides` (branch → new base title) lets `submit -t` set the current
174
+ * branch's title; the override is renumbered like any other title and the
175
+ * cache is kept in sync.
148
176
  */
149
- export async function reconcilePrTitles(stack: Stack, ordered: string[]): Promise<TitleResult> {
177
+ export async function reconcilePrTitles(
178
+ stack: Stack,
179
+ ordered: string[],
180
+ opts: { titleOverrides?: Record<string, string> } = {},
181
+ ): Promise<TitleResult> {
150
182
  const dirtyBackfill = await backfillTitles(stack, ordered);
151
- const total = ordered.length;
152
-
153
- const plans: { pr: number; desired: string; bm: Branch }[] = [];
154
- for (let i = 0; i < ordered.length; i++) {
155
- const bm = stack.branches[ordered[i]!]!;
156
- // Need a known base title — never guess from the branch name for an
157
- // existing PR (backfill couldn't fetch it → skip rather than clobber).
158
- if (bm.pr == null || !bm.prTitle) continue;
159
- const desired = numberedTitle(bm.prTitle, i + 1, total);
160
- if (desired !== bm.prTitle) plans.push({ pr: bm.pr, desired, bm });
161
- }
183
+ const overrides = opts.titleOverrides ?? {};
184
+
185
+ const items = ordered.map((name) => {
186
+ const bm = stack.branches[name]!;
187
+ return { pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
188
+ });
162
189
 
163
- const oks = await mapLimit(plans, 8, async (plan) => {
164
- const ok = await updatePrTitle(plan.pr, plan.desired);
165
- if (ok) plan.bm.prTitle = plan.desired;
190
+ const oks = await mapLimit(planTitleEdits(items), 8, async ({ item, desired }) => {
191
+ const ok = await updatePrTitle(item.pr!, desired);
192
+ if (ok) item.bm.prTitle = desired;
166
193
  return ok;
167
194
  });
168
195
 
@@ -181,14 +208,20 @@ export interface VizResult {
181
208
  * Refresh the stack visualization across a stack's PRs. Renders every block
182
209
  * locally, then PATCHes only the PRs whose block actually changed (unless
183
210
  * `force`), running the surviving PATCHes concurrently.
211
+ *
212
+ * `bodyOverrides` (branch → new description) lets `submit -b` replace a PR's
213
+ * body: the override is used as the base instead of the current GitHub body,
214
+ * the managed `📚 Stacked on` block is re-merged into it, and the PR is updated
215
+ * even if the viz block itself didn't change.
184
216
  */
185
217
  export async function refreshStackViz(
186
218
  meta: StackMetadata,
187
219
  stack: Stack,
188
220
  ordered: string[],
189
- opts: { force?: boolean } = {},
221
+ opts: { force?: boolean; bodyOverrides?: Record<string, string> } = {},
190
222
  ): Promise<VizResult> {
191
223
  const force = opts.force ?? false;
224
+ const bodyOverrides = opts.bodyOverrides ?? {};
192
225
  const nwo = await getRepoNwo();
193
226
 
194
227
  let dirty = await backfillTitles(stack, ordered);
@@ -204,7 +237,9 @@ export async function refreshStackViz(
204
237
  const viz = buildStackViz(branchInfos, i, baseRef);
205
238
  const hash = vizHash(viz);
206
239
  const bm = stack.branches[info.branch]!;
207
- return { info, viz, hash, bm, needsUpdate: force || bm.vizHash !== hash };
240
+ // A body override forces the PATCH even when the viz block is unchanged.
241
+ const overridden = bodyOverrides[info.branch] != null;
242
+ return { info, viz, hash, bm, needsUpdate: force || overridden || bm.vizHash !== hash };
208
243
  });
209
244
 
210
245
  const toUpdate = plans.filter((plan) => plan.needsUpdate);
@@ -212,7 +247,10 @@ export async function refreshStackViz(
212
247
  const skipped = branchInfos.length - plans.length;
213
248
 
214
249
  const oks = await mapLimit(toUpdate, 8, async (plan) => {
215
- const body = await getPrBody(plan.info.prNumber!);
250
+ // `submit -b` replaces the body; otherwise keep the PR's current body and
251
+ // just re-merge the viz block into it.
252
+ const override = bodyOverrides[plan.info.branch];
253
+ const body = override ?? (await getPrBody(plan.info.prNumber!));
216
254
  if (body === null) return false;
217
255
  const ok = await updatePrBody(plan.info.prNumber!, mergeViz(body, plan.viz));
218
256
  if (ok) plan.bm.vizHash = plan.hash;
package/src/index.ts CHANGED
@@ -185,7 +185,7 @@ ${bold("STACK NAVIGATION")}
185
185
  ${bold("STACK MANAGEMENT")}
186
186
  ${green("restack")} Rebase children onto updated parents
187
187
  ${green("sync")} Sync base with main + restack all
188
- ${green("merge")} Local squash-merge top-down
188
+ ${green("merge")} Squash-merge via GitHub (top-down, or --approved bottom-up)
189
189
  ${green("split")} Cut a stack into two at a branch
190
190
  ${green("rename")} Rename a tracked branch (keeps metadata in sync)
191
191
  ${green("delete")} Remove a branch from the stack
@@ -0,0 +1,185 @@
1
+ // Advancing a stack past merged bottom PRs.
2
+ //
3
+ // When the bottom PR of a stack lands on `main` — whether merged by
4
+ // `gh-stack merge --approved` or squash-merged outside the tool (the GitHub UI)
5
+ // — the rest of the stack must be *advanced*: drop the merged branch from the
6
+ // stack and re-root the new bottom onto `main`, replaying ONLY its own commits.
7
+ //
8
+ // The subtle part is the rebase fork-point. After a squash-merge, `main` carries
9
+ // a brand-new commit that is NOT in the child's history, so `merge-base(child,
10
+ // main)` resolves to the *original* fork — meaning a plain `git rebase main`
11
+ // would replay the already-squashed commits and conflict. The correct base is
12
+ // the merged branch's pre-merge tip; we resolve it exactly like `restack` does
13
+ // (snapshot pre-rewrite tip, falling back to `merge-base(child, mergedBranch)`)
14
+ // and rebase with `git rebase --onto main <merged-tip> <child>`.
15
+ //
16
+ // This module owns the planning (pure, unit-tested) and the per-step git/metadata
17
+ // mechanics. The GitHub squash-merge itself is injected by the caller so this
18
+ // stays free of `merge.ts`'s retry machinery.
19
+ import * as p from "./output.ts";
20
+ import pc from "picocolors";
21
+ import * as git from "./git.ts";
22
+ import type { StackMetadata } from "../types.ts";
23
+ import { removeBranchFromStack } from "./metadata.ts";
24
+ import { findPreRewriteSha } from "./snapshot.ts";
25
+ import { setPrBase } from "./github.ts";
26
+
27
+ /** What a single bottom branch needs: an active squash-merge, or just advancing
28
+ * past one that already merged outside the tool. */
29
+ export type MergeAction = "merge" | "advance-only";
30
+
31
+ /** The bottom-up review snapshot the planner reasons over. Pure data — no I/O. */
32
+ export interface BranchReview {
33
+ branch: string;
34
+ pr: number | null;
35
+ /** PR state from GitHub: "MERGED" | "OPEN" | "CLOSED" | null (no PR). */
36
+ prState: string | null;
37
+ /** "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "PENDING" | null. */
38
+ reviewDecision: string | null;
39
+ }
40
+
41
+ export interface PlannedStep {
42
+ branch: string;
43
+ pr: number | null;
44
+ action: MergeAction;
45
+ }
46
+
47
+ export interface MergePlan {
48
+ /** Contiguous bottom-up branches to process, in order. */
49
+ steps: PlannedStep[];
50
+ /** Where (and why) we stopped — null if the whole stack is included. */
51
+ stop: { branch: string; reason: string } | null;
52
+ }
53
+
54
+ /**
55
+ * Decide which bottom branches to drain, walking bottom-up over the ordered
56
+ * stack. A branch already `MERGED` is always included as `advance-only` (the
57
+ * stack is simply out of sync with reality and must catch up).
58
+ *
59
+ * When `approveAndMerge` is true (`merge --approved`), an unmerged branch is
60
+ * included as a `merge` step only if it has a PR that is `APPROVED`; the first
61
+ * branch that isn't stops the walk. When false (`sync` catching up to reality),
62
+ * only already-merged branches are drained and the first unmerged branch stops
63
+ * the walk regardless of its approval.
64
+ */
65
+ export function planMerges(ordered: BranchReview[], opts: { approveAndMerge: boolean }): MergePlan {
66
+ const steps: PlannedStep[] = [];
67
+
68
+ for (const b of ordered) {
69
+ if (b.prState === "MERGED") {
70
+ steps.push({ branch: b.branch, pr: b.pr, action: "advance-only" });
71
+ continue;
72
+ }
73
+
74
+ if (!opts.approveAndMerge) {
75
+ // sync mode: we only advance past branches already merged on GitHub.
76
+ return { steps, stop: { branch: b.branch, reason: "not merged yet" } };
77
+ }
78
+
79
+ if (b.pr == null) {
80
+ return {
81
+ steps,
82
+ stop: { branch: b.branch, reason: "no PR yet — run gh-stack submit" },
83
+ };
84
+ }
85
+
86
+ if (b.reviewDecision === "APPROVED") {
87
+ steps.push({ branch: b.branch, pr: b.pr, action: "merge" });
88
+ continue;
89
+ }
90
+
91
+ return { steps, stop: { branch: b.branch, reason: reviewReason(b.reviewDecision) } };
92
+ }
93
+
94
+ return { steps, stop: null };
95
+ }
96
+
97
+ function reviewReason(decision: string | null): string {
98
+ switch (decision) {
99
+ case "CHANGES_REQUESTED":
100
+ return "changes requested";
101
+ case "REVIEW_REQUIRED":
102
+ case "PENDING":
103
+ case null:
104
+ return "not approved yet";
105
+ default:
106
+ return `review: ${decision}`;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve the rebase fork-point for re-rooting `child` after `mergedBranch`
112
+ * landed on the trunk. Mirrors restack's resolver:
113
+ * 1. snapshot pre-rewrite tip of `mergedBranch` (handles the case where the
114
+ * merged branch was itself rebased earlier in a multi-merge cascade), then
115
+ * 2. `merge-base(child, mergedBranch)` for the common case where the merged
116
+ * branch still sits at the tip the child was stacked on.
117
+ * Returns null if neither resolves (caller must abort rather than guess).
118
+ */
119
+ export async function resolveForkPoint(
120
+ meta: StackMetadata,
121
+ mergedBranch: string,
122
+ child: string,
123
+ ): Promise<string | null> {
124
+ const fromSnapshot = await findPreRewriteSha(meta, mergedBranch, child);
125
+ if (fromSnapshot) return fromSnapshot;
126
+ return git.mergeBase(child, mergedBranch);
127
+ }
128
+
129
+ /** Outcome of advancing a single new bottom onto the trunk. */
130
+ export type AdvanceResult =
131
+ | { ok: true }
132
+ | { ok: false; reason: "conflict" | "no-fork-point" | "bad-fork-point"; forkPoint?: string };
133
+
134
+ /**
135
+ * Re-root `child` (the branch that becomes the new bottom) onto `targetRef`,
136
+ * dropping `mergedBranch`'s now-squashed commits, then drop `mergedBranch` from
137
+ * the stack metadata (re-parenting `child` and any siblings onto the trunk),
138
+ * push the rebased child, and repoint its PR base to `recordedBase`.
139
+ *
140
+ * The caller MUST have taken a snapshot first — on a rebase conflict this leaves
141
+ * the in-progress `git rebase` for the user to resolve and returns `conflict`.
142
+ */
143
+ export async function advanceNewBottom(opts: {
144
+ meta: StackMetadata;
145
+ stackName: string;
146
+ mergedBranch: string;
147
+ child: string;
148
+ childPr: number | null;
149
+ targetRef: string;
150
+ recordedBase: string;
151
+ }): Promise<AdvanceResult> {
152
+ const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
153
+
154
+ const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
155
+ if (!forkPoint) return { ok: false, reason: "no-fork-point" };
156
+
157
+ // Guard: the fork-point must be an ancestor of the child, else `--onto` would
158
+ // replay an unrelated range. Bail loudly rather than mangle history.
159
+ if (!(await git.isAncestor(forkPoint, child))) {
160
+ return { ok: false, reason: "bad-fork-point", forkPoint };
161
+ }
162
+
163
+ p.log.info(
164
+ `Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
165
+ );
166
+ const ok = await git.rebaseOnto(targetRef, forkPoint, child);
167
+ if (!ok) return { ok: false, reason: "conflict", forkPoint };
168
+
169
+ // Push the rebased head first so the PR diff is clean before its base flips.
170
+ await git.pushStackedBranch(child);
171
+ if (childPr != null) {
172
+ const moved = await setPrBase(childPr, recordedBase);
173
+ if (moved) {
174
+ p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
175
+ } else {
176
+ p.log.warn(`Could not retarget PR #${childPr} — set its base to ${recordedBase} manually`);
177
+ }
178
+ }
179
+
180
+ // Metadata last: re-parents child (and siblings) onto the merged branch's
181
+ // parent (the trunk) and writes. Done after the git work succeeds so a failure
182
+ // never leaves metadata describing a state git didn't reach.
183
+ await removeBranchFromStack(meta, stackName, mergedBranch);
184
+ return { ok: true };
185
+ }
package/src/lib/github.ts CHANGED
@@ -149,7 +149,21 @@ export async function updatePrTitle(prNumber: number, title: string): Promise<bo
149
149
  }
150
150
 
151
151
  /**
152
- * Get PR body text.
152
+ * Repoint a PR's base branch on GitHub (`gh pr edit --base <base>`). Used when a
153
+ * stack advances past a merged bottom: the new bottom PR must retarget `main`.
154
+ * Returns false on failure.
155
+ */
156
+ export async function setPrBase(prNumber: number, base: string): Promise<boolean> {
157
+ try {
158
+ await $`gh pr edit ${prNumber} --base ${base}`.quiet();
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Get body text for a PR.
153
167
  */
154
168
  export async function getPrBody(prNumber: number): Promise<string | null> {
155
169
  try {