gh-stack 0.19.1 → 0.19.3

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
@@ -262,11 +262,14 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
262
262
  Fetch main, rebase the base branch onto main, push it, then restack
263
263
  all children (every branch above it, in one resumable run). Snapshots every branch's pre-sync tip so children can be
264
264
  correctly rebased even though their parent's history was rewritten.
265
- If the bottom PR(s) already merged on main outside gh-stack (e.g.
266
- the GitHub web UI), sync detects this and advances the stack past
267
- them — re-rooting the survivor onto main with `--onto` so only its
268
- own commits replay — instead of replaying the now-squashed commits
269
- and conflicting.
265
+ If the bottom PR(s) already merged outside gh-stack (e.g. the GitHub
266
+ web UI, or the base of a `merge --approved` landing with the PRs
267
+ collapsed into it), sync detects this and advances the stack past
268
+ them: it drops every merged branch and re-roots only the first
269
+ unmerged one onto main with `--onto`, so only its own commits replay,
270
+ instead of replaying the now-squashed commits and conflicting. It
271
+ warns about a merged PR whose changes never reached main (merged
272
+ into a branch after that branch's own PR had landed).
270
273
 
271
274
  --no-push / --push (and their defaults): as for restack. Also skips
272
275
  retargeting a PR's base after advancing past a merged bottom;
@@ -323,7 +326,8 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
323
326
  just its own commits). One PR per run — run it again to
324
327
  land the next. Approval is enforced by GitHub branch
325
328
  protection, not gh-stack. A base PR already merged outside
326
- gh-stack is detected and advanced past. The re-root waits
329
+ gh-stack is detected and advanced past, along with
330
+ PRs already collapsed into it. The re-root waits
327
331
  until origin's main shows the merge commit, and stops (exit
328
332
  2) if it never does. When a re-root stops (a conflict, or
329
333
  main not showing the merge yet), finish it by re-running
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -423,11 +423,11 @@ OPTIONS
423
423
  : "Single branch stack — enabling auto-merge on GitHub.",
424
424
  );
425
425
  if (!dryRun) {
426
- try {
427
- await $`gh pr merge ${basePr} --squash --auto`.quiet();
426
+ const auto = await $`gh pr merge ${basePr} --squash --auto`.nothrow().quiet();
427
+ if (auto.exitCode === 0) {
428
428
  p.log.success(`Auto-merge enabled for PR #${basePr}`);
429
- } catch {
430
- p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
429
+ } else {
430
+ autoMergeFallback(basePr, auto.stderr.toString());
431
431
  }
432
432
  }
433
433
  } else {
@@ -721,6 +721,7 @@ OPTIONS
721
721
 
722
722
  // verdict is "merged", "ready", or "blocked" (pending checks). All are safe to
723
723
  // archive: the PR is either already merged or will merge once checks pass.
724
+ let autoMerging = true;
724
725
  if (verdict === "merged") {
725
726
  autoSpinner.stop(`PR #${basePr} already merged into main`);
726
727
  } else {
@@ -740,7 +741,8 @@ OPTIONS
740
741
  } else {
741
742
  autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
742
743
  if (auto.stderr) p.log.error(auto.stderr);
743
- p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
744
+ autoMergeFallback(basePr!, auto.stderr);
745
+ autoMerging = false;
744
746
  }
745
747
  }
746
748
 
