gh-stack 0.12.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -9
- package/package.json +1 -1
- package/src/commands/merge.ts +418 -34
- package/src/commands/submit.ts +17 -4
- 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
|
@@ -96,9 +96,14 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
|
|
|
96
96
|
from the top of a bare chain of local branches converges to the
|
|
97
97
|
expected end state (stack registered, branches pushed, PRs created).
|
|
98
98
|
|
|
99
|
-
--title/-t and --body/-b
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
--title/-t and --body/-b set the title/body for the branch you're on.
|
|
100
|
+
They work whether the PR is new OR already exists — on an existing PR
|
|
101
|
+
they update it in place: the title keeps its (N/M) stack position and
|
|
102
|
+
the body is replaced with the "📚 Stacked on" block re-merged in. Use
|
|
103
|
+
this instead of `gh pr edit` (which would wipe the stack viz block) to
|
|
104
|
+
let an agent or script regenerate a PR's title/description safely.
|
|
105
|
+
--body-file reads the body from a file. In --yes mode without --title,
|
|
106
|
+
titles are auto-generated from branch names.
|
|
102
107
|
```
|
|
103
108
|
|
|
104
109
|
### Stack Navigation
|
|
@@ -149,20 +154,41 @@ sync [--dry-run]
|
|
|
149
154
|
Fetch main, rebase the base branch onto main, then restack all
|
|
150
155
|
children. Snapshots every branch's pre-sync tip so children can be
|
|
151
156
|
correctly rebased even though their parent's history was rewritten.
|
|
157
|
+
If the bottom PR(s) already merged on main outside gh-stack (e.g.
|
|
158
|
+
the GitHub web UI), sync detects this and advances the stack past
|
|
159
|
+
them — re-rooting the survivor onto main with `--onto` so only its
|
|
160
|
+
own commits replay — instead of replaying the now-squashed commits
|
|
161
|
+
and conflicting.
|
|
152
162
|
|
|
153
163
|
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
merge --approved [--dry-run] [-d|--delete-branch]
|
|
165
|
+
Default (top-down collapse): squash-merge the stack top-down via
|
|
166
|
+
GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
|
|
167
|
+
into main. The whole stack lands on main as a single squash commit.
|
|
168
|
+
All merges happen on GitHub so PRs show as "Merged", Linear tickets
|
|
169
|
+
close automatically, and GitHub Actions fire normally. Skips already-
|
|
158
170
|
merged PRs (safe to re-run). Waits for GitHub between merges, and
|
|
159
171
|
self-heals its spurious "Head branch is out of date" (a stale PR
|
|
160
|
-
head pointer after a child squash) by re-syncing and retrying.
|
|
172
|
+
head pointer after a child squash) by re-syncing and retrying. Before
|
|
173
|
+
handing the base PR to auto-merge it confirms the PR is mergeable: a
|
|
174
|
+
real conflict with main stops the merge (the stack is left intact —
|
|
175
|
+
resolve with `gh-stack sync`, then re-run), while merely waiting on
|
|
176
|
+
pending checks is fine (auto-merge gates on them).
|
|
161
177
|
--collapse Stop after collapsing the stack into the base PR;
|
|
162
178
|
leaves the base PR open against main so you can
|
|
163
179
|
review the cumulative diff on GitHub. Re-run
|
|
164
180
|
`gh-stack merge` (without --collapse) to finish.
|
|
165
181
|
|
|
182
|
+
--approved (bottom-up, merge what's ready): merge approved PRs from
|
|
183
|
+
the BOTTOM up, each as its own squash commit on main, and leave the
|
|
184
|
+
unreviewed tail as a clean stack re-rooted on main. Stops at the
|
|
185
|
+
first PR that isn't approved. For each approved bottom PR it squash-
|
|
186
|
+
merges to main, re-roots the next branch onto main (replaying only
|
|
187
|
+
its own commits), pushes it, and repoints its PR base to main. A PR
|
|
188
|
+
already merged outside gh-stack is detected and advanced past, not
|
|
189
|
+
re-merged. For the "merge PRs as they get reviewed, keep stacking
|
|
190
|
+
the rest" workflow.
|
|
191
|
+
|
|
166
192
|
split [<branch>] [--name <name>]
|
|
167
193
|
Cut a stack into two at <branch>. The cut branch and every branch
|
|
168
194
|
above it move into a NEW stack whose base is the cut branch's parent
|
|
@@ -413,9 +439,12 @@ gh-stack top # jump to tip
|
|
|
413
439
|
# Check status
|
|
414
440
|
gh-stack status
|
|
415
441
|
|
|
416
|
-
# When
|
|
442
|
+
# When the whole stack is approved, merge it down as one squash commit
|
|
417
443
|
gh-stack merge # squash-merges down, pushes, enables auto-merge
|
|
418
444
|
|
|
445
|
+
# Or: merge PRs as they get approved, bottom-up, keep stacking the rest
|
|
446
|
+
gh-stack merge --approved # merges approved bottom PRs, re-roots the tail on main
|
|
447
|
+
|
|
419
448
|
# Or: collapse first to review the cumulative diff before shipping
|
|
420
449
|
gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
|
|
421
450
|
# ...review base PR on GitHub...
|
package/package.json
CHANGED
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
|
@@ -33,11 +33,16 @@ chain. You never need to run \`gh-stack init\` first.
|
|
|
33
33
|
OPTIONS
|
|
34
34
|
-d, --draft Create new PRs as drafts
|
|
35
35
|
-n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
|
|
36
|
-
-t, --title <title> PR title
|
|
37
|
-
-b, --body <body> PR body
|
|
36
|
+
-t, --title <title> Set the PR title (current branch) — creates or updates
|
|
37
|
+
-b, --body <body> Set the PR body (current branch) — creates or updates
|
|
38
38
|
--body-file <f> Read PR body from a file
|
|
39
39
|
--dry-run Show what would happen without doing anything
|
|
40
40
|
|
|
41
|
+
Updating an existing PR: -t / -b also update the PR for the branch you're on
|
|
42
|
+
if it already exists — the title keeps its (N/M) stack position, and the body
|
|
43
|
+
is replaced with the "Stacked on" block re-merged in. Use this instead of
|
|
44
|
+
gh pr edit so the stack visualization is never clobbered.
|
|
45
|
+
|
|
41
46
|
EXAMPLES
|
|
42
47
|
gh-stack submit # Push + create PRs interactively
|
|
43
48
|
gh-stack submit -n # Push + create PRs with auto-titles
|
|
@@ -357,9 +362,17 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
357
362
|
// visualization. Titles first so the viz renders the numbered titles. Both
|
|
358
363
|
// render locally and only hit the network for PRs that actually changed
|
|
359
364
|
// (see issue #16), so a no-op re-submit is free. ──
|
|
365
|
+
// `-t`/`-b` (and `--body-file`) update the PR for the branch you're on, even
|
|
366
|
+
// if it already exists — title is renumbered, body is replaced with the viz
|
|
367
|
+
// block re-merged. This makes `submit` the one tool to update a PR without
|
|
368
|
+
// `gh pr edit` clobbering the `📚 Stacked on` block.
|
|
360
369
|
p.log.info(pc.cyan("Updating PR titles & stack visualization..."));
|
|
361
|
-
const titles = await reconcilePrTitles(stack, ordered
|
|
362
|
-
|
|
370
|
+
const titles = await reconcilePrTitles(stack, ordered, {
|
|
371
|
+
titleOverrides: titleFlag ? { [branch]: titleFlag } : undefined,
|
|
372
|
+
});
|
|
373
|
+
const viz = await refreshStackViz(meta, stack, ordered, {
|
|
374
|
+
bodyOverrides: bodyFlag ? { [branch]: bodyFlag } : undefined,
|
|
375
|
+
});
|
|
363
376
|
|
|
364
377
|
// One persist for everything we cached this run (discovered PRs, base edits,
|
|
365
378
|
// backfilled/renumbered titles, viz hashes).
|
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 {
|