gh-stack 0.16.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 CHANGED
@@ -83,6 +83,18 @@ create <branch-name> [--description <desc>]
83
83
  Create a new git branch off the current branch and add it to
84
84
  the stack. The current branch must already be tracked.
85
85
 
86
+ add [branch]
87
+ Track an EXISTING local branch (default: the current branch) in a
88
+ stack — without pushing or opening a PR. Walks local ancestry: if the
89
+ branch sits on top of an existing stack it's adopted into it (parent
90
+ auto-detected); otherwise a new stack is created from the chain.
91
+
92
+ Use this for a WIP branch you've created with `git checkout -b` but
93
+ aren't ready to submit. It fills the gap between creating a branch and
94
+ `submit`: until a branch is tracked, `sync`/`restack`/navigation don't
95
+ know about it, and `add` is how you track it without publishing. (When
96
+ you do run `submit`, it auto-tracks too — `add` is the no-push path.)
97
+
86
98
  submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--restack] [--dry-run]
87
99
  Push all downstack branches to GitHub, create PRs for branches
88
100
  that don't have them, number each PR title with its stack position
@@ -168,9 +180,10 @@ sync [--dry-run]
168
180
  and conflicting.
169
181
 
170
182
  merge [--dry-run] [-d|--delete-branch] [--collapse]
171
- merge --approved [--dry-run] [-d|--delete-branch]
172
- Default (top-down collapse): squash-merge the stack top-down via
173
- GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
183
+ merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
184
+ merge --base [--dry-run] [-d|--delete-branch]
185
+ Default (top-down collapse): squash-merge the whole stack top-down
186
+ via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
174
187
  into main. The whole stack lands on main as a single squash commit.
175
188
  All merges happen on GitHub so PRs show as "Merged", Linear tickets
176
189
  close automatically, and GitHub Actions fire normally. Skips already-
@@ -181,20 +194,25 @@ merge --approved [--dry-run] [-d|--delete-branch]
181
194
  real conflict with main stops the merge (the stack is left intact —
182
195
  resolve with `gh-stack sync`, then re-run), while merely waiting on
183
196
  pending checks is fine (auto-merge gates on them).
