gh-stack 0.12.0 → 0.15.0
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 +46 -10
- package/package.json +1 -1
- package/src/commands/merge.ts +418 -34
- package/src/commands/submit.ts +54 -5
- package/src/commands/sync.ts +156 -4
- package/src/commands/update-prs.ts +61 -23
- package/src/index.ts +1 -1
- package/src/lib/advance.ts +185 -0
- package/src/lib/github.ts +15 -1
package/README.md
CHANGED
|
@@ -83,12 +83,19 @@ create <branch-name> [--description <desc>]
|
|
|
83
83
|
Create a new git branch off the current branch and add it to
|
|
84
84
|
the stack. The current branch must already be tracked.
|
|
85
85
|
|
|
86
|
-
submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
|
|
86
|
+
submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--restack] [--dry-run]
|
|
87
87
|
Push all downstack branches to GitHub, create PRs for branches
|
|
88
88
|
that don't have them, number each PR title with its stack position
|
|
89
89
|
`(N/M)`, and update all PR descriptions with stack visualization.
|
|
90
90
|
Idempotent — safe to run repeatedly.
|
|
91
91
|
|
|
92
|
+
--restack: after submitting, restack the branches above the current
|
|
93
|
+
one onto it. submit covers DOWNSTACK (trunk → current); restack covers
|
|
94
|
+
UPSTACK (the children), so together they sync the whole stack from
|
|
95
|
+
wherever you're standing. The everyday loop: drop downstack, fix an
|
|
96
|
+
issue, commit, then `submit --restack` to push the fix and propagate
|
|
97
|
+
it up in one command. Skips cleanly when nothing sits above you.
|
|
98
|
+
|
|
92
99
|
Self-healing: if the current branch isn't tracked in a stack yet,
|
|
93
100
|
submit auto-detects the chain from trunk → current, registers (or
|
|
94
101
|
reconciles into) a stack, then pushes + creates PRs for the whole
|
|
@@ -96,9 +103,14 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
|
|
|
96
103
|
from the top of a bare chain of local branches converges to the
|
|
97
104
|
expected end state (stack registered, branches pushed, PRs created).
|
|
98
105
|
|
|
99
|
-
--title/-t and --body/-b
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
--title/-t and --body/-b set the title/body for the branch you're on.
|
|
107
|
+
They work whether the PR is new OR already exists — on an existing PR
|
|
108
|
+
they update it in place: the title keeps its (N/M) stack position and
|
|
109
|
+
the body is replaced with the "📚 Stacked on" block re-merged in. Use
|
|
110
|
+
this instead of `gh pr edit` (which would wipe the stack viz block) to
|
|
111
|
+
let an agent or script regenerate a PR's title/description safely.
|
|
112
|
+
--body-file reads the body from a file. In --yes mode without --title,
|
|
113
|
+
titles are auto-generated from branch names.
|
|
102
114
|
```
|
|
103
115
|
|
|
104
116
|
### Stack Navigation
|
|
@@ -149,20 +161,41 @@ sync [--dry-run]
|
|
|
149
161
|
Fetch main, rebase the base branch onto main, then restack all
|
|
150
162
|
children. Snapshots every branch's pre-sync tip so children can be
|
|
151
163
|
correctly rebased even though their parent's history was rewritten.
|
|
164
|
+
If the bottom PR(s) already merged on main outside gh-stack (e.g.
|
|
165
|
+
the GitHub web UI), sync detects this and advances the stack past
|
|
166
|
+
them — re-rooting the survivor onto main with `--onto` so only its
|
|
167
|
+
own commits replay — instead of replaying the now-squashed commits
|
|
168
|
+
and conflicting.
|
|
152
169
|
|
|
153
170
|
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
merge --approved [--dry-run] [-d|--delete-branch]
|
|
172
|
+
Default (top-down collapse): squash-merge the stack top-down via
|
|
173
|
+
GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
|
|
174
|
+
into main. The whole stack lands on main as a single squash commit.
|
|
175
|
+
All merges happen on GitHub so PRs show as "Merged", Linear tickets
|
|
176
|
+
close automatically, and GitHub Actions fire normally. Skips already-
|
|
158
177
|
merged PRs (safe to re-run). Waits for GitHub between merges, and
|
|
159
178
|
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.
|
|
179
|
+
head pointer after a child squash) by re-syncing and retrying. Before
|
|
180
|
+
handing the base PR to auto-merge it confirms the PR is mergeable: a
|
|
181
|
+
real conflict with main stops the merge (the stack is left intact —
|
|
182
|
+
resolve with `gh-stack sync`, then re-run), while merely waiting on
|
|
183
|
+
pending checks is fine (auto-merge gates on them).
|
|
161
184
|
--collapse Stop after collapsing the stack into the base PR;
|
|
162
185
|
leaves the base PR open against main so you can
|
|
163
186
|
review the cumulative diff on GitHub. Re-run
|
|
164
187
|
`gh-stack merge` (without --collapse) to finish.
|
|
165
188
|
|
|
189
|
+
--approved (bottom-up, merge what's ready): merge approved PRs from
|
|
190
|
+
the BOTTOM up, each as its own squash commit on main, and leave the
|
|
191
|
+
unreviewed tail as a clean stack re-rooted on main. Stops at the
|
|
192
|
+
first PR that isn't approved. For each approved bottom PR it squash-
|
|
193
|
+
merges to main, re-roots the next branch onto main (replaying only
|
|
194
|
+
its own commits), pushes it, and repoints its PR base to main. A PR
|
|
195
|
+
already merged outside gh-stack is detected and advanced past, not
|
|
196
|
+
re-merged. For the "merge PRs as they get reviewed, keep stacking
|
|
197
|
+
the rest" workflow.
|
|
198
|
+
|
|
166
199
|
split [<branch>] [--name <name>]
|
|
167
200
|
Cut a stack into two at <branch>. The cut branch and every branch
|
|
168
201
|
above it move into a NEW stack whose base is the cut branch's parent
|
|
@@ -413,9 +446,12 @@ gh-stack top # jump to tip
|
|
|
413
446
|
# Check status
|
|
414
447
|
gh-stack status
|
|
415
448
|
|
|
416
|
-
# When
|
|
449
|
+
# When the whole stack is approved, merge it down as one squash commit
|
|
417
450
|
gh-stack merge # squash-merges down, pushes, enables auto-merge
|
|
418
451
|
|
|
452
|
+
# Or: merge PRs as they get approved, bottom-up, keep stacking the rest
|
|
453
|
+
gh-stack merge --approved # merges approved bottom PRs, re-roots the tail on main
|
|
454
|
+
|
|
419
455
|
# Or: collapse first to review the cumulative diff before shipping
|
|
420
456
|
gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
|
|
421
457
|
# ...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
|
+
}
|
package/src/commands/submit.ts
CHANGED
|
@@ -4,13 +4,14 @@ import pc from "picocolors";
|
|
|
4
4
|
import { $ } from "bun";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import {
|
|
7
|
+
buildRebaseChain,
|
|
7
8
|
findStackForBranch,
|
|
8
9
|
getOrderedBranches,
|
|
9
10
|
readMetadata,
|
|
10
11
|
writeMetadata,
|
|
11
12
|
} from "../lib/metadata.ts";
|
|
12
13
|
import { isAutoYes } from "../lib/ui.ts";
|
|
13
|
-
import type { StackMetadata } from "../types.ts";
|
|
14
|
+
import type { Stack, StackMetadata } from "../types.ts";
|
|
14
15
|
import { getPrNumber } from "../lib/github.ts";
|
|
15
16
|
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
16
17
|
import { reconcilePrTitles, refreshStackViz, branchToTitle } from "./update-prs.ts";
|
|
@@ -33,15 +34,29 @@ chain. You never need to run \`gh-stack init\` first.
|
|
|
33
34
|
OPTIONS
|
|
34
35
|
-d, --draft Create new PRs as drafts
|
|
35
36
|
-n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
|
|
36
|
-
-t, --title <title> PR title
|
|
37
|
-
-b, --body <body> PR body
|
|
37
|
+
-t, --title <title> Set the PR title (current branch) — creates or updates
|
|
38
|
+
-b, --body <body> Set the PR body (current branch) — creates or updates
|
|
38
39
|
--body-file <f> Read PR body from a file
|
|
40
|
+
--restack After submitting, restack the branches above the current
|
|
41
|
+
one onto it (propagate a downstack fix up to children)
|
|
39
42
|
--dry-run Show what would happen without doing anything
|
|
40
43
|
|
|
44
|
+
Updating an existing PR: -t / -b also update the PR for the branch you're on
|
|
45
|
+
if it already exists — the title keeps its (N/M) stack position, and the body
|
|
46
|
+
is replaced with the "Stacked on" block re-merged in. Use this instead of
|
|
47
|
+
gh pr edit so the stack visualization is never clobbered.
|
|
48
|
+
|
|
49
|
+
Restack after submit (--restack): submit covers DOWNSTACK (trunk → current);
|
|
50
|
+
restack covers UPSTACK (the children above current). Together they sync the
|
|
51
|
+
whole stack from wherever you're standing. The common loop: drop downstack,
|
|
52
|
+
fix an issue, commit, then \`submit --restack\` to push the fix and propagate
|
|
53
|
+
it up — one command instead of two. Skips cleanly if nothing sits above you.
|
|
54
|
+
|
|
41
55
|
EXAMPLES
|
|
42
56
|
gh-stack submit # Push + create PRs interactively
|
|
43
57
|
gh-stack submit -n # Push + create PRs with auto-titles
|
|
44
58
|
gh-stack submit -t "My PR" -b "Description" # Explicit title and body
|
|
59
|
+
gh-stack submit --restack # Submit, then restack children onto it
|
|
45
60
|
gh-stack submit --draft # Create new PRs as drafts
|
|
46
61
|
gh-stack submit --dry-run # Preview what would happen
|
|
47
62
|
`;
|
|
@@ -85,6 +100,7 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
85
100
|
|
|
86
101
|
const draftFlag = args.includes("--draft") || args.includes("-d");
|
|
87
102
|
const dryRun = args.includes("--dry-run");
|
|
103
|
+
const restackFlag = args.includes("--restack");
|
|
88
104
|
|
|
89
105
|
// Parse value flags
|
|
90
106
|
let titleFlag: string | undefined;
|
|
@@ -350,6 +366,7 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
350
366
|
`Dry run complete: would push ${scope.length}, create ${created}, update ${existing} PR(s)`,
|
|
351
367
|
),
|
|
352
368
|
);
|
|
369
|
+
if (restackFlag) await maybeRestack(stack, branch, true);
|
|
353
370
|
return;
|
|
354
371
|
}
|
|
355
372
|
|
|
@@ -357,9 +374,17 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
357
374
|
// visualization. Titles first so the viz renders the numbered titles. Both
|
|
358
375
|
// render locally and only hit the network for PRs that actually changed
|
|
359
376
|
// (see issue #16), so a no-op re-submit is free. ──
|
|
377
|
+
// `-t`/`-b` (and `--body-file`) update the PR for the branch you're on, even
|
|
378
|
+
// if it already exists — title is renumbered, body is replaced with the viz
|
|
379
|
+
// block re-merged. This makes `submit` the one tool to update a PR without
|
|
380
|
+
// `gh pr edit` clobbering the `📚 Stacked on` block.
|
|
360
381
|
p.log.info(pc.cyan("Updating PR titles & stack visualization..."));
|
|
361
|
-
const titles = await reconcilePrTitles(stack, ordered
|
|
362
|
-
|
|
382
|
+
const titles = await reconcilePrTitles(stack, ordered, {
|
|
383
|
+
titleOverrides: titleFlag ? { [branch]: titleFlag } : undefined,
|
|
384
|
+
});
|
|
385
|
+
const viz = await refreshStackViz(meta, stack, ordered, {
|
|
386
|
+
bodyOverrides: bodyFlag ? { [branch]: bodyFlag } : undefined,
|
|
387
|
+
});
|
|
363
388
|
|
|
364
389
|
// One persist for everything we cached this run (discovered PRs, base edits,
|
|
365
390
|
// backfilled/renumbered titles, viz hashes).
|
|
@@ -375,4 +400,28 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
375
400
|
(viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
|
|
376
401
|
),
|
|
377
402
|
);
|
|
403
|
+
|
|
404
|
+
// ── --restack: propagate a downstack fix up to the children ──
|
|
405
|
+
// submit owns DOWNSTACK (trunk → current); restack owns UPSTACK (the branches
|
|
406
|
+
// above current). Running both from one branch syncs the whole stack.
|
|
407
|
+
if (restackFlag) await maybeRestack(stack, branch, false);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Chain into `restack` from the current branch after a submit, so a downstack
|
|
412
|
+
* fix propagates up to its children in one command. No-op (with a friendly
|
|
413
|
+
* note) when nothing sits above the current branch — there's nothing to rebase.
|
|
414
|
+
*/
|
|
415
|
+
async function maybeRestack(stack: Stack, branch: string, dryRun: boolean): Promise<void> {
|
|
416
|
+
// buildRebaseChain returns the current branch + all its descendants; a length
|
|
417
|
+
// of 1 means it's the top of its line, so there's nothing above to restack.
|
|
418
|
+
if (buildRebaseChain(stack, branch).length <= 1) {
|
|
419
|
+
console.log();
|
|
420
|
+
p.log.info(pc.dim(`--restack: nothing sits above ${pc.yellow(branch)} — nothing to restack.`));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
console.log();
|
|
425
|
+
const { default: restack } = await import("./restack.ts");
|
|
426
|
+
await restack(dryRun ? ["--dry-run"] : []);
|
|
378
427
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -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
|
-
|
|
59
|
+
let stack = meta.stacks[stackName]!;
|
|
54
60
|
await ensureValidStack(meta, stackName);
|
|
55
|
-
|
|
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
|
|
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),
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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(
|
|
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
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
164
|
-
const ok = await updatePrTitle(
|
|
165
|
-
if (ok)
|
|
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
|
-
|
|
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
|
-
|
|
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")}
|
|
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
|
-
*
|
|
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 {
|