@@ -764,7 +766,7 @@ OPTIONS
764
766
  if (tail.length > 0) {
765
767
  p.outro(
766
768
  pc.green(
767
- `Approved run collapsed into PR #${basePr} → auto-merging to main. ` +
769
+ `Approved run collapsed into PR #${basePr}${autoMerging ? " → auto-merging to main" : ""}. ` +
768
770
  `After it lands, run \`gh-stack sync\` to re-root the ${tail.length} remaining branch(es).`,
769
771
  ),
770
772
  );
@@ -960,6 +962,36 @@ async function collapsedChildren(
960
962
  return { collapsed, unknown: null };
961
963
  }
962
964
 
965
+ /**
966
+ * What to do when `gh pr merge --auto` failed. A repo that doesn't allow
967
+ * auto-merge fails the same way every time, so don't suggest retrying it (#54).
968
+ */
969
+ function autoMergeFallback(prNumber: number, stderr: string): void {
970
+ if (/auto.?merge is not allowed/i.test(stderr)) {
971
+ p.log.info(
972
+ `This repo doesn't allow auto-merge. Merge PR #${prNumber} once its checks pass: ${pc.dim(`gh pr merge ${prNumber} --squash`)}`,
973
+ );
974
+ } else {
975
+ p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${prNumber} --squash --auto`)}`);
976
+ }
977
+ }
978
+
979
+ /**
980
+ * The branches right above the base (`ordered[0]`) whose PRs already merged,
981
+ * bottom-up, stopping at the first that hasn't: PRs collapsed into the base,
982
+ * or landed on their own. A PR that can't be read stops the run, so it's
983
+ * re-rooted rather than dropped on a guess.
984
+ */
985
+ async function mergedRunAbove(stack: Stack, ordered: string[]): Promise<string[]> {
986
+ const run: string[] = [];
987
+ for (const branch of ordered.slice(1)) {
988
+ const pr = stack.branches[branch]?.pr;
989
+ if (pr == null || (await getPrMergeState(pr))?.state !== "MERGED") break;
990
+ run.push(branch);
991
+ }
992
+ return run;
993
+ }
994
+
963
995
  /**
964
996
  * Fetch the trunk and confirm it holds PR `prNumber`'s merge commit (#51).
965
997
  * The re-root rebases onto `origin/<trunk>`; a trunk fetched before the squash
@@ -1071,7 +1103,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1071
1103
  const baseState = await getPrMergeState(basePr);
1072
1104
  const baseAction: MergeAction = baseState?.state === "MERGED" ? "advance-only" : "merge";
1073
1105
  const plan = { steps: [{ branch: baseBranch, pr: basePr, action: baseAction }] };
1074
- const nextChild = ordered[1] ?? null;
1106
+ // PRs already merged above the base (collapsed into it, or landed on their
1107
+ // own) go with it: dropped, never re-rooted, since replaying their commits
1108
+ // onto a trunk that holds them squashed conflicts (#54).
1109
+ const mergedAbove = await mergedRunAbove(stack, ordered);
1110
+ const nextChild = ordered[1 + mergedAbove.length] ?? null;
1075
1111
 
1076
1112
  console.log();
1077
1113
  console.log(` ${pc.bold("Plan:")}`);
@@ -1082,6 +1118,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1082
1118
  : `${pc.green("squash-merge")} → ${pc.green(trunk)}`
1083
1119
  }`,
1084
1120
  );
1121
+ if (mergedAbove.length > 0) {
1122
+ console.log(
1123
+ ` ${pc.dim(`drop ${mergedAbove.join(", ")} (already merged into the branch below)`)}`,
1124
+ );
1125
+ }
1085
1126
  if (nextChild) {
1086
1127
  console.log(
1087
1128
  ` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
@@ -1151,7 +1192,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1151
1192
  const overwrite = args.includes("--overwrite-remote");
1152
1193
  const verified = await guardRemoteContent(
1153
1194
  meta,
1154
- ordered.slice(1).filter((b) => !collapsed.includes(b)),
1195
+ ordered.slice(1).filter((b) => !collapsed.includes(b) && !mergedAbove.includes(b)),
1155
1196
  { command: "merge --base", overwrite, dryRun: false },
1156
1197
  );
1157
1198
 
@@ -1164,7 +1205,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1164
1205
  for (let i = 0; i < plan.steps.length; i++) {
1165
1206
  const step = plan.steps[i]!;
1166
1207
  const mergedBranch = step.branch;
1167
- const child = ordered[i + 1] ?? null;
1208
+ const child = nextChild;
1168
1209
  const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
1169
1210
 
1170
1211
  console.log();
@@ -1232,10 +1273,13 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1232
1273
  process.exit(2);
1233
1274
  }
1234
1275
 
1276
+ // The child sits on the last branch of the merged run.
1277
+ const mergedParent = stack.branches[child]!.parent;
1235
1278
  const res = await advanceNewBottom({
1236
1279
  meta,
1237
1280
  stackName,
1238
- mergedBranch,
1281
+ mergedBranch: mergedParent,
1282
+ alsoMerged: [mergedBranch, ...mergedAbove].filter((b) => b !== mergedParent),
1239
1283
  child,
1240
1284
  childPr,
1241
1285
  targetRef,
@@ -1267,8 +1311,10 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
1267
1311
  process.exit(2);
1268
1312
  }
1269
1313
  } else {
1270
- // The merged branch was the top — nothing rides above it. Just drop it.
1271
- await removeBranchFromStack(meta, stackName, mergedBranch);
1314
+ // Nothing unmerged rides above it. Just drop the merged run.
1315
+ for (const branch of [mergedBranch, ...mergedAbove]) {
1316
+ await removeBranchFromStack(meta, stackName, branch);
1317
+ }
1272
1318
  }
1273
1319
  }
1274
1320
 
@@ -14,8 +14,16 @@ import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib
14
14
  import { resolveFlag } from "../lib/config.ts";
15
15
  import { takeSnapshot } from "../lib/snapshot.ts";
16
16
  import { guardRemoteContent } from "../lib/remote-guard.ts";
17
- import { getPrInfo } from "../lib/github.ts";
18
- import { planMerges, advanceNewBottom, type BranchReview, type MergePlan } from "../lib/advance.ts";
17
+ import { getPrMergeState } from "../lib/github.ts";
18
+ import {
19
+ planMerges,
20
+ advanceNewBottom,
21
+ strandedMerges,
22
+ type BranchReview,
23
+ type MergePlan,
24
+ type MergedRunEntry,
25
+ type StrandedMerge,
26
+ } from "../lib/advance.ts";
19
27
  import type { StackMetadata } from "../types.ts";
20
28
  import restack, {
21
29
  notePendingRun,
@@ -117,7 +125,7 @@ adds on GitHub). Pull or cherry-pick them first; see \`gh-stack restack --help\`
117
125
  // Bottoms whose PRs already merged are skipped: the advance drops them
118
126
  // rather than rewriting them, and after `merge --approved` they're exactly
119
127
  // where the collapse's squash commits live.
120
- const mergedBranches = new Set(merged.steps.map((step) => step.branch));
128
+ const mergedBranches = new Set(merged.plan.steps.map((step) => step.branch));
121
129
  const verified = await guardRemoteContent(
122
130
  meta,
123
131
  ordered.filter((b) => !mergedBranches.has(b)),
@@ -251,60 +259,83 @@ interface AdvanceOutcome {
251
259
  drained: boolean;
252
260
  }
253
261
 
254
- /**
255
- * Detect bottom PR(s) that already merged on the trunk outside gh-stack and
256
- * advance the stack past them — re-rooting the survivor onto the trunk with
257
- * `--onto` so only its own commits replay (never the squashed work). Shares the
258
- * planner and re-root mechanics with `merge --approved` (see ./lib/advance.ts).
259
- *
260
- * Returns what happened so the caller can re-derive the root or short-circuit.
261
- * In dry-run it reports and makes no changes.
262
- */
262
+ /** The contiguous merged bottom branches, and how each one's PR merged. */
263
+ interface MergedBottoms {
264
+ plan: MergePlan;
265
+ run: MergedRunEntry[];
266
+ }
267
+
263
268
  /**
264
269
  * The contiguous bottom branches whose PRs already merged (outside gh-stack,
265
- * or the base of a `merge --approved`), walking bottom-up and stopping at the
266
- * first that isn't.
270
+ * or the base of a `merge --approved` and the PRs collapsed into it), walking
271
+ * bottom-up and stopping at the first that isn't.
267
272
  */
