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.
@@ -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 { planMerges, advanceNewBottom, type BranchReview } from "../lib/advance.ts";
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 (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.
256
-
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.
261
-
262
- All merges happen on GitHub, so PRs show as "Merged", Linear tickets
263
- close automatically, and all GitHub Actions/webhooks fire normally.
264
-
265
- Skips PRs that are already merged (safe to re-run after partial failure).
266
- Waits for GitHub to process between merges, and self-heals GitHub's
267
- spurious "Head branch is out of date" (a stale PR head pointer after a
268
- child squash) by re-syncing the branch and retrying — only a genuine
269
- conflict or failing required check is reported as a hard failure.
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 Merge approved PRs bottom-up; leave the rest as PRs
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 (approved) {
287
- await mergeApproved(deleteFlag, dryRun);
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
- if (ordered.length <= 1) {
318
- // Single branch — just enable auto-merge
319
- const basePr = stack.branches[ordered[0]!]?.pr;
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
- `Single-branch stack — nothing to collapse. The base PR${basePr ? ` (#${basePr})` : ""} already targets main.`,
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("Single branch stack — enabling auto-merge on GitHub.");
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
- const reversed = ordered.toReversed();
353
- const baseBranch = ordered[0]!;
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
- console.log();
381
-
382
- if (dryRun) {
383
- p.outro(pc.yellow("[DRY RUN] No changes made"));
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
- // Check all PRs in stack have PR numbers
388
- const missingPrs = ordered.filter((b) => !stack.branches[b]?.pr);
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 --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).
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 mergeApproved(deleteFlag: boolean, dryRun: boolean): Promise<void> {
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 Approved (bottom-up, via GitHub)"));
790
+ p.intro(pc.cyan("Merge Base PR (via GitHub)"));
703
791
  p.log.info(`Stack: ${pc.yellow(stackName)}`);
704
792
 
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 });
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
- const plan = planMerges(reviews, { approveAndMerge: true });
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
- for (const step of plan.steps) {
728
- const label =
729
- step.action === "advance-only"
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
- console.log(` ${pc.yellow(step.branch)}${step.pr ? ` (#${step.pr})` : ""} → ${label}`);
733
- }
734
- if (plan.stop) {
815
+ : `${pc.green("squash-merge")} → ${pc.green(trunk)}`
816
+ }`,
817
+ );
818
+ if (nextChild) {
735
819
  console.log(
736
- ` ${pc.dim("⏹")} stop at ${pc.yellow(plan.stop.branch)} ${pc.dim(`(${plan.stop.reason}) — left as a stack on ${trunk}`)}`,
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
- `Merge ${mergeSteps.length} approved PR(s) bottom-up into ${trunk} and re-root the rest?`,
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-approved");
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 with --approved to continue from where you left off.");
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 with --approved to continue from where you left off.");
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("All approved PRs merged — stack drained & archived."));
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 ${mergeSteps.length} approved PR(s); ${remaining.length} branch(es) left as a stack on ${trunk}.`,
981
+ `Merged base PR #${basePr} into ${trunk}; ${remaining.length} branch(es) left as a stack.`,
906
982
  ),
907
983
  );
908
984
  }
@@ -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
- if (!(await git.remoteBranchExists(branch))) {
147
- stale.push({ branch, local, remote: "(missing)" });
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 ? remote.slice(0, 8) : "(unknown)" });
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(`Branch ${pc.blue(currentBranch)} is not in any stack.`);
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
- async function pushBranchRef(branch: string): Promise<void> {
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
@@ -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(`Branch ${pc.blue(currentBranch)} not found in any stack`);
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
 
@@ -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 git.checkout(target);
71
+ await checkoutOrExit(target);
72
72
 
73
73
  // Update tracking
74
74
  stack.last_branch = target;
@@ -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 git.checkout(currentBranch);
74
+ await checkoutOrExit(currentBranch);
75
75
 
76
76
  // Update tracking
77
77
  stack.last_branch = currentBranch;