gh-stack 0.15.0 → 0.17.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 +64 -20
- package/package.json +1 -1
- package/src/commands/add.ts +107 -0
- package/src/commands/bottom.ts +2 -2
- package/src/commands/checkout.ts +9 -8
- package/src/commands/down.ts +2 -2
- package/src/commands/learn.ts +152 -0
- package/src/commands/merge.ts +174 -98
- package/src/commands/restack.ts +57 -13
- package/src/commands/sync.ts +3 -1
- package/src/commands/top.ts +2 -2
- package/src/commands/up.ts +2 -2
- package/src/index.ts +16 -2
- package/src/lib/git.ts +45 -1
- package/src/lib/md.d.ts +7 -0
- package/src/lib/safety.ts +31 -0
- package/src/lib/skill.ts +22 -0
- package/src/lib/using-gh-stack.md +260 -0
package/src/commands/merge.ts
CHANGED
|
@@ -14,7 +14,12 @@ import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib
|
|
|
14
14
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
15
15
|
import { getBranchRefSha, getPrInfo, getPrMergeState, resyncPrHead } from "../lib/github.ts";
|
|
16
16
|
import { confirmAction } from "../lib/ui.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
planMerges,
|
|
19
|
+
advanceNewBottom,
|
|
20
|
+
type BranchReview,
|
|
21
|
+
type MergeAction,
|
|
22
|
+
} from "../lib/advance.ts";
|
|
18
23
|
|
|
19
24
|
const MAX_RETRIES = 12;
|
|
20
25
|
const RETRY_DELAY_MS = 5000;
|
|
@@ -234,57 +239,63 @@ export default async function merge(args: string[]): Promise<void> {
|
|
|
234
239
|
const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
|
|
235
240
|
const collapse = args.includes("--collapse") || args.includes("--stop-at-base");
|
|
236
241
|
const approved = args.includes("--approved");
|
|
242
|
+
const baseOnly = args.includes("--base");
|
|
237
243
|
|
|
238
244
|
if (args.includes("--help")) {
|
|
239
245
|
console.log(`
|
|
240
|
-
gh-stack merge — Squash-merge stack via GitHub
|
|
246
|
+
gh-stack merge — Squash-merge a stack via GitHub
|
|
241
247
|
|
|
242
248
|
USAGE
|
|
243
249
|
gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
244
|
-
gh-stack merge --approved [--dry-run] [-d|--delete-branch]
|
|
250
|
+
gh-stack merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
|
|
251
|
+
gh-stack merge --base [--dry-run] [-d|--delete-branch]
|
|
245
252
|
|
|
246
253
|
DEFAULT (top-down collapse)
|
|
247
|
-
Squash-merges the stack from top to bottom via GitHub:
|
|
254
|
+
Squash-merges the whole stack from top to bottom via GitHub:
|
|
248
255
|
PR3 → squash into PR2, PR2 → squash into PR1, PR1 → auto-merge into main.
|
|
249
256
|
The whole stack lands on main as a single squash commit.
|
|
250
257
|
|
|
251
|
-
--approved (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
--approved (collapse the approved run)
|
|
259
|
+
Like the default collapse, but only as far up as the stack is approved.
|
|
260
|
+
Walks approval from the BASE up and stops at the first PR that isn't
|
|
261
|
+
approved — contiguous, so an unapproved PR4 caps the run at PR3 even if
|
|
262
|
+
PR5 is approved. Collapses base..highest-approved into a single squash
|
|
263
|
+
commit on main and leaves the unapproved tail in place. After the base PR
|
|
264
|
+
lands, run ${pc.green("gh-stack sync")} to re-root the tail onto main. All approved ⇒
|
|
265
|
+
identical to a full merge.
|
|
266
|
+
|
|
267
|
+
--base (merge just the bottom PR)
|
|
268
|
+
Squash-merges only the bottom PR into main as its own commit, then re-roots
|
|
269
|
+
the next branch onto main. One PR per run — run it again to land the next.
|
|
270
|
+
Approval is enforced by GitHub branch protection, not gh-stack.
|
|
271
|
+
|
|
272
|
+
--collapse (stop before main)
|
|
273
|
+
With the default or --approved, stop after collapsing into the base PR
|
|
274
|
+
WITHOUT merging it to main — review the cumulative diff on GitHub, then
|
|
275
|
+
re-run ${pc.green("gh-stack merge")} to finish. Alias: --stop-at-base.
|
|
276
|
+
|
|
277
|
+
All merges happen on GitHub, so PRs show as "Merged", Linear tickets close
|
|
278
|
+
automatically, and Actions/webhooks fire normally. Already-merged PRs are
|
|
279
|
+
skipped (safe to re-run after a partial failure), and a spurious "Head branch
|
|
280
|
+
is out of date" (a stale PR head pointer after a child squash) self-heals by
|
|
281
|
+
re-syncing and retrying — only a genuine conflict or failing required check is
|
|
282
|
+
a hard failure.
|
|
270
283
|
|
|
271
284
|
A snapshot is taken first; ${pc.green("gh-stack undo")} restores the prior state.
|
|
272
285
|
|
|
273
286
|
OPTIONS
|
|
274
287
|
-d, --delete-branch Delete remote branches after merging
|
|
275
|
-
--approved
|
|
288
|
+
--approved Collapse only the approved run (from the base up)
|
|
289
|
+
--base Merge just the bottom PR into main, re-root the next
|
|
290
|
+
--collapse Stop after collapsing into the base PR (don't merge to
|
|
291
|
+
main); alias: --stop-at-base
|
|
276
292
|
--dry-run Show what would happen without doing anything
|
|
277
|
-
--collapse Stop after collapsing the stack into the base PR;
|
|
278
|
-
do NOT merge base PR into main. Lets you review the
|
|
279
|
-
cumulative diff on GitHub first. Re-run ${pc.green("gh-stack merge")}
|
|
280
|
-
(without --collapse) to finish the job.
|
|
281
|
-
--stop-at-base Alias for --collapse
|
|
282
293
|
`);
|
|
283
294
|
return;
|
|
284
295
|
}
|
|
285
296
|
|
|
286
|
-
if (
|
|
287
|
-
await
|
|
297
|
+
if (baseOnly) {
|
|
298
|
+
await mergeBaseOnly(deleteFlag, dryRun);
|
|
288
299
|
return;
|
|
289
300
|
}
|
|
290
301
|
|
|
@@ -314,17 +325,63 @@ OPTIONS
|
|
|
314
325
|
process.exit(1);
|
|
315
326
|
}
|
|
316
327
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
328
|
+
// ── Determine the collapse range ──
|
|
329
|
+
// Default: the whole stack. --approved: only the contiguous-from-base
|
|
330
|
+
// APPROVED run — the first unapproved PR is the ceiling. The unapproved tail
|
|
331
|
+
// is left in place and re-rooted by a later `gh-stack sync` once the base PR
|
|
332
|
+
// lands on main (approach A — no CI blocking). All approved ⇒ whole stack.
|
|
333
|
+
let rangeOrdered = ordered;
|
|
334
|
+
let tail: string[] = [];
|
|
335
|
+
if (approved) {
|
|
336
|
+
const reviewSpinner = p.spinner();
|
|
337
|
+
reviewSpinner.start("Checking PR review status...");
|
|
338
|
+
// Fetch every branch's review state concurrently — sequential `gh` calls
|
|
339
|
+
// add up fast on a deep stack.
|
|
340
|
+
const reviews: BranchReview[] = await Promise.all(
|
|
341
|
+
ordered.map(async (branch) => {
|
|
342
|
+
const pr = stack.branches[branch]?.pr ?? null;
|
|
343
|
+
if (pr == null) return { branch, pr: null, prState: null, reviewDecision: null };
|
|
344
|
+
const info = await getPrInfo(pr);
|
|
345
|
+
return {
|
|
346
|
+
branch,
|
|
347
|
+
pr,
|
|
348
|
+
prState: info?.state ?? null,
|
|
349
|
+
reviewDecision: info?.reviewDecision ?? null,
|
|
350
|
+
};
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
reviewSpinner.stop("Review status checked");
|
|
354
|
+
|
|
355
|
+
// planMerges walks bottom-up over the contiguous run of approved (or
|
|
356
|
+
// already-merged) branches, stopping at the first that isn't approved.
|
|
357
|
+
const plan = planMerges(reviews, { approveAndMerge: true });
|
|
358
|
+
if (plan.steps.length === 0) {
|
|
359
|
+
p.cancel(
|
|
360
|
+
`Nothing approved to merge — the base PR isn't approved yet${plan.stop ? ` (${plan.stop.reason})` : ""}.\n\n` +
|
|
361
|
+
` Approval is taken contiguously from the base, so the bottom PR must be approved first.`,
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
rangeOrdered = ordered.slice(0, plan.steps.length);
|
|
366
|
+
tail = ordered.slice(plan.steps.length);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (rangeOrdered.length <= 1) {
|
|
370
|
+
// Only the base PR is in range (single-branch stack, or --approved with just
|
|
371
|
+
// the bottom approved). Nothing to collapse — enable auto-merge on the base.
|
|
372
|
+
const basePr = stack.branches[rangeOrdered[0]!]?.pr;
|
|
320
373
|
if (collapse) {
|
|
321
374
|
p.log.info(
|
|
322
|
-
`
|
|
375
|
+
`Nothing to collapse — the base PR${basePr ? ` (#${basePr})` : ""} already targets main.`,
|
|
323
376
|
);
|
|
324
377
|
return;
|
|
325
378
|
}
|
|
326
379
|
if (basePr) {
|
|
327
|
-
p.log.info(
|
|
380
|
+
p.log.info(
|
|
381
|
+
tail.length > 0
|
|
382
|
+
? `Base PR (#${basePr}) is approved — enabling auto-merge on GitHub.`
|
|
383
|
+
: "Single branch stack — enabling auto-merge on GitHub.",
|
|
384
|
+
);
|
|
328
385
|
if (!dryRun) {
|
|
329
386
|
try {
|
|
330
387
|
await $`gh pr merge ${basePr} --squash --auto`.quiet();
|
|
@@ -336,11 +393,24 @@ OPTIONS
|
|
|
336
393
|
} else {
|
|
337
394
|
p.log.info("Single branch stack — merge via GitHub as normal.");
|
|
338
395
|
}
|
|
396
|
+
if (tail.length > 0 && !dryRun) {
|
|
397
|
+
console.log();
|
|
398
|
+
p.log.info(
|
|
399
|
+
`${tail.length} unapproved branch(es) left in place. After PR #${basePr} lands, run ${pc.green("gh-stack sync")} to re-root them onto main.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
339
402
|
return;
|
|
340
403
|
}
|
|
341
404
|
|
|
342
405
|
p.intro(pc.cyan(collapse ? "Stack Collapse (via GitHub)" : "Stack Merge (via GitHub)"));
|
|
343
406
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
407
|
+
if (approved) {
|
|
408
|
+
p.log.info(
|
|
409
|
+
pc.dim(
|
|
410
|
+
`--approved: collapsing the ${rangeOrdered.length} approved branch(es) from the base up.`,
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
344
414
|
if (collapse) {
|
|
345
415
|
p.log.info(
|
|
346
416
|
pc.dim("--collapse: will stop after collapsing into base PR; base will NOT merge to main."),
|
|
@@ -348,9 +418,10 @@ OPTIONS
|
|
|
348
418
|
}
|
|
349
419
|
console.log();
|
|
350
420
|
|
|
351
|
-
// Check PR states and build the merge plan
|
|
352
|
-
|
|
353
|
-
const
|
|
421
|
+
// Check PR states and build the merge plan — scoped to the collapse RANGE
|
|
422
|
+
// (the whole stack by default, the approved run under --approved).
|
|
423
|
+
const reversed = rangeOrdered.toReversed();
|
|
424
|
+
const baseBranch = rangeOrdered[0]!;
|
|
354
425
|
const basePr = stack.branches[baseBranch]?.pr;
|
|
355
426
|
|
|
356
427
|
console.log(` ${pc.bold("Merge plan:")}`);
|
|
@@ -377,15 +448,17 @@ OPTIONS
|
|
|
377
448
|
` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
|
|
378
449
|
);
|
|
379
450
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return;
|
|
451
|
+
if (tail.length > 0) {
|
|
452
|
+
console.log(
|
|
453
|
+
` ${pc.dim(`⏹ ${tail.length} unapproved branch(es) above left in place → re-root with `)}${pc.green("gh-stack sync")}${pc.dim(" after the base lands")}`,
|
|
454
|
+
);
|
|
385
455
|
}
|
|
456
|
+
console.log();
|
|
386
457
|
|
|
387
|
-
//
|
|
388
|
-
|
|
458
|
+
// Every PR in the collapse RANGE must have a number (tail branches aren't
|
|
459
|
+
// being merged now, so a local-only tip there is fine). Validate before the
|
|
460
|
+
// dry-run exit so previews surface the same blocker as a real merge.
|
|
461
|
+
const missingPrs = rangeOrdered.filter((b) => !stack.branches[b]?.pr);
|
|
389
462
|
if (missingPrs.length > 0) {
|
|
390
463
|
p.cancel(
|
|
391
464
|
`Missing PR numbers for: ${missingPrs.join(", ")}\n\n Run ${pc.green("gh-stack submit")} first to create PRs.`,
|
|
@@ -393,6 +466,11 @@ OPTIONS
|
|
|
393
466
|
process.exit(1);
|
|
394
467
|
}
|
|
395
468
|
|
|
469
|
+
if (dryRun) {
|
|
470
|
+
p.outro(pc.yellow("[DRY RUN] No changes made"));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
396
474
|
const confirmed = await confirmAction(
|
|
397
475
|
collapse
|
|
398
476
|
? "Collapse stack top-down via GitHub (stop at base PR)?"
|
|
@@ -633,6 +711,20 @@ OPTIONS
|
|
|
633
711
|
pullSpinner.stop(pc.dim("Could not sync local branches — run git pull manually"));
|
|
634
712
|
}
|
|
635
713
|
|
|
714
|
+
// --approved with an unapproved tail: the base PR is auto-merging the approved
|
|
715
|
+
// run as one commit, but branches remain. DON'T archive — leave the stack so
|
|
716
|
+
// `gh-stack sync` can re-root the tail onto main once the base lands (it
|
|
717
|
+
// detects the merged bottom and advances past it). (approach A)
|
|
718
|
+
if (tail.length > 0) {
|
|
719
|
+
p.outro(
|
|
720
|
+
pc.green(
|
|
721
|
+
`Approved run collapsed into PR #${basePr} → auto-merging to main. ` +
|
|
722
|
+
`After it lands, run \`gh-stack sync\` to re-root the ${tail.length} remaining branch(es).`,
|
|
723
|
+
),
|
|
724
|
+
);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
636
728
|
// Archive the stack
|
|
637
729
|
if (!meta.archive) meta.archive = {};
|
|
638
730
|
meta.archive[stackName] = { ...stack };
|
|
@@ -649,17 +741,13 @@ OPTIONS
|
|
|
649
741
|
}
|
|
650
742
|
|
|
651
743
|
/**
|
|
652
|
-
* `gh-stack merge --
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
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).
|
|
744
|
+
* `gh-stack merge --base` — squash-merge ONLY the bottom PR into main, then
|
|
745
|
+
* re-root the next branch onto main (replaying just its own commits, via
|
|
746
|
+
* advance.ts's fork-point resolution). One PR per run — run it again to land the
|
|
747
|
+
* next. Approval is NOT checked here; GitHub branch protection enforces it.
|
|
748
|
+
* A base PR already merged outside gh-stack is detected and advanced past.
|
|
661
749
|
*/
|
|
662
|
-
async function
|
|
750
|
+
async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean): Promise<void> {
|
|
663
751
|
// We rebase the surviving branches, so the same pre-flight guards as restack.
|
|
664
752
|
if (await git.isRebaseInProgress()) {
|
|
665
753
|
p.cancel(
|
|
@@ -699,62 +787,50 @@ async function mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
699
787
|
const trunk = base;
|
|
700
788
|
const targetRef = `origin/${trunk}`;
|
|
701
789
|
|
|
702
|
-
p.intro(pc.cyan("Merge
|
|
790
|
+
p.intro(pc.cyan("Merge Base PR (via GitHub)"));
|
|
703
791
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
704
792
|
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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 });
|
|
793
|
+
const baseBranch = ordered[0]!;
|
|
794
|
+
const basePr = stack.branches[baseBranch]?.pr ?? null;
|
|
795
|
+
if (basePr == null) {
|
|
796
|
+
p.cancel(
|
|
797
|
+
`Base branch ${pc.yellow(baseBranch)} has no PR.\n\n Open it with ${pc.green("gh-stack submit")} first.`,
|
|
798
|
+
);
|
|
799
|
+
process.exit(1);
|
|
719
800
|
}
|
|
720
|
-
reviewSpinner.stop("Review status checked");
|
|
721
801
|
|
|
722
|
-
|
|
802
|
+
// Only the bottom PR is merged. If it already landed (e.g. via the GitHub UI),
|
|
803
|
+
// advance the stack past it instead of re-merging.
|
|
804
|
+
const baseState = await getPrMergeState(basePr);
|
|
805
|
+
const baseAction: MergeAction = baseState?.state === "MERGED" ? "advance-only" : "merge";
|
|
806
|
+
const plan = { steps: [{ branch: baseBranch, pr: basePr, action: baseAction }] };
|
|
807
|
+
const nextChild = ordered[1] ?? null;
|
|
723
808
|
|
|
724
|
-
// Show the plan.
|
|
725
809
|
console.log();
|
|
726
810
|
console.log(` ${pc.bold("Plan:")}`);
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
811
|
+
console.log(
|
|
812
|
+
` ${pc.yellow(baseBranch)} (#${basePr}) → ${
|
|
813
|
+
baseAction === "advance-only"
|
|
730
814
|
? pc.dim("already merged — advance past")
|
|
731
|
-
: `${pc.green("squash-merge")} → ${pc.green(trunk)}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
if (
|
|
815
|
+
: `${pc.green("squash-merge")} → ${pc.green(trunk)}`
|
|
816
|
+
}`,
|
|
817
|
+
);
|
|
818
|
+
if (nextChild) {
|
|
735
819
|
console.log(
|
|
736
|
-
` ${pc.dim(
|
|
820
|
+
` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
|
|
737
821
|
);
|
|
738
822
|
}
|
|
739
823
|
console.log();
|
|
740
824
|
|
|
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
825
|
if (dryRun) {
|
|
752
826
|
p.outro(pc.yellow("[DRY RUN] No changes made"));
|
|
753
827
|
return;
|
|
754
828
|
}
|
|
755
829
|
|
|
756
830
|
const confirmed = await confirmAction(
|
|
757
|
-
|
|
831
|
+
nextChild
|
|
832
|
+
? `Merge base PR #${basePr} into ${trunk} and re-root ${nextChild}?`
|
|
833
|
+
: `Merge base PR #${basePr} into ${trunk}?`,
|
|
758
834
|
);
|
|
759
835
|
if (!confirmed) {
|
|
760
836
|
p.cancel("Cancelled");
|
|
@@ -763,7 +839,7 @@ async function mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
763
839
|
|
|
764
840
|
// Snapshot first — this records every branch's pre-merge tip, which both the
|
|
765
841
|
// fork-point resolver and `gh-stack undo` rely on.
|
|
766
|
-
await takeSnapshot(meta, stackName, "merge-
|
|
842
|
+
await takeSnapshot(meta, stackName, "merge-base");
|
|
767
843
|
// Keep a copy of the original topology to archive if the stack fully drains.
|
|
768
844
|
const originalStack = structuredClone(stack);
|
|
769
845
|
|
|
@@ -797,7 +873,7 @@ async function mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
797
873
|
if (!ready) {
|
|
798
874
|
s.stop(pc.red(`PR #${prNum} is not mergeable`));
|
|
799
875
|
p.log.info("Check the PR on GitHub for merge conflicts or required checks.");
|
|
800
|
-
p.log.info("Re-run
|
|
876
|
+
p.log.info("Re-run `gh-stack merge --base` to continue.");
|
|
801
877
|
process.exit(2);
|
|
802
878
|
}
|
|
803
879
|
|
|
@@ -813,7 +889,7 @@ async function mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
813
889
|
? "Real conflict or failing required check — resolve it on GitHub."
|
|
814
890
|
: "GitHub's mergeability recompute didn't settle (transient lag).",
|
|
815
891
|
);
|
|
816
|
-
p.log.info("Re-run
|
|
892
|
+
p.log.info("Re-run `gh-stack merge --base` to continue.");
|
|
817
893
|
process.exit(2);
|
|
818
894
|
}
|
|
819
895
|
} else {
|
|
@@ -882,7 +958,7 @@ async function mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
882
958
|
if (meta.current_stack === stackName) meta.current_stack = null;
|
|
883
959
|
await writeMetadata(meta);
|
|
884
960
|
|
|
885
|
-
p.outro(pc.green(
|
|
961
|
+
p.outro(pc.green(`Base PR #${basePr} merged — stack drained & archived.`));
|
|
886
962
|
return;
|
|
887
963
|
}
|
|
888
964
|
|
|
@@ -902,7 +978,7 @@ async function mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void
|
|
|
902
978
|
|
|
903
979
|
p.outro(
|
|
904
980
|
pc.green(
|
|
905
|
-
`Merged
|
|
981
|
+
`Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
|
|
906
982
|
),
|
|
907
983
|
);
|
|
908
984
|
}
|
package/src/commands/restack.ts
CHANGED
|
@@ -109,7 +109,7 @@ ALIASES
|
|
|
109
109
|
// origin. Fail loudly (listing the stale ones) rather than reporting success
|
|
110
110
|
// when a push didn't take. (#12)
|
|
111
111
|
if (!dryRun) {
|
|
112
|
-
const stale = await findStaleRefs(touched);
|
|
112
|
+
const stale = await findStaleRefs(touched, meta);
|
|
113
113
|
if (stale.length > 0) {
|
|
114
114
|
console.log();
|
|
115
115
|
p.log.error("Some branch refs are NOT in sync with origin:");
|
|
@@ -132,24 +132,41 @@ ALIASES
|
|
|
132
132
|
* For each branch, compare its local tip against origin. Returns the ones that
|
|
133
133
|
* differ (or whose remote ref is missing), so restack can report exactly which
|
|
134
134
|
* refs didn't land instead of falsely claiming success. (#12)
|
|
135
|
+
*
|
|
136
|
+
* The remote sha comes from a single authoritative `git ls-remote` (see
|
|
137
|
+
* git.remoteHeads), NOT from `rev-parse origin/<branch>`. A force-push can leave
|
|
138
|
+
* the local remote-tracking ref stale even though the push landed, which made
|
|
139
|
+
* this pass report a false "un-pushed ref" and exit non-zero after a fully
|
|
140
|
+
* successful run (#23). Reading the real remote state makes the signal
|
|
141
|
+
* trustworthy in both directions: a genuinely un-pushed ref is still caught.
|
|
142
|
+
*
|
|
143
|
+
* A branch absent from origin is only flagged when it has a PR (it was
|
|
144
|
+
* published and should be there). A local-only branch with no PR is
|
|
145
|
+
* intentionally absent — restack/sync don't publish it — so it's not a stale
|
|
146
|
+
* ref and must not fail the run. (#24)
|
|
135
147
|
*/
|
|
136
|
-
async function findStaleRefs(
|
|
148
|
+
export async function findStaleRefs(
|
|
137
149
|
branches: string[],
|
|
150
|
+
meta: StackMetadata,
|
|
138
151
|
): Promise<{ branch: string; local: string; remote: string }[]> {
|
|
139
152
|
const stale: { branch: string; local: string; remote: string }[] = [];
|
|
140
153
|
const seen = new Set<string>();
|
|
154
|
+
const remoteHeads = await git.remoteHeads();
|
|
141
155
|
for (const branch of branches) {
|
|
142
156
|
if (seen.has(branch) || branch === "main" || branch === "master") continue;
|
|
143
157
|
seen.add(branch);
|
|
144
158
|
if (!(await git.localBranchExists(branch))) continue;
|
|
145
159
|
const local = await git.revParse(branch);
|
|
146
|
-
|
|
147
|
-
|
|
160
|
+
const remote = remoteHeads.get(branch);
|
|
161
|
+
if (!remote) {
|
|
162
|
+
// Missing from origin is only a problem for a published branch.
|
|
163
|
+
if (branchHasPr(meta, branch)) {
|
|
164
|
+
stale.push({ branch, local, remote: "(missing)" });
|
|
165
|
+
}
|
|
148
166
|
continue;
|
|
149
167
|
}
|
|
150
|
-
const remote = await git.revParse(`origin/${branch}`).catch(() => "");
|
|
151
168
|
if (remote !== local) {
|
|
152
|
-
stale.push({ branch, local, remote: remote
|
|
169
|
+
stale.push({ branch, local, remote: remote.slice(0, 8) });
|
|
153
170
|
}
|
|
154
171
|
}
|
|
155
172
|
return stale;
|
|
@@ -201,7 +218,7 @@ async function handleResume(
|
|
|
201
218
|
await clearRestackState();
|
|
202
219
|
|
|
203
220
|
// Prompt to push the resumed branch
|
|
204
|
-
await pushBranchRef(expectedBranch);
|
|
221
|
+
await pushBranchRef(meta, expectedBranch);
|
|
205
222
|
|
|
206
223
|
// Continue with remaining branches in chain
|
|
207
224
|
const stackName = state.stack_name;
|
|
@@ -289,7 +306,7 @@ async function handleFreshRestack(
|
|
|
289
306
|
for (const baseBranch of baseBranches) {
|
|
290
307
|
if (await git.localBranchExists(baseBranch)) {
|
|
291
308
|
touched.push(baseBranch);
|
|
292
|
-
await pushBranchRef(baseBranch);
|
|
309
|
+
await pushBranchRef(meta, baseBranch);
|
|
293
310
|
}
|
|
294
311
|
}
|
|
295
312
|
}
|
|
@@ -340,7 +357,9 @@ async function handleReroot(
|
|
|
340
357
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
341
358
|
|
|
342
359
|
if (!stackName) {
|
|
343
|
-
p.cancel(
|
|
360
|
+
p.cancel(
|
|
361
|
+
`Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Track it first (no push/PR):\n ${pc.green("gh-stack add")}`,
|
|
362
|
+
);
|
|
344
363
|
process.exit(1);
|
|
345
364
|
}
|
|
346
365
|
|
|
@@ -431,7 +450,7 @@ async function handleReroot(
|
|
|
431
450
|
|
|
432
451
|
rebasedBranches.push(root);
|
|
433
452
|
touched.push(root);
|
|
434
|
-
await pushBranchRef(root);
|
|
453
|
+
await pushBranchRef(meta, root);
|
|
435
454
|
|
|
436
455
|
// Restack the children onto the moved root.
|
|
437
456
|
const children = ordered.slice(1);
|
|
@@ -477,7 +496,7 @@ async function processChain(
|
|
|
477
496
|
p.log.success("Already up to date with parent");
|
|
478
497
|
if (!dryRun) {
|
|
479
498
|
touched.push(branch);
|
|
480
|
-
await pushBranchRef(branch);
|
|
499
|
+
await pushBranchRef(meta, branch);
|
|
481
500
|
}
|
|
482
501
|
continue;
|
|
483
502
|
}
|
|
@@ -575,7 +594,7 @@ async function processChain(
|
|
|
575
594
|
|
|
576
595
|
// Push immediately (don't batch) so an interrupt can't leave later
|
|
577
596
|
// branches un-pushed.
|
|
578
|
-
await pushBranchRef(branch);
|
|
597
|
+
await pushBranchRef(meta, branch);
|
|
579
598
|
} else {
|
|
580
599
|
// Conflict!
|
|
581
600
|
console.log();
|
|
@@ -601,11 +620,36 @@ async function processChain(
|
|
|
601
620
|
* differs from origin OR the remote branch doesn't exist yet (so a branch
|
|
602
621
|
* that was never pushed still lands). No-op when already in sync. (#12)
|
|
603
622
|
*/
|
|
604
|
-
|
|
623
|
+
/**
|
|
624
|
+
* Whether a branch has been published — it has a PR recorded in metadata.
|
|
625
|
+
* restack/sync keep published branches' remotes in sync after a rebase, but must
|
|
626
|
+
* NOT create a remote for an unpublished, local-only branch — publishing is
|
|
627
|
+
* `submit`'s job, not a side effect of restacking. (#24)
|
|
628
|
+
*/
|
|
629
|
+
function branchHasPr(meta: StackMetadata, branch: string): boolean {
|
|
630
|
+
const stackName = findStackForBranch(meta, branch);
|
|
631
|
+
if (!stackName) return false;
|
|
632
|
+
return meta.stacks[stackName]?.branches[branch]?.pr != null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void> {
|
|
605
636
|
if (branch === "main" || branch === "master") return;
|
|
606
637
|
|
|
607
638
|
const localSha = await git.revParse(branch);
|
|
608
639
|
const hasRemote = await git.remoteBranchExists(branch);
|
|
640
|
+
|
|
641
|
+
// Don't publish an unsubmitted branch. A local-only branch (no remote AND no
|
|
642
|
+
// PR) is intentionally not on origin — restack/sync rebase it locally but
|
|
643
|
+
// leave pushing to `submit`. Skip with a note rather than creating its remote
|
|
644
|
+
// (the #24 surprise: `--yes` silently published a WIP tip). A branch that
|
|
645
|
+
// already has a remote or a PR still gets pushed below to stay in sync.
|
|
646
|
+
if (!hasRemote && !branchHasPr(meta, branch)) {
|
|
647
|
+
p.log.info(
|
|
648
|
+
`Skipping push of ${pc.yellow(branch)} — local-only (not submitted). Run ${pc.green("gh-stack submit")} to open its PR.`,
|
|
649
|
+
);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
609
653
|
const remoteSha = hasRemote ? await git.revParse(`origin/${branch}`).catch(() => null) : null;
|
|
610
654
|
|
|
611
655
|
if (hasRemote && remoteSha === localSha) return; // already in sync — nothing to do
|
package/src/commands/sync.ts
CHANGED
|
@@ -52,7 +52,9 @@ with the base branch included.
|
|
|
52
52
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
53
53
|
|
|
54
54
|
if (!stackName) {
|
|
55
|
-
p.cancel(
|
|
55
|
+
p.cancel(
|
|
56
|
+
`Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Track it first (no push/PR):\n ${pc.green("gh-stack add")}\n\n Or open its PR with:\n ${pc.green("gh-stack submit")}`,
|
|
57
|
+
);
|
|
56
58
|
process.exit(1);
|
|
57
59
|
}
|
|
58
60
|
|
package/src/commands/top.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
getChildren,
|
|
9
9
|
writeMetadata,
|
|
10
10
|
} from "../lib/metadata.ts";
|
|
11
|
-
import { ensureMetadata } from "../lib/safety.ts";
|
|
11
|
+
import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
|
|
12
12
|
|
|
13
13
|
export default async function top(args: string[]): Promise<void> {
|
|
14
14
|
if (args.includes("--help")) {
|
|
@@ -68,7 +68,7 @@ Prompts if there are multiple leaf branches.
|
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
await
|
|
71
|
+
await checkoutOrExit(target);
|
|
72
72
|
|
|
73
73
|
// Update tracking
|
|
74
74
|
stack.last_branch = target;
|
package/src/commands/up.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "../lib/output.ts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getChildren, writeMetadata } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
|
|
7
7
|
|
|
8
8
|
export default async function up(args: string[]): Promise<void> {
|
|
9
9
|
if (args.includes("--help")) {
|
|
@@ -71,7 +71,7 @@ OPTIONS
|
|
|
71
71
|
currentBranch = target;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
await
|
|
74
|
+
await checkoutOrExit(currentBranch);
|
|
75
75
|
|
|
76
76
|
// Update tracking
|
|
77
77
|
stack.last_branch = currentBranch;
|