gh-stack 0.11.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -16
- package/package.json +1 -1
- package/src/commands/merge.ts +418 -34
- package/src/commands/submit.ts +26 -10
- package/src/commands/sync.ts +156 -4
- package/src/commands/update-prs.ts +116 -10
- package/src/index.ts +1 -1
- package/src/lib/advance.ts +185 -0
- package/src/lib/github.ts +27 -1
package/README.md
CHANGED
|
@@ -85,8 +85,9 @@ create <branch-name> [--description <desc>]
|
|
|
85
85
|
|
|
86
86
|
submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
|
|
87
87
|
Push all downstack branches to GitHub, create PRs for branches
|
|
88
|
-
that don't have them,
|
|
89
|
-
|
|
88
|
+
that don't have them, number each PR title with its stack position
|
|
89
|
+
`(N/M)`, and update all PR descriptions with stack visualization.
|
|
90
|
+
Idempotent — safe to run repeatedly.
|
|
90
91
|
|
|
91
92
|
Self-healing: if the current branch isn't tracked in a stack yet,
|
|
92
93
|
submit auto-detects the chain from trunk → current, registers (or
|
|
@@ -95,9 +96,14 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
|
|
|
95
96
|
from the top of a bare chain of local branches converges to the
|
|
96
97
|
expected end state (stack registered, branches pushed, PRs created).
|
|
97
98
|
|
|
98
|
-
--title/-t and --body/-b
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
--title/-t and --body/-b set the title/body for the branch you're on.
|
|
100
|
+
They work whether the PR is new OR already exists — on an existing PR
|
|
101
|
+
they update it in place: the title keeps its (N/M) stack position and
|
|
102
|
+
the body is replaced with the "📚 Stacked on" block re-merged in. Use
|
|
103
|
+
this instead of `gh pr edit` (which would wipe the stack viz block) to
|
|
104
|
+
let an agent or script regenerate a PR's title/description safely.
|
|
105
|
+
--body-file reads the body from a file. In --yes mode without --title,
|
|
106
|
+
titles are auto-generated from branch names.
|
|
101
107
|
```
|
|
102
108
|
|
|
103
109
|
### Stack Navigation
|
|
@@ -148,20 +154,41 @@ sync [--dry-run]
|
|
|
148
154
|
Fetch main, rebase the base branch onto main, then restack all
|
|
149
155
|
children. Snapshots every branch's pre-sync tip so children can be
|
|
150
156
|
correctly rebased even though their parent's history was rewritten.
|
|
157
|
+
If the bottom PR(s) already merged on main outside gh-stack (e.g.
|
|
158
|
+
the GitHub web UI), sync detects this and advances the stack past
|
|
159
|
+
them — re-rooting the survivor onto main with `--onto` so only its
|
|
160
|
+
own commits replay — instead of replaying the now-squashed commits
|
|
161
|
+
and conflicting.
|
|
151
162
|
|
|
152
163
|
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
merge --approved [--dry-run] [-d|--delete-branch]
|
|
165
|
+
Default (top-down collapse): squash-merge the stack top-down via
|
|
166
|
+
GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
|
|
167
|
+
into main. The whole stack lands on main as a single squash commit.
|
|
168
|
+
All merges happen on GitHub so PRs show as "Merged", Linear tickets
|
|
169
|
+
close automatically, and GitHub Actions fire normally. Skips already-
|
|
157
170
|
merged PRs (safe to re-run). Waits for GitHub between merges, and
|
|
158
171
|
self-heals its spurious "Head branch is out of date" (a stale PR
|
|
159
|
-
head pointer after a child squash) by re-syncing and retrying.
|
|
172
|
+
head pointer after a child squash) by re-syncing and retrying. Before
|
|
173
|
+
handing the base PR to auto-merge it confirms the PR is mergeable: a
|
|
174
|
+
real conflict with main stops the merge (the stack is left intact —
|
|
175
|
+
resolve with `gh-stack sync`, then re-run), while merely waiting on
|
|
176
|
+
pending checks is fine (auto-merge gates on them).
|
|
160
177
|
--collapse Stop after collapsing the stack into the base PR;
|
|
161
178
|
leaves the base PR open against main so you can
|
|
162
179
|
review the cumulative diff on GitHub. Re-run
|
|
163
180
|
`gh-stack merge` (without --collapse) to finish.
|
|
164
181
|
|
|
182
|
+
--approved (bottom-up, merge what's ready): merge approved PRs from
|
|
183
|
+
the BOTTOM up, each as its own squash commit on main, and leave the
|
|
184
|
+
unreviewed tail as a clean stack re-rooted on main. Stops at the
|
|
185
|
+
first PR that isn't approved. For each approved bottom PR it squash-
|
|
186
|
+
merges to main, re-roots the next branch onto main (replaying only
|
|
187
|
+
its own commits), pushes it, and repoints its PR base to main. A PR
|
|
188
|
+
already merged outside gh-stack is detected and advanced past, not
|
|
189
|
+
re-merged. For the "merge PRs as they get reviewed, keep stacking
|
|
190
|
+
the rest" workflow.
|
|
191
|
+
|
|
165
192
|
split [<branch>] [--name <name>]
|
|
166
193
|
Cut a stack into two at <branch>. The cut branch and every branch
|
|
167
194
|
above it move into a NEW stack whose base is the cut branch's parent
|
|
@@ -279,19 +306,36 @@ gh-stack solves this by snapshotting every branch's tip **before** any destructi
|
|
|
279
306
|
|
|
280
307
|
Snapshots also power `gh-stack undo`, so the same data structure does double duty.
|
|
281
308
|
|
|
309
|
+
### Stack Position in PR Titles
|
|
310
|
+
|
|
311
|
+
`submit` keeps each PR's **title** suffixed with its stack position as `(N/M)`,
|
|
312
|
+
so you can see the order from a bare "needs your review" list — where only
|
|
313
|
+
titles show, not descriptions — and review bottom-up:
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
feat(paid-subs): tiers overview list [BEE-20531] (1/4)
|
|
317
|
+
feat(paid-subs): tier detail page [BEE-20550] (2/4)
|
|
318
|
+
feat(paid-subs): tier create wizard [BEE-20579] (3/4)
|
|
319
|
+
feat(paid-subs): advanced settings step [BEE-20587] (4/4)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Parentheses (not brackets) avoid colliding with `[BEE-1234]` ticket tags. It's
|
|
323
|
+
self-healing and idempotent: re-running `submit` after adding or reordering a
|
|
324
|
+
branch renumbers the whole stack, and a no-op re-submit changes nothing. The
|
|
325
|
+
position is the only managed part of the title — your text (and ticket tags)
|
|
326
|
+
are preserved. Single-PR stacks get no suffix.
|
|
327
|
+
|
|
282
328
|
### Stack Visualization
|
|
283
329
|
|
|
284
|
-
`submit`
|
|
285
|
-
branch numbered by its position in the stack (handy for spotting "which one is
|
|
286
|
-
PR 6 of 12"):
|
|
330
|
+
`submit` also adds a stack section to every PR description:
|
|
287
331
|
|
|
288
332
|
```
|
|
289
333
|
### 📚 Stacked on
|
|
290
334
|
⚫ main
|
|
291
335
|
┃
|
|
292
|
-
┣━
|
|
336
|
+
┣━ #123 feat: backend models (1/2)
|
|
293
337
|
┃
|
|
294
|
-
┗━
|
|
338
|
+
┗━ #124 feat: frontend UI (2/2) 👈
|
|
295
339
|
```
|
|
296
340
|
|
|
297
341
|
The block is rendered entirely from local metadata plus one repo-identity
|
|
@@ -395,9 +439,12 @@ gh-stack top # jump to tip
|
|
|
395
439
|
# Check status
|
|
396
440
|
gh-stack status
|
|
397
441
|
|
|
398
|
-
# When
|
|
442
|
+
# When the whole stack is approved, merge it down as one squash commit
|
|
399
443
|
gh-stack merge # squash-merges down, pushes, enables auto-merge
|
|
400
444
|
|
|
445
|
+
# Or: merge PRs as they get approved, bottom-up, keep stacking the rest
|
|
446
|
+
gh-stack merge --approved # merges approved bottom PRs, re-roots the tail on main
|
|
447
|
+
|
|
401
448
|
# Or: collapse first to review the cumulative diff before shipping
|
|
402
449
|
gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
|
|
403
450
|
# ...review base PR on GitHub...
|
package/package.json
CHANGED
package/src/commands/merge.ts
CHANGED
|
@@ -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
|
-
|
|
161
|
-
if (
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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.
|
|
494
|
-
|
|
495
|
-
|
|
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
|
+
}
|