268
- async function findMergedBottoms(meta: StackMetadata, stackName: string): Promise<MergePlan> {
273
+ async function findMergedBottoms(meta: StackMetadata, stackName: string): Promise<MergedBottoms> {
269
274
  const stack = meta.stacks[stackName]!;
270
275
  const reviews: BranchReview[] = [];
276
+ const run: MergedRunEntry[] = [];
271
277
  for (const branch of getOrderedBranches(stack)) {
272
278
  const pr = stack.branches[branch]?.pr ?? null;
273
- let prState: string | null = null;
274
- if (pr != null) {
275
- const info = await getPrInfo(pr);
276
- prState = info?.state ?? null;
277
- }
279
+ const state = pr != null ? await getPrMergeState(pr) : null;
280
+ const prState = state?.state ?? null;
278
281
  reviews.push({ branch, pr, prState, reviewDecision: null });
279
282
  if (prState !== "MERGED") break;
283
+ run.push({
284
+ branch,
285
+ parent: stack.branches[branch]!.parent,
286
+ pr,
287
+ baseRefName: state?.baseRefName ?? null,
288
+ mergedAt: state?.mergedAt ?? null,
289
+ });
280
290
  }
281
- return planMerges(reviews, { approveAndMerge: false });
291
+ return { plan: planMerges(reviews, { approveAndMerge: false }), run };
282
292
  }
283
293
 
294
+ /**
295
+ * Advance the stack past bottom PR(s) that already merged: drop them all and
296
+ * re-root the first unmerged branch onto the trunk with `--onto`, from its
297
+ * merged parent's pre-merge tip, so only its own commits replay. The merged
298
+ * branches themselves are never re-rooted: their commits are already on the
299
+ * trunk, squashed, and replaying them conflicts (#54). Shares the re-root
300
+ * mechanics with `merge` (see ./lib/advance.ts).
301
+ *
302
+ * Returns what happened so the caller can re-derive the root or short-circuit.
303
+ * In dry-run it reports and makes no changes.
304
+ */
284
305
  async function advanceMergedBottoms(
285
306
  meta: StackMetadata,
286
307
  stackName: string,
287
308
  trunk: string,
288
309
  dryRun: boolean,
289
310
  noPush: boolean,
290
- plan: MergePlan,
311
+ merged: MergedBottoms,
291
312
  verified: Map<string, string>,
292
313
  ): Promise<AdvanceOutcome> {
293
314
  const stack = meta.stacks[stackName]!;
294
315
  const ordered = getOrderedBranches(stack);
316
+ const { plan, run } = merged;
295
317
 
296
318
  if (plan.steps.length === 0) {
297
319
  return { detected: false, advanced: false, drained: false };
298
320
  }
299
321
 
300
- p.log.warn(`${plan.steps.length} bottom PR(s) already merged on ${trunk} outside gh-stack:`);
322
+ const mergedBranches = plan.steps.map((step) => step.branch);
323
+ // The first unmerged branch. Ordered parents-first, so its parent is in the run.
324
+ const survivor = ordered[mergedBranches.length] ?? null;
325
+
326
+ p.log.warn(`${plan.steps.length} bottom PR(s) already merged:`);
301
327
  for (const step of plan.steps) {
302
328
  console.log(` ${pc.dim("✓")} ${pc.yellow(step.branch)}${step.pr ? ` (#${step.pr})` : ""}`);
303
329
  }
304
330
  console.log(
305
- ` ${pc.dim(`Advancing past them (re-rooting onto ${trunk}) instead of replaying squashed commits.`)}`,
331
+ ` ${pc.dim(
332
+ survivor
333
+ ? `Dropping them and re-rooting ${survivor} onto ${trunk}, so only its own commits replay.`
334
+ : `Dropping them: nothing is left above them.`,
335
+ )}`,
306
336
  );
307
337
  console.log();
338
+ warnStranded(strandedMerges(run, trunk), trunk);
308
339
 
309
340
  if (dryRun) {
310
341
  p.log.warn("[DRY RUN] Would advance the stack past the merged bottom PR(s)");
@@ -316,45 +347,41 @@ async function advanceMergedBottoms(
316
347
  await takeSnapshot(meta, stackName, "sync-advance");
317
348
  const originalStack = structuredClone(stack);
318
349
 
319
- for (let i = 0; i < plan.steps.length; i++) {
320
- const mergedBranch = plan.steps[i]!.branch;
321
- const child = ordered[i + 1] ?? null;
322
- const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
323
-
324
- if (child) {
325
- const res = await advanceNewBottom({
326
- meta,
327
- stackName,
328
- mergedBranch,
329
- child,
330
- childPr,
331
- targetRef,
332
- recordedBase: trunk,
333
- push: !noPush,
334
- expect: verified.get(child),
335
- });
336
- if (!res.ok) {
337
- console.log();
338
- if (res.reason === "conflict") {
339
- p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
340
- console.log(` Resolve it, then finish the sync:`);
341
- console.log(` ${pc.green("git rebase --continue")}`);
342
- console.log(
343
- ` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${child}; not restack, which would put it back on ${mergedBranch})`)}`,
344
- );
345
- } else {
346
- p.log.error(
347
- `Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
348
- );
349
- console.log(
350
- ` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${child}`)}`,
351
- );
352
- }
353
- process.exit(2);
350
+ if (survivor) {
351
+ const mergedParent = stack.branches[survivor]!.parent;
352
+ const res = await advanceNewBottom({
353
+ meta,
354
+ stackName,
355
+ mergedBranch: mergedParent,
356
+ alsoMerged: mergedBranches.filter((b) => b !== mergedParent),
357
+ child: survivor,
358
+ childPr: stack.branches[survivor]?.pr ?? null,
359
+ targetRef,
360
+ recordedBase: trunk,
361
+ push: !noPush,
362
+ expect: verified.get(survivor),
363
+ });
364
+ if (!res.ok) {
365
+ console.log();
366
+ if (res.reason === "conflict") {
367
+ p.log.error(`Rebase conflict re-rooting ${pc.yellow(survivor)} onto ${trunk}.`);
368
+ console.log(` Resolve it, then finish the sync:`);
369
+ console.log(` ${pc.green("git rebase --continue")}`);
370
+ console.log(
371
+ ` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${survivor}; not restack, which would put it back on ${mergedParent})`)}`,
372
+ );
373
+ } else {
374
+ p.log.error(
375
+ `Could not determine a safe rebase base for ${pc.yellow(survivor)} (${res.reason}).`,
376
+ );
377
+ console.log(
378
+ ` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <${mergedParent}'s pre-merge tip> ${survivor}`)}`,
379
+ );
354
380
  }
355
- } else {
356
- await removeBranchFromStack(meta, stackName, mergedBranch);
381
+ process.exit(2);
357
382
  }
383
+ } else {
384
+ for (const branch of mergedBranches) await removeBranchFromStack(meta, stackName, branch);
358
385
  }
359
386
 
360
387
  // Whole stack drained? Archive the original topology and land on the trunk.
@@ -376,3 +403,25 @@ async function advanceMergedBottoms(
376
403
 
377
404
  return { detected: true, advanced: true, drained: false };
378
405
  }
406
+
407
+ /**
408
+ * Say which merged PRs' changes never reached the trunk. The advance drops
409
+ * their branches either way (re-rooting one would replay its commits onto a
410
+ * trunk they were never squashed into, under the wrong PR), so the user has
411
+ * to land those changes again themselves.
412
+ */
413
+ function warnStranded(stranded: StrandedMerge[], trunk: string): void {
414
+ if (stranded.length === 0) return;
415
+ for (const s of stranded) {
416
+ const pr = s.pr != null ? `PR #${s.pr}` : pc.yellow(s.branch);
417
+ p.log.warn(
418
+ s.landedPr != null
419
+ ? `${pr} merged into ${s.into} after PR #${s.landedPr} had landed, so its changes never reached ${trunk}.`
420
+ : `${pr} merged into ${s.into}, which isn't ${trunk} or a branch below it, so its changes may not be on ${trunk}.`,
421
+ );
422
+ }
423
+ console.log(
424
+ ` ${pc.dim(`Sync drops them from the stack anyway. To ship those changes, open a new PR for them against ${trunk}.`)}`,
425
+ );
426
+ console.log();
427
+ }
@@ -94,6 +94,53 @@ export function planMerges(ordered: BranchReview[], opts: { approveAndMerge: boo
94
94
  return { steps, stop: null };
95
95
  }
96
96
 
97
+ /** One branch of a contiguous merged run, with how its PR merged. */
98
+ export interface MergedRunEntry {
99
+ branch: string;
100
+ /** Its stack parent. */
101
+ parent: string;
102
+ pr: number | null;
103
+ /** The branch its PR merged into. */
104
+ baseRefName: string | null;
105
+ mergedAt: string | null;
106
+ }
107
+
108
+ /** A merged PR whose changes never reached the trunk. */
109
+ export interface StrandedMerge {
110
+ branch: string;
111
+ pr: number | null;
112
+ into: string;
113
+ /** The PR of the branch it merged into, which had already landed; null when that branch isn't in the run. */
114
+ landedPr: number | null;
115
+ }
116
+
117
+ /**
118
+ * The merged PRs in `run` (bottom-up) whose changes never reached `trunk`.
119
+ * Advancing past a merged run drops its branches without replaying them, which
120
+ * is only right if each one's changes are on the trunk: merged into the trunk,
121
+ * or into a branch of the run before that branch's own PR merged (a collapse).
122
+ * One merged into its parent after the parent landed, or into a branch outside
123
+ * the run, is stranded. Missing facts never claim stranded.
124
+ */
125
+ export function strandedMerges(run: MergedRunEntry[], trunk: string): StrandedMerge[] {
126
+ const byBranch = new Map(run.map((entry) => [entry.branch, entry]));
127
+ const stranded: StrandedMerge[] = [];
128
+ for (const entry of run) {
129
+ const into = entry.baseRefName;
130
+ if (!into || into === trunk) continue;
131
+ const container = byBranch.get(into);
132
+ if (!container) {
133
+ stranded.push({ branch: entry.branch, pr: entry.pr, into, landedPr: null });
134
+ continue;
135
+ }
136
+ if (!entry.mergedAt || !container.mergedAt) continue;
137
+ if (Date.parse(entry.mergedAt) > Date.parse(container.mergedAt)) {
138
+ stranded.push({ branch: entry.branch, pr: entry.pr, into, landedPr: container.pr });
139
+ }
140
+ }
141
+ return stranded;
142
+ }
143
+
97
144
  function reviewReason(decision: string | null): string {
98
145
  switch (decision) {
99
146
  case "CHANGES_REQUESTED":
@@ -142,9 +189,10 @@ export type AdvanceResult =
142
189
 
143
190
  /**
144
191
  * Re-root `child` (the branch that becomes the new bottom) onto `targetRef`,
145
- * dropping `mergedBranch`'s now-squashed commits, then drop `mergedBranch` from
146
- * the stack metadata (re-parenting `child` and any siblings onto the trunk),
147
- * push the rebased child, and repoint its PR base to `recordedBase`.
192
+ * dropping `mergedBranch`'s now-squashed commits, then drop `mergedBranch` (and
193
+ * `alsoMerged`) from the stack metadata (re-parenting `child` and any siblings
194
+ * onto the trunk), push the rebased child, and repoint its PR base to
195
+ * `recordedBase`.
148
196
  *
149
197
  * The caller MUST have taken a snapshot first — on a rebase conflict this leaves
150
198
  * the in-progress `git rebase` for the user to resolve and returns `conflict`.
@@ -155,7 +203,13 @@ export type AdvanceResult =
155
203
  export async function advanceNewBottom(opts: {
156
204
  meta: StackMetadata;
157
205
  stackName: string;
206
+ /** The merged branch `child` sits on: its pre-merge tip is the fork point. */
158
207
  mergedBranch: string;
208
+ /**
209
+ * The rest of a merged run below `mergedBranch` (#54). Their commits are in
210
+ * the trunk too, so they're dropped without being re-rooted themselves.
211
+ */
212
+ alsoMerged?: string[];
159
213
  child: string;
160
214
  childPr: number | null;
161
215
  targetRef: string;
@@ -227,10 +281,12 @@ export async function advanceNewBottom(opts: {
227
281
  );
228
282
  }
229
283
 
230
- // Metadata last: re-parents child (and siblings) onto the merged branch's
231
- // parent (the trunk) and writes. Done after the git work succeeds so a failure
284
+ // Metadata last: drop the merged run, which re-parents child (and siblings)
285
+ // onto the trunk, and write. Done after the git work succeeds so a failure
232
286
  // never leaves metadata describing a state git didn't reach.
233
- await removeBranchFromStack(meta, stackName, mergedBranch);
287
+ for (const merged of [...(opts.alsoMerged ?? []), mergedBranch]) {
288
+ await removeBranchFromStack(meta, stackName, merged);
289
+ }
234
290
 
235
291
  // Record where the child's PR really points. Until it's retargeted it still
236
292
  // targets the merged branch, and the next successful push of the child
package/src/lib/github.ts CHANGED
@@ -215,16 +215,18 @@ export interface PrMergeResult {
215
215
  baseRefName: string;
216
216
  /** The commit merging it created on its base (for a squash merge, the squash commit). */
217
217
  mergeCommit: string | null;
218
+ /** When it merged (ISO 8601); null if it hasn't. */
219
+ mergedAt: string | null;
218
220
  }
219
221
 
220
222
  export async function getPrMergeState(prNumber: number): Promise<PrMergeResult | null> {
221
223
  try {
222
224
  const data = JSON.parse(
223
225
  (
224
- await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,mergeCommit`.text()
226
+ await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,mergeCommit,mergedAt`.text()
225
227
  ).trim(),
226
228
  );
227
- return { ...data, mergeCommit: data.mergeCommit?.oid ?? null };
229
+ return { ...data, mergeCommit: data.mergeCommit?.oid ?? null, mergedAt: data.mergedAt || null };
228
230
  } catch {
229
231
  return null;
230
232
  }
@@ -303,9 +303,11 @@ often the squash commit a `merge --collapse` added on GitHub. Run the
303
303
  pass `--overwrite-remote` unless you were asked to drop those commits: it
304
304
  deletes them from the PR.
305
305
 
306
- **`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI,
307
- `sync` detects it and advances the stack past the merged branch (re-rooting onto
308
- trunk) instead of replaying squashed commits and conflicting.
306
+ **`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI, or
307
+ the base of a `merge --approved` landed with the PRs collapsed into it, `sync`
308
+ detects it, drops every merged branch, and re-roots only the first unmerged one
309
+ onto trunk instead of replaying squashed commits and conflicting. It rebases
310
+ onto `origin/main` (fetched itself), so a stale local `main` doesn't matter.
309
311
 
310
312
  ### 5. Status
311
313
 
@@ -332,7 +334,9 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
332
334
  one commit on trunk. Approval is read from the base up — the first un-approved
333
335
  PR caps the run (unapproved PR4 stops at PR3 even if PR5 is approved). The
334
336
  unapproved tail is left in place; after the base lands, run `gh-stack sync` to
335
- re-root the tail onto trunk (it does not block on CI).
337
+ re-root the tail onto trunk (it does not block on CI). If the repo doesn't
338
+ allow auto-merge, merge the base PR yourself once its checks pass
339
+ (`gh pr merge <N> --squash`), then `gh-stack sync`.
336
340
  - **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
337
341
  the next branch onto trunk — one PR per run, re-run for the next. Use this to
338
342
  land reviewed PRs one at a time. Auto-advances past a bottom PR already merged