184
- --collapse Stop after collapsing the stack into the base PR;
185
- leaves the base PR open against main so you can
186
- review the cumulative diff on GitHub. Re-run
187
- `gh-stack merge` (without --collapse) to finish.
188
-
189
- --approved (bottom-up, merge what's ready): merge approved PRs from
190
- the BOTTOM up, each as its own squash commit on main, and leave the
191
- unreviewed tail as a clean stack re-rooted on main. Stops at the
192
- first PR that isn't approved. For each approved bottom PR it squash-
193
- merges to main, re-roots the next branch onto main (replaying only
194
- its own commits), pushes it, and repoints its PR base to main. A PR
195
- already merged outside gh-stack is detected and advanced past, not
196
- re-merged. For the "merge PRs as they get reviewed, keep stacking
197
- the rest" workflow.
197
+
198
+ --approved Collapse only the approved run. Walks approval from the
199
+ BASE up and stops at the first PR that isn't approved
200
+ (contiguous — an unapproved PR4 caps the run at PR3 even
201
+ if PR5 is approved). Collapses base..highest-approved into
202
+ a single squash commit on main and leaves the unapproved
203
+ tail in place; after the base PR lands, run `gh-stack sync`
204
+ to re-root the tail onto main. All approved ⇒ same as a
205
+ full merge.
206
+ --base Squash-merge ONLY the bottom PR into main as its own
207
+ commit, then re-root the next branch onto main (replaying
208
+ just its own commits). One PR per run — run it again to
209
+ land the next. Approval is enforced by GitHub branch
210
+ protection, not gh-stack. A base PR already merged outside
211
+ gh-stack is detected and advanced past.
212
+ --collapse With the default or --approved, stop after collapsing into
213
+ the base PR WITHOUT merging it to main, so you can review
214
+ the cumulative diff on GitHub. Re-run `gh-stack merge` to
215
+ finish. Alias: --stop-at-base.
198
216
 
199
217
  split [<branch>] [--name <name>]
200
218
  Cut a stack into two at <branch>. The cut branch and every branch
@@ -462,8 +480,12 @@ gh-stack status
462
480
  # When the whole stack is approved, merge it down as one squash commit
463
481
  gh-stack merge # squash-merges down, pushes, enables auto-merge
464
482
 
465
- # Or: merge PRs as they get approved, bottom-up, keep stacking the rest
466
- gh-stack merge --approved # merges approved bottom PRs, re-roots the tail on main
483
+ # Or: only the approved part is ready — collapse the approved run into one
484
+ # commit and leave the rest stacked (re-root the tail later with `sync`)
485
+ gh-stack merge --approved # collapses base..highest-approved → main, keeps the tail
486
+
487
+ # Or: land just the bottom PR, one at a time
488
+ gh-stack merge --base # merges the bottom PR → main, re-roots the next
467
489
 
468
490
  # Or: collapse first to review the cumulative diff before shipping
469
491
  gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -0,0 +1,107 @@
1
+ // gh-stack add — register an existing local branch into a stack (no push, no PR).
2
+ //
3
+ // Fills the gap between `git checkout -b <wip>` and `submit`: it tracks the
4
+ // branch (and any untracked ancestors) in the right stack by walking local
5
+ // ancestry, so `sync` / `restack` / navigation work BEFORE you're ready to open
6
+ // a PR. Network-free — nothing is pushed and no PR is created. That's the whole
7
+ // point: `submit` is the command that publishes; `add` only updates metadata.
8
+ //
9
+ // Resolves the "catch-22" where a local WIP tip couldn't be synced because it
10
+ // wasn't in a stack, and the only way into a stack was `submit` (which pushes).
11
+ import * as p from "../lib/output.ts";
12
+ import pc from "picocolors";
13
+ import * as git from "../lib/git.ts";
14
+ import { gateLegacyMetadata } from "../lib/safety.ts";
15
+ import { readMetadata, writeMetadata, findStackForBranch, stackBase } from "../lib/metadata.ts";
16
+ import { resolveOrCreateStack } from "../lib/chain.ts";
17
+ import type { StackMetadata } from "../types.ts";
18
+
19
+ export default async function add(args: string[]): Promise<void> {
20
+ if (args.includes("--help")) {
21
+ console.log(`
22
+ gh-stack add — track an existing local branch in a stack (no push, no PR)
23
+
24
+ USAGE
25
+ gh-stack add [branch]
26
+
27
+ Registers the branch (default: the current branch) into a stack so sync,
28
+ restack, and navigation work before you've submitted it. Walks local ancestry:
29
+ if the branch sits on top of an existing stack it's adopted into that stack;
30
+ otherwise a new stack is created from the chain, rooted at the trunk.
31
+
32
+ Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
33
+ you're ready to publish.
34
+
35
+ EXAMPLES
36
+ gh-stack add Track the current branch
37
+ gh-stack add my-wip-branch Track a specific local branch
38
+ `);
39
+ return;
40
+ }
41
+
42
+ await gateLegacyMetadata();
43
+
44
+ const positional = args.find((a) => !a.startsWith("-"));
45
+ const branch = positional ?? (await git.currentBranch());
46
+
47
+ if (!(await git.localBranchExists(branch))) {
48
+ p.cancel(`Branch ${pc.yellow(branch)} doesn't exist locally.`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const trunk = await git.trunkBranch();
53
+ if (branch === trunk || branch === "main" || branch === "master") {
54
+ p.cancel(
55
+ `${pc.yellow(branch)} is the trunk — nothing to add.\n\n Checkout a feature branch first.`,
56
+ );
57
+ process.exit(1);
58
+ }
59
+
60
+ // submit self-heals from an empty store, so a missing metadata file is fine —
61
+ // resolveOrCreateStack will create the first stack from local ancestry.
62
+ const meta: StackMetadata = (await readMetadata()) ?? {
63
+ version: 2,
64
+ current_stack: null,
65
+ stacks: {},
66
+ };
67
+
68
+ const already = findStackForBranch(meta, branch);
69
+ if (already) {
70
+ p.log.info(`${pc.yellow(branch)} is already tracked in stack ${pc.yellow(already)}.`);
71
+ return;
72
+ }
73
+
74
+ let resolution;
75
+ try {
76
+ resolution = await resolveOrCreateStack(meta, branch, trunk);
77
+ } catch (err) {
78
+ p.cancel((err as Error).message);
79
+ process.exit(1);
80
+ }
81
+
82
+ meta.current_stack = resolution.stackName;
83
+ await writeMetadata(meta);
84
+
85
+ p.intro(pc.cyan("Add to Stack"));
86
+
87
+ const stack = meta.stacks[resolution.stackName]!;
88
+ const base = stackBase(stack);
89
+ const baseNote = base === "main" || base === "master" ? "" : ` ${pc.dim(`(base: ${base})`)}`;
90
+
91
+ if (resolution.created) {
92
+ p.log.success(
93
+ `Created stack ${pc.yellow(resolution.stackName)} from ${resolution.addedBranches.length} local branch(es)${baseNote}`,
94
+ );
95
+ } else {
96
+ p.log.success(
97
+ `Added ${resolution.addedBranches.length} branch(es) to ${pc.yellow(resolution.stackName)}${baseNote}`,
98
+ );
99
+ }
100
+ for (const b of resolution.addedBranches) {
101
+ console.log(` ${pc.green("+")} ${b}`);
102
+ }
103
+
104
+ p.outro(
105
+ `Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
106
+ );
107
+ }
@@ -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, getOrderedBranches, 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 bottom(args: string[]): Promise<void> {
9
9
  if (args.includes("--help")) {
@@ -43,7 +43,7 @@ Switches to the bottom-most branch of the current stack
43
43
  return;
44
44
  }
45
45
 
46
- await git.checkout(target);
46
+ await checkoutOrExit(target);
47
47
 
48
48
  // Update tracking
49
49
  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 { writeMetadata, getOrderedBranches, findStackForBranch } from "../lib/metadata.ts";
6
- import { ensureMetadata } from "../lib/safety.ts";
6
+ import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
7
7
  import { selectBranch, selectStack } from "../lib/ui.ts";
8
8
 
9
9
  export default async function checkout(args: string[]): Promise<void> {
@@ -45,20 +45,21 @@ TIP
45
45
  process.exit(0);
46
46
  }
47
47
 
48
- // Update current_stack
49
- meta.current_stack = stackName;
50
- await writeMetadata(meta);
51
-
52
- // Checkout last branch in stack
48
+ // Checkout the stack's last branch FIRST — only persist current_stack once
49
+ // the switch actually lands, so a refused checkout (e.g. dirty tree) leaves
50
+ // metadata pointing at the stack we're still on rather than the target.
53
51
  const stack = meta.stacks[stackName]!;
54
52
  const lastBranch = stack.last_branch;
55
53
 
56
54
  if (lastBranch && lastBranch !== currentBranch) {
57
55
  p.log.info(`Switching to stack ${pc.yellow(stackName)}`);
58
56
  p.log.info(`Checking out ${pc.yellow(lastBranch)}`);
59
- await git.checkout(lastBranch);
57
+ await checkoutOrExit(lastBranch);
60
58
  }
61
59
 
60
+ meta.current_stack = stackName;
61
+ await writeMetadata(meta);
62
+
62
63
  p.outro(pc.green(`Switched to stack ${pc.yellow(stackName)}`));
63
64
  } else {
64
65
  // ── Branch switching ──
@@ -105,7 +106,7 @@ TIP
105
106
  }
106
107
 
107
108
  p.log.info(`Switching to ${pc.yellow(targetBranch)}`);
108
- await git.checkout(targetBranch);
109
+ await checkoutOrExit(targetBranch);
109
110
 
110
111
  // Update last_branch
111
112
  stack.last_branch = targetBranch;
@@ -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, stackBase, 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 down(args: string[]): Promise<void> {
9
9
  if (args.includes("--help")) {
@@ -64,7 +64,7 @@ OPTIONS
64
64
  moved++;
65
65
  }
66
66
 
67
- await git.checkout(currentBranch);
67
+ await checkoutOrExit(currentBranch);
68
68
 
69
69
  // Update tracking
70
70
  stack.last_branch = currentBranch;
@@ -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:");
@@ -139,9 +139,15 @@ ALIASES
139
139
  * this pass report a false "un-pushed ref" and exit non-zero after a fully
140
140
  * successful run (#23). Reading the real remote state makes the signal
141
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)
142
147
  */
143
148
  export async function findStaleRefs(
144
149
  branches: string[],
150
+ meta: StackMetadata,
145
151
  ): Promise<{ branch: string; local: string; remote: string }[]> {
146
152
  const stale: { branch: string; local: string; remote: string }[] = [];
147
153
  const seen = new Set<string>();
@@ -153,7 +159,10 @@ export async function findStaleRefs(
153
159
  const local = await git.revParse(branch);
154
160
  const remote = remoteHeads.get(branch);
155
161
  if (!remote) {
156
- stale.push({ branch, local, remote: "(missing)" });
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
+ }
157
166
  continue;
158
167
  }
159
168
  if (remote !== local) {
@@ -209,7 +218,7 @@ async function handleResume(
209
218
  await clearRestackState();
210
219
 
211
220
  // Prompt to push the resumed branch
212
- await pushBranchRef(expectedBranch);
221
+ await pushBranchRef(meta, expectedBranch);
213
222
 
214
223
  // Continue with remaining branches in chain
215
224
  const stackName = state.stack_name;
@@ -297,7 +306,7 @@ async function handleFreshRestack(
297
306
  for (const baseBranch of baseBranches) {
298
307
  if (await git.localBranchExists(baseBranch)) {
299
308
  touched.push(baseBranch);
300
- await pushBranchRef(baseBranch);
309
+ await pushBranchRef(meta, baseBranch);
301
310
  }
302
311
  }
303
312
  }
@@ -348,7 +357,9 @@ async function handleReroot(
348
357
  const stackName = findStackForBranch(meta, currentBranch);
349
358
 
350
359
  if (!stackName) {
351
- 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
+ );
352
363
  process.exit(1);
353
364
  }
354
365
 
@@ -439,7 +450,7 @@ async function handleReroot(
439
450
 
440
451
  rebasedBranches.push(root);
441
452
  touched.push(root);
442
- await pushBranchRef(root);
453
+ await pushBranchRef(meta, root);
443
454
 
444
455
  // Restack the children onto the moved root.
445
456
  const children = ordered.slice(1);
@@ -485,7 +496,7 @@ async function processChain(
485
496
  p.log.success("Already up to date with parent");
486
497
  if (!dryRun) {
487
498
  touched.push(branch);
488
- await pushBranchRef(branch);
499
+ await pushBranchRef(meta, branch);
489
500
  }
490
501
  continue;
491
502
  }
@@ -583,7 +594,7 @@ async function processChain(
583
594
 
584
595
  // Push immediately (don't batch) so an interrupt can't leave later
585
596
  // branches un-pushed.
586
- await pushBranchRef(branch);
597
+ await pushBranchRef(meta, branch);
587
598
  } else {
588
599
  // Conflict!
589
600
  console.log();
@@ -609,11 +620,36 @@ async function processChain(
609
620
  * differs from origin OR the remote branch doesn't exist yet (so a branch
610
621
  * that was never pushed still lands). No-op when already in sync. (#12)
611
622
  */
612
- 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> {
613
636
  if (branch === "main" || branch === "master") return;
614
637
 
615
638
  const localSha = await git.revParse(branch);
616
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
+
617
653
  const remoteSha = hasRemote ? await git.revParse(`origin/${branch}`).catch(() => null) : null;
618
654
 
619
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;
package/src/index.ts CHANGED
@@ -69,6 +69,10 @@ switch (command) {
69
69
  await (await import("./commands/create.ts")).default(commandArgs);
70
70
  break;
71
71
 
72
+ case "add":
73
+ await (await import("./commands/add.ts")).default(commandArgs);
74
+ break;
75
+
72
76
  case "delete":
73
77
  await (await import("./commands/delete.ts")).default(commandArgs);
74
78
  break;
@@ -178,6 +182,7 @@ ${bold("TERMS")}
178
182
  ${bold("CORE WORKFLOW")}
179
183
  ${green("init")} Create a new stack from the current branch
180
184
  ${green("create")} Create a new branch and add to the stack
185
+ ${green("add")} Track the current local branch in a stack ${dim("(no push/PR)")}
181
186
  ${green("submit")} Push branches and create/update PRs
182
187
  ${green("log")} Display current stack tree ${dim("(default)")}
183
188
 
package/src/lib/git.ts CHANGED
@@ -86,12 +86,25 @@ export async function isRebaseInProgress(): Promise<boolean> {
86
86
  }
87
87
 
88
88
  /**
89
- * Checkout a branch.
89
+ * Checkout a branch. Throws a raw ShellError on failure — only use this where a
90
+ * failure is unexpected (e.g. after ensureCleanWorkingTree). For user-facing
91
+ * branch switching, prefer tryCheckout so a dirty-tree refusal can be rendered
92
+ * as a clean message instead of a stack trace.
90
93
  */
91
94
  export async function checkout(branch: string): Promise<void> {
92
95
  await $`git checkout ${branch}`.quiet();
93
96
  }
94
97
 
98
+ /**
99
+ * Attempt to checkout a branch without throwing. Returns whether it succeeded
100
+ * and git's stderr (which carries the "local changes would be overwritten"
101
+ * message and the offending file list when a dirty tree blocks the switch).
102
+ */
103
+ export async function tryCheckout(branch: string): Promise<{ ok: boolean; stderr: string }> {
104
+ const { exitCode, stderr } = await $`git checkout ${branch}`.quiet().nothrow();
105
+ return { ok: exitCode === 0, stderr: stderr.toString() };
106
+ }
107
+
95
108
  /**
96
109
  * Create and checkout a new branch from the current HEAD.
97
110
  */
package/src/lib/safety.ts CHANGED
@@ -115,6 +115,37 @@ export async function ensureCleanWorkingTree(): Promise<void> {
115
115
  }
116
116
  }
117
117
 
118
+ /**
119
+ * Switch branches for a user-facing navigation command, rendering a clean
120
+ * message (and exiting 1) when git refuses — most commonly because local
121
+ * changes would be overwritten. Avoids leaking a raw ShellError stack trace.
122
+ *
123
+ * Note: this deliberately does NOT pre-check for a clean tree. git happily
124
+ * carries uncommitted changes across a switch when they don't conflict with the
125
+ * target branch, and stack navigation often wants to bring WIP along — so we
126
+ * let git decide and only intervene on an actual refusal.
127
+ */
128
+ export async function checkoutOrExit(branch: string): Promise<void> {
129
+ const { ok, stderr } = await git.tryCheckout(branch);
130
+ if (ok) return;
131
+
132
+ if (stderr.includes("would be overwritten by checkout")) {
133
+ const files = stderr
134
+ .split("\n")
135
+ .filter((line) => line.startsWith("\t"))
136
+ .map((line) => ` ${pc.yellow(line.trim())}`)
137
+ .join("\n");
138
+ p.cancel(
139
+ `Can't switch to ${pc.yellow(branch)} — local changes would be overwritten:\n\n${files}\n\n Commit or stash them first:\n ${pc.green("git stash")} ${pc.dim("(or git commit)")}`,
140
+ );
141
+ } else {
142
+ p.cancel(
143
+ `Can't switch to ${pc.yellow(branch)}:\n\n ${stderr.trim() || "git checkout failed"}`,
144
+ );
145
+ }
146
+ process.exit(1);
147
+ }
148
+
118
149
  /**
119
150
  * Ensure we're not on main.
120
151
  */
@@ -89,6 +89,21 @@ gh-stack create kiliman/third-WEB-9012 # stack another
89
89
 
90
90
  `init` options: `--name <stack>`, `--parent <trunk>`, `--description <text>`.
91
91
 
92
+ **Track a local WIP branch without publishing it — `gh-stack add`.** If you
93
+ created a branch with `git checkout -b` and want to `sync`/`restack` it _before_
94
+ opening a PR, `add` registers it in a stack (network-free — no push, no PR):
95
+
96
+ ```bash
97
+ gh-stack add # track the current branch
98
+ gh-stack add <branch> # track a named branch
99
+ ```
100
+
101
+ It walks local ancestry: a branch sitting on top of an existing stack is adopted
102
+ into it (parent auto-detected); otherwise a new stack is created from the chain.
103
+ This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
104
+ tip you hadn't submitted yet. (`submit` still auto-tracks too — `add` is just the
105
+ no-publish path.)
106
+
92
107
  ### 2. Push & create PRs — `submit`
93
108
 
94
109
  `submit` is the workhorse: **idempotent, scope-aware, self-healing**. It pushes
@@ -183,16 +198,27 @@ gh-stack status --json # structured, for agents
183
198
  ### 6. Merge & ship
184
199
 
185
200
  ```bash
186
- gh-stack merge # top-down: collapse stack into one squash commit → trunk
187
- gh-stack merge --approved # bottom-up: merge approved PRs (each its own commit),
188
- # re-root the unreviewed tail, leave it as a clean stack
201
+ gh-stack merge # collapse the WHOLE stack into one squash commit → trunk
202
+ gh-stack merge --approved # collapse only the approved run (base..highest-approved)
203
+ # into one commit; leave the unapproved tail in place
204
+ gh-stack merge --base # land ONLY the bottom PR → trunk, re-root the next branch
189
205
  gh-stack merge --dry-run
190
206
  ```
191
207
 
192
- - **`merge`** (top-down): the whole stack is approved → lands as a unit.
193
- - **`merge --approved`** (bottom-up): merge PRs as they're reviewed, keep
194
- stacking the rest. Stops at the first un-approved PR; auto-advances past any
195
- bottom PR already merged outside gh-stack.
208
+ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, then base → trunk):
209
+
210
+ - **`merge`**: the whole stack is approved → lands as a single squash commit.
211
+ - **`merge --approved`**: collapse the **contiguous-from-base approved run** into
212
+ one commit on trunk. Approval is read from the base up — the first un-approved
213
+ PR caps the run (unapproved PR4 stops at PR3 even if PR5 is approved). The
214
+ unapproved tail is left in place; after the base lands, run `gh-stack sync` to
215
+ re-root the tail onto trunk (it does not block on CI).
216
+ - **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
217
+ the next branch onto trunk — one PR per run, re-run for the next. Use this to
218
+ land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
219
+ outside gh-stack.
220
+ - **`--collapse`** (with `merge` or `merge --approved`): stop after collapsing
221
+ into the base PR — review on GitHub, then re-run `merge` to finish.
196
222
 
197
223
  ### 7. Maintenance
198
224