gh-stack 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -19
- 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/merge.ts +174 -98
- package/src/commands/restack.ts +92 -21
- 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 +5 -0
- package/src/lib/git.ts +14 -1
- package/src/lib/safety.ts +31 -0
- package/src/lib/snapshot.ts +57 -1
- package/src/lib/using-gh-stack.md +33 -7
- package/src/types.ts +7 -0
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
466
|
-
|
|
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
|
@@ -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
|
+
}
|
package/src/commands/bottom.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, 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
|
|
46
|
+
await checkoutOrExit(target);
|
|
47
47
|
|
|
48
48
|
// Update tracking
|
|
49
49
|
stack.last_branch = target;
|
package/src/commands/checkout.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 { 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
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
109
|
+
await checkoutOrExit(targetBranch);
|
|
109
110
|
|
|
110
111
|
// Update last_branch
|
|
111
112
|
stack.last_branch = targetBranch;
|
package/src/commands/down.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, 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
|
|
67
|
+
await checkoutOrExit(currentBranch);
|
|
68
68
|
|
|
69
69
|
// Update tracking
|
|
70
70
|
stack.last_branch = currentBranch;
|
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
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
writeMetadata,
|
|
16
16
|
} from "../lib/metadata.ts";
|
|
17
17
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
18
|
-
import { takeSnapshot, findPreRewriteSha } from "../lib/snapshot.ts";
|
|
18
|
+
import { takeSnapshot, findPreRewriteSha, findRecordedBaseTip } from "../lib/snapshot.ts";
|
|
19
19
|
import { confirmAction } from "../lib/ui.ts";
|
|
20
20
|
import type { StackMetadata } from "../types.ts";
|
|
21
21
|
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -336,6 +345,36 @@ async function handleFreshRestack(
|
|
|
336
345
|
* the new base (the now-merged parent commits are dropped). Children then
|
|
337
346
|
* restack onto the moved root via the normal chain logic.
|
|
338
347
|
*/
|
|
348
|
+
// Resolve the boundary commit for re-rooting a stack's root off its old base:
|
|
349
|
+
// the tip of the old base branch that the root was built on. Returns null when
|
|
350
|
+
// it can't be determined safely, so the caller refuses rather than replaying
|
|
351
|
+
// already-merged commits (issue #13).
|
|
352
|
+
async function resolveRerootBoundary(
|
|
353
|
+
meta: StackMetadata,
|
|
354
|
+
oldBase: string,
|
|
355
|
+
root: string,
|
|
356
|
+
): Promise<{ sha: string | null; source: string }> {
|
|
357
|
+
// 1. The live base branch tip — but only if the root still sits on it. If the
|
|
358
|
+
// base was rewritten after the root forked, its current tip isn't the
|
|
359
|
+
// boundary; fall through to the recorded tip.
|
|
360
|
+
if (await git.localBranchExists(oldBase)) {
|
|
361
|
+
const tip = await git.revParse(oldBase);
|
|
362
|
+
if (tip && (await git.isAncestor(tip, root))) {
|
|
363
|
+
return { sha: tip, source: "old-base branch tip" };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 2. The tip recorded in a snapshot — survives the base branch being deleted
|
|
368
|
+
// or rewritten by a squash-merge of the parent stack.
|
|
369
|
+
const recorded = await findRecordedBaseTip(meta, oldBase, root);
|
|
370
|
+
if (recorded) {
|
|
371
|
+
return { sha: recorded, source: "snapshot (old base branch gone/rewritten)" };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 3. Unknown — refuse rather than replay merged commits.
|
|
375
|
+
return { sha: null, source: "" };
|
|
376
|
+
}
|
|
377
|
+
|
|
339
378
|
async function handleReroot(
|
|
340
379
|
meta: StackMetadata,
|
|
341
380
|
newBase: string,
|
|
@@ -348,7 +387,9 @@ async function handleReroot(
|
|
|
348
387
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
349
388
|
|
|
350
389
|
if (!stackName) {
|
|
351
|
-
p.cancel(
|
|
390
|
+
p.cancel(
|
|
391
|
+
`Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Track it first (no push/PR):\n ${pc.green("gh-stack add")}`,
|
|
392
|
+
);
|
|
352
393
|
process.exit(1);
|
|
353
394
|
}
|
|
354
395
|
|
|
@@ -372,20 +413,25 @@ async function handleReroot(
|
|
|
372
413
|
}
|
|
373
414
|
|
|
374
415
|
// The rebase base = the tip the root was built on (the old base branch).
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
416
|
+
// Getting this right matters: rebasing from the wrong boundary replays the
|
|
417
|
+
// old base's commits onto the new base, and when the old base was a
|
|
418
|
+
// squash-merged parent stack those commits collide with their own squashed
|
|
419
|
+
// result on main — a self-inflicted conflict storm (issue #13). So we never
|
|
420
|
+
// guess with merge-base; we refuse instead.
|
|
421
|
+
const { sha: oldBaseTip, source: oldBaseSource } = await resolveRerootBoundary(
|
|
422
|
+
meta,
|
|
423
|
+
oldBase,
|
|
424
|
+
root,
|
|
425
|
+
);
|
|
384
426
|
|
|
385
427
|
if (!oldBaseTip) {
|
|
386
428
|
p.cancel(
|
|
387
|
-
`
|
|
388
|
-
|
|
429
|
+
`Can't safely re-root ${pc.yellow(stackName)}: its old base ${pc.yellow(
|
|
430
|
+
oldBase,
|
|
431
|
+
)} is gone (or was rewritten) and no snapshot recorded where ${pc.yellow(
|
|
432
|
+
root,
|
|
433
|
+
)} forked off it.\n\n Rebasing from the merge-base would replay already-merged commits and conflict.\n Re-root manually once you know the old base tip:\n ${pc.green(
|
|
434
|
+
`git rebase --onto ${newBase} <old-base-tip> ${root}`,
|
|
389
435
|
)}`,
|
|
390
436
|
);
|
|
391
437
|
process.exit(1);
|
|
@@ -439,7 +485,7 @@ async function handleReroot(
|
|
|
439
485
|
|
|
440
486
|
rebasedBranches.push(root);
|
|
441
487
|
touched.push(root);
|
|
442
|
-
await pushBranchRef(root);
|
|
488
|
+
await pushBranchRef(meta, root);
|
|
443
489
|
|
|
444
490
|
// Restack the children onto the moved root.
|
|
445
491
|
const children = ordered.slice(1);
|
|
@@ -485,7 +531,7 @@ async function processChain(
|
|
|
485
531
|
p.log.success("Already up to date with parent");
|
|
486
532
|
if (!dryRun) {
|
|
487
533
|
touched.push(branch);
|
|
488
|
-
await pushBranchRef(branch);
|
|
534
|
+
await pushBranchRef(meta, branch);
|
|
489
535
|
}
|
|
490
536
|
continue;
|
|
491
537
|
}
|
|
@@ -583,7 +629,7 @@ async function processChain(
|
|
|
583
629
|
|
|
584
630
|
// Push immediately (don't batch) so an interrupt can't leave later
|
|
585
631
|
// branches un-pushed.
|
|
586
|
-
await pushBranchRef(branch);
|
|
632
|
+
await pushBranchRef(meta, branch);
|
|
587
633
|
} else {
|
|
588
634
|
// Conflict!
|
|
589
635
|
console.log();
|
|
@@ -609,11 +655,36 @@ async function processChain(
|
|
|
609
655
|
* differs from origin OR the remote branch doesn't exist yet (so a branch
|
|
610
656
|
* that was never pushed still lands). No-op when already in sync. (#12)
|
|
611
657
|
*/
|
|
612
|
-
|
|
658
|
+
/**
|
|
659
|
+
* Whether a branch has been published — it has a PR recorded in metadata.
|
|
660
|
+
* restack/sync keep published branches' remotes in sync after a rebase, but must
|
|
661
|
+
* NOT create a remote for an unpublished, local-only branch — publishing is
|
|
662
|
+
* `submit`'s job, not a side effect of restacking. (#24)
|
|
663
|
+
*/
|
|
664
|
+
function branchHasPr(meta: StackMetadata, branch: string): boolean {
|
|
665
|
+
const stackName = findStackForBranch(meta, branch);
|
|
666
|
+
if (!stackName) return false;
|
|
667
|
+
return meta.stacks[stackName]?.branches[branch]?.pr != null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void> {
|
|
613
671
|
if (branch === "main" || branch === "master") return;
|
|
614
672
|
|
|
615
673
|
const localSha = await git.revParse(branch);
|
|
616
674
|
const hasRemote = await git.remoteBranchExists(branch);
|
|
675
|
+
|
|
676
|
+
// Don't publish an unsubmitted branch. A local-only branch (no remote AND no
|
|
677
|
+
// PR) is intentionally not on origin — restack/sync rebase it locally but
|
|
678
|
+
// leave pushing to `submit`. Skip with a note rather than creating its remote
|
|
679
|
+
// (the #24 surprise: `--yes` silently published a WIP tip). A branch that
|
|
680
|
+
// already has a remote or a PR still gets pushed below to stay in sync.
|
|
681
|
+
if (!hasRemote && !branchHasPr(meta, branch)) {
|
|
682
|
+
p.log.info(
|
|
683
|
+
`Skipping push of ${pc.yellow(branch)} — local-only (not submitted). Run ${pc.green("gh-stack submit")} to open its PR.`,
|
|
684
|
+
);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
617
688
|
const remoteSha = hasRemote ? await git.revParse(`origin/${branch}`).catch(() => null) : null;
|
|
618
689
|
|
|
619
690
|
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;
|
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
|
*/
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// stack's only record while a busy sibling stack churned (see issue #13).
|
|
8
8
|
import type { StackMetadata, Snapshot } from "../types.ts";
|
|
9
9
|
import * as git from "./git.ts";
|
|
10
|
-
import { getOrderedBranches } from "./metadata.ts";
|
|
10
|
+
import { getOrderedBranches, stackBase } from "./metadata.ts";
|
|
11
11
|
import { snapshotsDir } from "./paths.ts";
|
|
12
12
|
import { mkdir, readdir, unlink } from "node:fs/promises";
|
|
13
13
|
|
|
@@ -86,6 +86,48 @@ export async function findPreRewriteSha(
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Find the recorded tip of a stack's (non-main) base branch — the boundary a
|
|
91
|
+
* re-root needs to replay only the dependent stack's own commits.
|
|
92
|
+
*
|
|
93
|
+
* Walks snapshots newest-first for one whose recorded `baseBranch` matches and
|
|
94
|
+
* whose recorded `baseTip` is still an ancestor of `rootBranch` (the dependent
|
|
95
|
+
* stack's root). That tip is exactly where the root forked off the base, so
|
|
96
|
+
* `git rebase --onto <new-base> <baseTip> <root>` drops the already-merged
|
|
97
|
+
* prefix instead of replaying it.
|
|
98
|
+
*
|
|
99
|
+
* Unlike `findPreRewriteSha`, this does NOT require the base branch to still
|
|
100
|
+
* exist locally — that's the whole point: after a squash-merge deletes the base
|
|
101
|
+
* branch, its tip survives only in these snapshots. The ancestor-of-root check
|
|
102
|
+
* rejects stale candidates (an older base tip the root no longer sits on).
|
|
103
|
+
*
|
|
104
|
+
* Returns null if no snapshot recorded this base, or none is still an ancestor
|
|
105
|
+
* of the root (in which case the caller must refuse rather than guess).
|
|
106
|
+
*/
|
|
107
|
+
export async function findRecordedBaseTip(
|
|
108
|
+
meta: StackMetadata,
|
|
109
|
+
baseBranch: string,
|
|
110
|
+
rootBranch: string,
|
|
111
|
+
): Promise<string | null> {
|
|
112
|
+
if (!meta.snapshots || meta.snapshots.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
let rootTip: string;
|
|
115
|
+
try {
|
|
116
|
+
rootTip = await git.revParse(rootBranch);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Walk newest → oldest; first base tip that the root still sits on wins.
|
|
122
|
+
for (let i = meta.snapshots.length - 1; i >= 0; i--) {
|
|
123
|
+
const snap = meta.snapshots[i]!;
|
|
124
|
+
if (snap.baseBranch !== baseBranch || !snap.baseTip) continue;
|
|
125
|
+
if (await git.isAncestor(snap.baseTip, rootTip)) return snap.baseTip;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
// Snapshot files are named `<sortable-timestamp>__<encoded-stack>.json`. The
|
|
90
132
|
// timestamp keeps colons/dots out of the filename (Windows-safe) while sorting
|
|
91
133
|
// chronologically as a plain string; the stack name is URL-encoded so `/` in a
|
|
@@ -161,6 +203,20 @@ export async function takeSnapshot(
|
|
|
161
203
|
stack: stackName,
|
|
162
204
|
};
|
|
163
205
|
|
|
206
|
+
// For a non-main base (a stack stacked on top of another stack), record the
|
|
207
|
+
// base branch's tip too — kept out of `branches` so `undo` won't reset a
|
|
208
|
+
// branch that belongs to the parent stack. This is the boundary a later
|
|
209
|
+
// re-root needs if the base branch is deleted by a squash-merge (issue #13).
|
|
210
|
+
const base = stackBase(stack);
|
|
211
|
+
if (base !== "main" && base !== "master") {
|
|
212
|
+
try {
|
|
213
|
+
snapshot.baseTip = await git.revParse(base);
|
|
214
|
+
snapshot.baseBranch = base;
|
|
215
|
+
} catch {
|
|
216
|
+
// Base branch already gone locally — nothing to record.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
164
220
|
await writeSnapshotFile(snapshot);
|
|
165
221
|
|
|
166
222
|
// Keep in-memory array consistent for callers that read it afterward.
|
|
@@ -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 #
|
|
187
|
-
gh-stack merge --approved #
|
|
188
|
-
#
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
package/src/types.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface Snapshot {
|
|
|
50
50
|
operation: string; // "restack" | "merge" | "sync" | "remove"
|
|
51
51
|
branches: Record<string, string>; // branch name -> commit SHA
|
|
52
52
|
stack?: string; // owning stack (v3: drives per-stack retention + file naming)
|
|
53
|
+
// For a stack with a non-main base: the base branch and its tip at snapshot
|
|
54
|
+
// time. Recorded separately from `branches` (which `undo` force-resets) so a
|
|
55
|
+
// dependent stack can re-root onto main after its base branch is deleted by a
|
|
56
|
+
// squash-merge — without that recorded boundary the reroot would replay the
|
|
57
|
+
// already-merged commits and conflict (issue #13).
|
|
58
|
+
baseBranch?: string;
|
|
59
|
+
baseTip?: string;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
// Resume state for restack --resume
|