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.
@@ -13,7 +13,7 @@ import { isAutoYes } from "../lib/ui.ts";
13
13
  import type { StackMetadata } from "../types.ts";
14
14
  import { getPrNumber } from "../lib/github.ts";
15
15
  import { resolveOrCreateStack } from "../lib/chain.ts";
16
- import { refreshStackViz, branchToTitle } from "./update-prs.ts";
16
+ import { reconcilePrTitles, refreshStackViz, branchToTitle } from "./update-prs.ts";
17
17
 
18
18
  const HELP = `
19
19
  gh-stack submit — Push branches and create/update PRs
@@ -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
@@ -353,14 +358,25 @@ export default async function submit(args: string[]): Promise<void> {
353
358
  return;
354
359
  }
355
360
 
356
- // ── Phase 2: Refresh the stack visualization (renders locally, PATCHes only
357
- // the PRs whose block actually changed; see issue #16). ──
358
- p.log.info(pc.cyan("Updating stack visualization..."));
359
- const viz = await refreshStackViz(meta, stack, ordered);
361
+ // ── Phase 2: Reconcile PR titles (stack position) then refresh the stack
362
+ // visualization. Titles first so the viz renders the numbered titles. Both
363
+ // render locally and only hit the network for PRs that actually changed
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.
369
+ p.log.info(pc.cyan("Updating PR titles & stack visualization..."));
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
+ });
360
376
 
361
377
  // One persist for everything we cached this run (discovered PRs, base edits,
362
- // backfilled titles, viz hashes).
363
- if (metadataChanged || viz.dirty) {
378
+ // backfilled/renumbered titles, viz hashes).
379
+ if (metadataChanged || titles.dirty || viz.dirty) {
364
380
  await writeMetadata(meta);
365
381
  }
366
382
 
@@ -368,7 +384,7 @@ export default async function submit(args: string[]): Promise<void> {
368
384
  console.log();
369
385
  p.outro(
370
386
  pc.green(
371
- `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s)` +
387
+ `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s), ${titles.updated} title(s)` +
372
388
  (viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
373
389
  ),
374
390
  );
@@ -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;
@@ -16,7 +16,14 @@ import {
16
16
  writeMetadata,
17
17
  } from "../lib/metadata.ts";
18
18
  import { ensureMetadata } from "../lib/safety.ts";
19
- import { getPrInfo, getPrBody, updatePrBody, getRepoNwo, prUrlFor } from "../lib/github.ts";
19
+ import {
20
+ getPrInfo,
21
+ getPrBody,
22
+ updatePrBody,
23
+ updatePrTitle,
24
+ getRepoNwo,
25
+ prUrlFor,
26
+ } from "../lib/github.ts";
20
27
  import type { StackMetadata, Stack } from "../types.ts";
21
28
 
22
29
  /** The ref a stack is rooted on, for rendering the base node of the viz. */
@@ -103,6 +110,93 @@ function mergeViz(body: string, viz: string): string {
103
110
  return base ? `${base}\n\n${viz}` : viz;
104
111
  }
105
112
 
113
+ // ── PR-title stack numbering ──
114
+ //
115
+ // The stack position lives in the PR title as a ` (N/M)` suffix so reviewers
116
+ // can see order from a bare "needs review" list (parens, not brackets, to avoid
117
+ // colliding with `[BEE-1234]` ticket tags). The cached `prTitle` tracks exactly
118
+ // what's on GitHub; we recover the base title by stripping the suffix and
119
+ // re-applying the current position, so reordering/adding a branch renumbers the
120
+ // whole stack and a no-op re-submit touches nothing.
121
+
122
+ /** Matches a trailing ` (N/M)` stack-position suffix, e.g. " (2/4)". */
123
+ const SEQ_SUFFIX = /\s*\(\d+\/\d+\)\s*$/;
124
+
125
+ /** Strip a trailing ` (N/M)` suffix to recover the base PR title. */
126
+ export function stripSeqSuffix(title: string): string {
127
+ return title.replace(SEQ_SUFFIX, "").trimEnd();
128
+ }
129
+
130
+ /** The title a branch at `position` of `total` should have. Idempotent. */
131
+ export function numberedTitle(title: string, position: number, total: number): string {
132
+ const base = stripSeqSuffix(title);
133
+ return total >= 2 ? `${base} (${position}/${total})` : base;
134
+ }
135
+
136
+ export interface TitleResult {
137
+ updated: number; // PR titles we edited on GitHub
138
+ dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
139
+ }
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
+
165
+ /**
166
+ * Reconcile every PR title in the stack to carry its `(N/M)` stack position.
167
+ * Backfills any missing cached titles first (so we never clobber a real GitHub
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.
176
+ */
177
+ export async function reconcilePrTitles(
178
+ stack: Stack,
179
+ ordered: string[],
180
+ opts: { titleOverrides?: Record<string, string> } = {},
181
+ ): Promise<TitleResult> {
182
+ const dirtyBackfill = await backfillTitles(stack, ordered);
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
+ });
189
+
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;
193
+ return ok;
194
+ });
195
+
196
+ const updated = oks.filter(Boolean).length;
197
+ return { updated, dirty: dirtyBackfill || updated > 0 };
198
+ }
199
+
106
200
  export interface VizResult {
107
201
  updated: number; // PRs whose description we PATCHed
108
202
  unchanged: number; // PRs skipped because their rendered block was identical
@@ -114,14 +208,20 @@ export interface VizResult {
114
208
  * Refresh the stack visualization across a stack's PRs. Renders every block
115
209
  * locally, then PATCHes only the PRs whose block actually changed (unless
116
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.
117
216
  */
118
217
  export async function refreshStackViz(
119
218
  meta: StackMetadata,
120
219
  stack: Stack,
121
220
  ordered: string[],
122
- opts: { force?: boolean } = {},
221
+ opts: { force?: boolean; bodyOverrides?: Record<string, string> } = {},
123
222
  ): Promise<VizResult> {
124
223
  const force = opts.force ?? false;
224
+ const bodyOverrides = opts.bodyOverrides ?? {};
125
225
  const nwo = await getRepoNwo();
126
226
 
127
227
  let dirty = await backfillTitles(stack, ordered);
@@ -137,7 +237,9 @@ export async function refreshStackViz(
137
237
  const viz = buildStackViz(branchInfos, i, baseRef);
138
238
  const hash = vizHash(viz);
139
239
  const bm = stack.branches[info.branch]!;
140
- 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 };
141
243
  });
142
244
 
143
245
  const toUpdate = plans.filter((plan) => plan.needsUpdate);
@@ -145,7 +247,10 @@ export async function refreshStackViz(
145
247
  const skipped = branchInfos.length - plans.length;
146
248
 
147
249
  const oks = await mapLimit(toUpdate, 8, async (plan) => {
148
- 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!));
149
254
  if (body === null) return false;
150
255
  const ok = await updatePrBody(plan.info.prNumber!, mergeViz(body, plan.viz));
151
256
  if (ok) plan.bm.vizHash = plan.hash;
@@ -192,16 +297,17 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
192
297
  console.log();
193
298
 
194
299
  const s = p.spinner();
195
- s.start("Updating PR descriptions...");
300
+ s.start("Updating PR titles & descriptions...");
301
+ const titles = await reconcilePrTitles(stack, ordered);
196
302
  const result = await refreshStackViz(meta, stack, ordered, { force });
197
303
  s.stop("Done");
198
304
 
199
- if (result.dirty) await writeMetadata(meta);
305
+ if (titles.dirty || result.dirty) await writeMetadata(meta);
200
306
 
201
307
  console.log();
202
308
  p.outro(
203
309
  pc.green(
204
- `Updated ${result.updated} PR(s)` +
310
+ `Updated ${result.updated} description(s), ${titles.updated} title(s)` +
205
311
  (result.unchanged > 0 ? `, ${result.unchanged} unchanged` : "") +
206
312
  (result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
207
313
  ),
@@ -249,9 +355,9 @@ export function buildStackViz(
249
355
  // Tree character
250
356
  const tree = isLast ? "┗━" : "┣━";
251
357
 
252
- // Leading `N.` is the branch's 1-based position in the stack — makes it
253
- // easy to tell which PR is #6 in a 12-PR stack at a glance.
254
- lines.push(`${tree} ${i + 1}. ${prLink} ${info.prTitle}${marker}`);
358
+ // No leading `N.` — the stack position now lives in the PR title itself as
359
+ // a `(N/M)` suffix (see reconcilePrTitles), so the tree just shows titles.
360
+ lines.push(`${tree} ${prLink} ${info.prTitle}${marker}`);
255
361
 
256
362
  if (!isLast) {
257
363
  lines.push("┃");
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
+ }