gh-stack 0.7.0 → 0.9.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Stacked PR manager for squash-merge workflows.
4
4
 
5
- Manages stacked pull requests with metadata stored in `.git/gh-stack-metadata.json`. Designed for repositories that use squash-merge (where tools like Graphite break down). Inspired by [Graphite](https://graphite.dev/) (`gt`) but works directly against GitHub — no backend required.
5
+ Manages stacked pull requests with metadata stored locally under `.git/.gh-stack/` (never committed). Designed for repositories that use squash-merge (where tools like Graphite break down). Inspired by [Graphite](https://graphite.dev/) (`gt`) but works directly against GitHub — no backend required.
6
6
 
7
7
  ## Install
8
8
 
@@ -62,6 +62,11 @@ gh-stack sync
62
62
 
63
63
  # Check PR status
64
64
  gh-stack status
65
+
66
+ # Split a long in-review chain so new work becomes its own stack
67
+ gh-stack split feat-12 # feat-12.. → new stack based on feat-11
68
+ # ...later, after the original stack merges to main:
69
+ gh-stack restack --onto main # re-root the split stack onto main
65
70
  ```
66
71
 
67
72
  ## Commands
@@ -133,6 +138,12 @@ restack [--resume] [--dry-run] [--verbose]
133
138
 
134
139
  (alias: rebase)
135
140
 
141
+ restack --onto <ref>
142
+ Re-root the current stack onto a new base ref, changing the stack's
143
+ base. Use this to move a split stack off its parent-stack branch and
144
+ onto main once the parent stack has merged — only the stack's own
145
+ commits replay onto the new base.
146
+
136
147
  sync [--dry-run]
137
148
  Fetch main, rebase the base branch onto main, then restack all
138
149
  children. Snapshots every branch's pre-sync tip so children can be
@@ -149,9 +160,25 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
149
160
  review the cumulative diff on GitHub. Re-run
150
161
  `gh-stack merge` (without --collapse) to finish.
151
162
 
152
- delete [<branch>]
153
- Remove a branch from the stack and re-parent its children.
154
- Interactive selector if no branch specified.
163
+ split [<branch>] [--name <name>]
164
+ Cut the current stack into two at <branch>. The cut branch and every
165
+ branch above it move into a NEW stack whose base is the cut branch's
166
+ parent (which stays in the original stack). Purely a metadata
167
+ operation — no git branches are moved or rebased. Interactive
168
+ selector if no branch is given; you can't split at the stack's root.
169
+
170
+ Use it when a long chain is in review and can't merge yet, but new
171
+ work is piling on top: split at the first "new work" branch so the
172
+ original stack stays the review unit and the new stack rides on its
173
+ tip. Once the original stack merges, re-root the new stack with
174
+ `gh-stack restack --onto main`.
175
+
176
+ delete [<branch>] [-k|--keep-branch] [--no-remote]
177
+ Remove a branch from the stack and re-parent its children, then
178
+ delete the underlying local git branch (and the remote branch if
179
+ it was pushed). Interactive selector if no branch specified.
180
+ --keep-branch leaves the git branches untouched (metadata only);
181
+ --no-remote deletes the local branch but keeps the remote.
155
182
  ```
156
183
 
157
184
  ### Info & Maintenance
@@ -171,6 +198,11 @@ undo
171
198
 
172
199
  archive [--restore <name>]
173
200
  List archived stacks by default, or restore one by name.
201
+
202
+ doctor
203
+ Migrate old (v2) metadata to the v3 layout, reconcile git branch
204
+ config against the topology files, and flag stacks whose base
205
+ stack appears already-merged into main. Safe to run repeatedly.
174
206
  ```
175
207
 
176
208
  ## Global Options
@@ -208,47 +240,85 @@ Snapshots also power `gh-stack undo`, so the same data structure does double dut
208
240
 
209
241
  ### Stack Visualization
210
242
 
211
- `submit` automatically adds a stack section to all PR descriptions:
243
+ `submit` automatically adds a stack section to all PR descriptions, with each
244
+ branch numbered by its position in the stack (handy for spotting "which one is
245
+ PR 6 of 12"):
212
246
 
213
247
  ```
214
248
  ### 📚 Stacked on
215
249
  ⚫ main
216
250
  ┃
217
- ┣━ ✅ #123 Backend models
251
+ ┣━ 1. #123 Backend models
218
252
  ┃
219
- ┗━ ⏳ #124 Frontend UI 👈
253
+ ┗━ 2. #124 Frontend UI 👈
220
254
  ```
221
255
 
222
- ### Metadata
256
+ The block is rendered entirely from local metadata plus one repo-identity
257
+ lookup for links — no per-PR API call — and `submit` only rewrites a PR
258
+ description whose block actually changed (membership/order/title), so a
259
+ re-submit that touched one branch on a 12-PR stack does ~1 update, not 12.
260
+ Review/CI status is intentionally not shown here. Run `gh-stack update-prs`
261
+ (`--force` to rewrite all) to refresh manually.
262
+
263
+ ### Metadata (v3)
264
+
265
+ Stack metadata lives under `.git/.gh-stack/` (never committed) — a folder of
266
+ per-stack files plus git-native branch config, rather than a single JSON blob:
267
+
268
+ ```
269
+ .git/.gh-stack/
270
+ current hint: last-active stack name
271
+ active/<stack>.json topology of a live stack (ordered branches, base, description)
272
+ archived/<stack>.json merged/closed stacks
273
+ deleted/<stack>.json tombstones (recoverable)
274
+ snapshots/<ts>__<stack>.json append-only, retained per-stack
275
+ restack-state.json in-flight restack/sync resume state
276
+ ```
223
277
 
224
- Stack metadata lives at `.git/gh-stack-metadata.json` (never committed):
278
+ A single `active/<stack>.json` looks like:
225
279
 
226
280
  ```json
227
281
  {
228
- "version": 2,
229
- "current_stack": "kiliman/feature-2",
230
- "stacks": {
231
- "kiliman/feature-2": {
232
- "description": "",
233
- "last_branch": "kiliman/feature-2",
234
- "branches": {
235
- "kiliman/feature-1": {
236
- "parent": "main",
237
- "pr": 21729
238
- },
239
- "kiliman/feature-2": {
240
- "parent": "kiliman/feature-1",
241
- "pr": 21730
242
- }
243
- }
244
- }
282
+ "description": "",
283
+ "last_branch": "kiliman/feature-2",
284
+ "base": "main",
285
+ "branches": {
286
+ "kiliman/feature-1": { "parent": "main", "pr": 21729 },
287
+ "kiliman/feature-2": { "parent": "kiliman/feature-1", "pr": 21730 }
245
288
  }
246
289
  }
247
290
  ```
248
291
 
292
+ Per-branch membership is *also* recorded in git's own config, so renaming or
293
+ deleting a branch updates/cleans it automatically:
294
+
295
+ ```
296
+ branch.<name>.ghstack-stack <stack>
297
+ branch.<name>.ghstack-parent <parent-branch>
298
+ branch.<name>.ghstack-pr <number>
299
+ ```
300
+
301
+ The two representations cross-check each other; `gh-stack doctor` reconciles
302
+ any drift. Why this shape:
303
+
304
+ - **A stack can't silently vanish** — lifecycle transitions are file moves
305
+ (`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
306
+ just unlinked.
307
+ - **No `current_stack` drift** — the stack you're on is derivable from the
308
+ branch's own config, so a written pointer can't contradict reality.
309
+ - **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
310
+
311
+ #### Migrating from v2
312
+
313
+ Repos created before v3 store a single `.git/gh-stack-metadata.json`. Run
314
+ `gh-stack doctor` once — it fans the monolith out into the layout above,
315
+ backfills branch config, explodes snapshots into per-file records, and keeps a
316
+ `.bak` of the old file. Commands refuse to run on unmigrated metadata and point
317
+ you at `doctor`.
318
+
249
319
  ### Snapshots
250
320
 
251
- Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs. Run `gh-stack undo` to restore.
321
+ Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs as an append-only file under `snapshots/`, retained per-stack. Run `gh-stack undo` to restore.
252
322
 
253
323
  ## Example Workflow
254
324
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ import pc from "picocolors";
4
4
  import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
5
5
  import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
6
6
  import { takeSnapshot } from "../lib/snapshot.ts";
7
- import { selectBranch } from "../lib/ui.ts";
7
+ import { selectBranch, confirmAction } from "../lib/ui.ts";
8
8
  import * as git from "../lib/git.ts";
9
9
 
10
10
  export default async function deleteCmd(args: string[]): Promise<void> {
@@ -13,14 +13,26 @@ export default async function deleteCmd(args: string[]): Promise<void> {
13
13
  gh-stack delete — Delete a branch from the stack
14
14
 
15
15
  USAGE
16
- gh-stack delete [<branch>]
16
+ gh-stack delete [<branch>] [options]
17
17
 
18
- Removes the branch and re-parents its children to its parent.
19
- If no branch is specified, shows interactive selector.
18
+ Removes the branch from the stack and deletes the underlying git branch,
19
+ re-parenting any children to the removed branch's parent. If the deleted
20
+ branch was pushed, its remote counterpart is deleted too.
21
+
22
+ If no branch is specified, shows an interactive selector.
23
+
24
+ OPTIONS
25
+ -k, --keep-branch Only remove from the stack metadata; leave the
26
+ local and remote git branches untouched
27
+ --no-remote Delete the local branch but keep the remote branch
20
28
  `);
21
29
  return;
22
30
  }
23
31
 
32
+ const keepBranch = args.includes("--keep-branch") || args.includes("-k");
33
+ const noRemote = args.includes("--no-remote");
34
+ const positional = args.filter((a) => !a.startsWith("-"));
35
+
24
36
  const meta = await ensureMetadata();
25
37
  const stackName = ensureCurrentStack(meta);
26
38
  await ensureValidStack(meta, stackName);
@@ -30,7 +42,7 @@ If no branch is specified, shows interactive selector.
30
42
  p.intro(pc.cyan("Delete Branch from Stack"));
31
43
 
32
44
  // Get branch to delete
33
- let branchName = args[0];
45
+ let branchName = positional[0];
34
46
  if (!branchName) {
35
47
  branchName = (await selectBranch(stack, "Select branch to delete", currentBranch)) ?? undefined;
36
48
  if (!branchName) {
@@ -46,9 +58,13 @@ If no branch is specified, shows interactive selector.
46
58
  process.exit(1);
47
59
  }
48
60
 
61
+ // Discover what actually exists in git so we report (and delete) accurately
62
+ const hasLocal = !keepBranch && (await git.localBranchExists(branchName));
63
+ const hasRemote = !keepBranch && !noRemote && (await git.remoteBranchExists(branchName));
64
+
49
65
  // Show what will happen
50
66
  const children = getChildren(stack, branchName);
51
- p.log.info(`Removing: ${pc.yellow(branchName)}`);
67
+ p.log.info(`Removing: ${pc.yellow(branchName)} from stack ${pc.blue(stackName)}`);
52
68
  p.log.info(`Parent was: ${pc.dim(branch.parent)}`);
53
69
 
54
70
  if (children.length > 0) {
@@ -60,11 +76,19 @@ If no branch is specified, shows interactive selector.
60
76
  }
61
77
  }
62
78
 
63
- // Confirm
64
- const confirmed = await p.confirm({
65
- message: `Delete ${pc.yellow(branchName)} from stack?`,
66
- });
67
- if (p.isCancel(confirmed) || !confirmed) {
79
+ if (keepBranch) {
80
+ p.log.info(pc.dim("Keeping git branch (--keep-branch) — stack metadata only"));
81
+ } else {
82
+ if (hasLocal) p.log.info(`Deleting local branch ${pc.yellow(branchName)}`);
83
+ if (hasRemote) p.log.info(`Deleting remote branch ${pc.yellow(`origin/${branchName}`)}`);
84
+ if (!hasLocal && !hasRemote) {
85
+ p.log.info(pc.dim("No git branch found to delete — stack metadata only"));
86
+ }
87
+ }
88
+
89
+ // Confirm (respects --yes / GH_STACK_YES — no interactive hang for agents)
90
+ const confirmed = await confirmAction(`Delete ${pc.yellow(branchName)}?`);
91
+ if (!confirmed) {
68
92
  p.cancel("Cancelled");
69
93
  process.exit(0);
70
94
  }
@@ -72,8 +96,38 @@ If no branch is specified, shows interactive selector.
72
96
  // Take snapshot before destructive operation
73
97
  await takeSnapshot(meta, stackName, "delete");
74
98
 
75
- // Remove branch
99
+ // If we're sitting on the branch we're about to delete, git won't let us
100
+ // remove it — move to the parent (or trunk) first.
101
+ if (hasLocal && branchName === currentBranch) {
102
+ const fallback = (await git.localBranchExists(branch.parent))
103
+ ? branch.parent
104
+ : await git.trunkBranch();
105
+ await git.checkout(fallback);
106
+ p.log.info(`Switched to ${pc.blue(fallback)} (was on the deleted branch)`);
107
+ }
108
+
109
+ // Remove from metadata (re-parents children, fixes last_branch)
76
110
  await removeBranchFromStack(meta, stackName, branchName);
77
111
 
112
+ // Delete the actual git branch(es)
113
+ if (hasRemote) {
114
+ const ok = await git.deleteRemoteBranch(branchName);
115
+ if (ok) {
116
+ p.log.success(`Deleted remote branch ${pc.dim(`origin/${branchName}`)}`);
117
+ } else {
118
+ p.log.warn(
119
+ `Could not delete remote branch ${pc.dim(`origin/${branchName}`)} (delete it manually)`,
120
+ );
121
+ }
122
+ }
123
+ if (hasLocal) {
124
+ const ok = await git.deleteLocalBranch(branchName);
125
+ if (ok) {
126
+ p.log.success(`Deleted local branch ${pc.dim(branchName)}`);
127
+ } else {
128
+ p.log.warn(`Could not delete local branch ${pc.dim(branchName)} (delete it manually)`);
129
+ }
130
+ }
131
+
78
132
  p.outro(pc.green(`Removed ${pc.yellow(branchName)} from stack ${pc.blue(stackName)}`));
79
133
  }
@@ -0,0 +1,268 @@
1
+ // gh-stack doctor — migrate v2 → v3, reconcile branch config ↔ topology,
2
+ // and flag stacks whose base stack appears to have merged.
3
+ import * as p from "../lib/output.ts";
4
+ import pc from "picocolors";
5
+ import { rename, unlink } from "node:fs/promises";
6
+ import * as git from "../lib/git.ts";
7
+ import type { StackMetadata, Snapshot, Stack } from "../types.ts";
8
+ import {
9
+ readMetadata,
10
+ writeMetadata,
11
+ v3Exists,
12
+ legacyMetadataExists,
13
+ stackBase,
14
+ } from "../lib/metadata.ts";
15
+ import { legacyMetadataPath, legacyRestackStatePath, restackStateFile } from "../lib/paths.ts";
16
+ import { writeSnapshotFile } from "../lib/snapshot.ts";
17
+ import { allBranchMemberships } from "../lib/branch-config.ts";
18
+
19
+ const HELP = `
20
+ gh-stack doctor — Migrate & repair stack metadata
21
+
22
+ USAGE
23
+ gh-stack doctor
24
+
25
+ Migrates old (v2) metadata to the v3 layout (per-stack files under
26
+ .git/.gh-stack/ + git branch config), then reconciles branch config
27
+ against the topology files and flags stacks whose base stack looks
28
+ already-merged into main.
29
+
30
+ Safe to run repeatedly — it's idempotent.
31
+ `;
32
+
33
+ export default async function doctor(args: string[]): Promise<void> {
34
+ if (args.includes("--help")) {
35
+ console.log(HELP);
36
+ return;
37
+ }
38
+
39
+ p.intro(pc.cyan("gh-stack doctor"));
40
+
41
+ // ── 1. Migrate v2 → v3 if needed ──
42
+ const migrated = await migrateIfNeeded();
43
+
44
+ if (!(await v3Exists())) {
45
+ p.cancel(
46
+ `No stack metadata found.\n\n Create your first stack with:\n ${pc.green("gh-stack init")}`,
47
+ );
48
+ process.exit(1);
49
+ }
50
+
51
+ const meta = await readMetadata();
52
+ if (!meta) {
53
+ p.cancel("Failed to read stack metadata after migration");
54
+ process.exit(1);
55
+ }
56
+
57
+ // ── 2. Reconcile branch config ↔ topology ──
58
+ await reconcile(meta);
59
+
60
+ // ── 3. Flag base-stack-merged situations (issue #13) ──
61
+ await flagMergedBaseStacks(meta);
62
+
63
+ if (migrated) {
64
+ p.outro(pc.green("Migration complete — metadata is now v3."));
65
+ } else {
66
+ p.outro(pc.green("Metadata looks healthy."));
67
+ }
68
+ }
69
+
70
+ // ── Migration ──
71
+
72
+ async function migrateIfNeeded(): Promise<boolean> {
73
+ if (!(await legacyMetadataExists())) return false;
74
+
75
+ const legacyPath = await legacyMetadataPath();
76
+
77
+ // If a v3 store already exists alongside the legacy file, don't clobber it —
78
+ // just retire the stale legacy file.
79
+ if (await v3Exists()) {
80
+ p.log.warn(
81
+ `Both v3 metadata and a legacy file exist. Keeping v3 and backing up the legacy file.`,
82
+ );
83
+ await rename(legacyPath, `${legacyPath}.bak`).catch(() => {});
84
+ return false;
85
+ }
86
+
87
+ const s = p.spinner();
88
+ s.start("Migrating v2 metadata → v3...");
89
+
90
+ const raw = (await Bun.file(legacyPath).json()) as Partial<StackMetadata>;
91
+ const stacks: Record<string, Stack> = raw.stacks ?? {};
92
+ const archive: Record<string, Stack> = raw.archive ?? {};
93
+ const snapshots: Snapshot[] = raw.snapshots ?? [];
94
+
95
+ // Backfill base for stacks written before the split feature.
96
+ for (const stack of [...Object.values(stacks), ...Object.values(archive)]) {
97
+ if (stack.base == null) stack.base = "main";
98
+ }
99
+
100
+ const meta: StackMetadata = {
101
+ version: 3,
102
+ current_stack: raw.current_stack ?? null,
103
+ stacks,
104
+ };
105
+ if (Object.keys(archive).length > 0) meta.archive = archive;
106
+
107
+ // Fan out topology + backfill branch config.
108
+ await writeMetadata(meta);
109
+
110
+ // Explode the snapshots[] array into per-file records, inferring each
111
+ // snapshot's owning stack from its recorded branch set (v2 didn't store it).
112
+ for (const snap of snapshots) {
113
+ const owner = inferStack(snap, stacks, archive) ?? meta.current_stack ?? undefined;
114
+ await writeSnapshotFile({ ...snap, stack: owner });
115
+ }
116
+
117
+ // Migrate the legacy restack-state file, if present.
118
+ const legacyState = await legacyRestackStatePath();
119
+ if (await Bun.file(legacyState).exists()) {
120
+ const content = await Bun.file(legacyState).text();
121
+ await Bun.write(await restackStateFile(), content);
122
+ await unlink(legacyState).catch(() => {});
123
+ }
124
+
125
+ // Retire the legacy monolith (keep a .bak for safety).
126
+ await rename(legacyPath, `${legacyPath}.bak`).catch(() => {});
127
+
128
+ s.stop("Migrated to v3");
129
+
130
+ const stackCount = Object.keys(stacks).length;
131
+ const archiveCount = Object.keys(archive).length;
132
+ p.log.info(
133
+ ` ${pc.dim("→")} ${stackCount} active, ${archiveCount} archived, ${snapshots.length} snapshot(s)`,
134
+ );
135
+ p.log.info(` ${pc.dim("→")} Backed up legacy file to ${pc.dim(`${legacyPath}.bak`)}`);
136
+ return true;
137
+ }
138
+
139
+ /** Pick the stack whose branch set best overlaps a snapshot's branches. */
140
+ function inferStack(
141
+ snap: Snapshot,
142
+ stacks: Record<string, Stack>,
143
+ archive: Record<string, Stack>,
144
+ ): string | null {
145
+ const recorded = new Set(Object.keys(snap.branches));
146
+ let best: string | null = null;
147
+ let bestOverlap = 0;
148
+ for (const [name, stack] of Object.entries({ ...archive, ...stacks })) {
149
+ let overlap = 0;
150
+ for (const b of Object.keys(stack.branches)) if (recorded.has(b)) overlap++;
151
+ if (overlap > bestOverlap) {
152
+ bestOverlap = overlap;
153
+ best = name;
154
+ }
155
+ }
156
+ return best;
157
+ }
158
+
159
+ // ── Reconciliation ──
160
+
161
+ async function reconcile(meta: StackMetadata): Promise<void> {
162
+ // Re-syncing config is what writeMetadata already does: set desired
163
+ // membership, clear stale. Snapshot the before-state so we can report what
164
+ // drifted, then apply.
165
+ const before = await allBranchMemberships();
166
+
167
+ // Report topology branches that no longer exist locally (can't auto-fix
168
+ // safely — re-parenting children is a judgement call).
169
+ const deadBranches: { stack: string; branch: string }[] = [];
170
+ for (const [name, stack] of Object.entries(meta.stacks)) {
171
+ for (const branch of Object.keys(stack.branches)) {
172
+ if (!(await git.localBranchExists(branch))) {
173
+ deadBranches.push({ stack: name, branch });
174
+ }
175
+ }
176
+ }
177
+
178
+ // Build desired membership to diff against `before`.
179
+ const desired = new Set<string>();
180
+ let drift = 0;
181
+ for (const [name, stack] of Object.entries(meta.stacks)) {
182
+ for (const [branch, b] of Object.entries(stack.branches)) {
183
+ desired.add(branch);
184
+ const cur = before.get(branch);
185
+ if (!cur || cur.stack !== name || cur.parent !== b.parent || cur.pr !== b.pr) drift++;
186
+ }
187
+ }
188
+ let orphans = 0;
189
+ for (const branch of before.keys()) {
190
+ if (!desired.has(branch)) orphans++;
191
+ }
192
+
193
+ // Apply the reconciliation (idempotent).
194
+ await writeMetadata(meta);
195
+
196
+ if (drift > 0 || orphans > 0) {
197
+ p.log.success(
198
+ `Reconciled branch config: ${drift} updated, ${orphans} stale membership(s) cleared.`,
199
+ );
200
+ } else {
201
+ p.log.info("Branch config already in sync with topology.");
202
+ }
203
+
204
+ if (deadBranches.length > 0) {
205
+ console.log();
206
+ p.log.warn(`${deadBranches.length} branch(es) in topology no longer exist locally:`);
207
+ for (const { stack, branch } of deadBranches) {
208
+ console.log(` ${pc.red("•")} ${pc.yellow(branch)} ${pc.dim(`(stack: ${stack})`)}`);
209
+ }
210
+ console.log(
211
+ ` ${pc.dim(`Prune with ${pc.green("gh-stack delete <branch>")} or restore the branch.`)}`,
212
+ );
213
+ }
214
+ }
215
+
216
+ // ── Base-stack-merged detection (issue #13) ──
217
+
218
+ async function flagMergedBaseStacks(meta: StackMetadata): Promise<void> {
219
+ const trunk = await git.trunkBranch();
220
+ const flagged: { stack: string; base: string; reason: string }[] = [];
221
+
222
+ for (const [name, stack] of Object.entries(meta.stacks)) {
223
+ const base = stackBase(stack);
224
+ if (base === "main" || base === "master") continue; // trunk-rooted: not affected
225
+
226
+ const baseExists = await git.localBranchExists(base);
227
+ if (!baseExists) {
228
+ flagged.push({
229
+ stack: name,
230
+ base,
231
+ reason: `base branch ${pc.yellow(base)} is gone (parent stack likely merged)`,
232
+ });
233
+ continue;
234
+ }
235
+
236
+ // Base still exists — has it already landed in trunk?
237
+ let baseInTrunk = false;
238
+ for (const ref of [`origin/${trunk}`, trunk]) {
239
+ try {
240
+ if (await git.isAncestor(base, ref)) {
241
+ baseInTrunk = true;
242
+ break;
243
+ }
244
+ } catch {
245
+ // ref not available locally — ignore
246
+ }
247
+ }
248
+ if (baseInTrunk) {
249
+ flagged.push({
250
+ stack: name,
251
+ base,
252
+ reason: `base ${pc.yellow(base)} is already in ${pc.cyan(trunk)}`,
253
+ });
254
+ }
255
+ }
256
+
257
+ if (flagged.length === 0) return;
258
+
259
+ console.log();
260
+ p.log.warn("Stacks whose base appears merged into main:");
261
+ for (const { stack, reason } of flagged) {
262
+ console.log(` ${pc.yellow("⚠")} ${pc.yellow(stack)} — ${reason}`);
263
+ }
264
+ console.log();
265
+ console.log(` ${pc.dim("Re-root each onto main once its parent has merged:")}`);
266
+ console.log(` ${pc.green("gh-stack checkout <stack>")}`);
267
+ console.log(` ${pc.green("gh-stack restack --onto main")}`);
268
+ }
@@ -2,7 +2,7 @@
2
2
  import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
- import { findStackForBranch, writeMetadata } from "../lib/metadata.ts";
5
+ import { findStackForBranch, stackBase, writeMetadata } from "../lib/metadata.ts";
6
6
  import { ensureMetadata } from "../lib/safety.ts";
7
7
 
8
8
  export default async function down(args: string[]): Promise<void> {
@@ -35,6 +35,8 @@ OPTIONS
35
35
  }
36
36
 
37
37
  const stack = meta.stacks[stackName]!;
38
+ const base = stackBase(stack);
39
+ let moved = 0;
38
40
 
39
41
  for (let i = 0; i < steps; i++) {
40
42
  const branchMeta = stack.branches[currentBranch];
@@ -43,17 +45,23 @@ OPTIONS
43
45
  return;
44
46
  }
45
47
 
48
+ // The stack bottoms out at its base (main for a normal stack, or the base
49
+ // branch for a split stack). The root's parent IS the base — stop there
50
+ // rather than crossing into a parent stack.
46
51
  const parent = branchMeta.parent;
47
- if (parent === "main" || parent === "master") {
48
- if (i === 0) {
52
+ if (parent === base) {
53
+ if (moved === 0) {
49
54
  p.log.warn(`${pc.yellow(currentBranch)} is already at the bottom of the stack`);
50
- } else {
51
- p.log.info(`Reached bottom of stack at ${pc.yellow(currentBranch)} after ${i} step(s)`);
55
+ return;
52
56
  }
53
- return;
57
+ // Reached the bottom partway through a multi-step move — stop here but
58
+ // still check out as far as we got.
59
+ p.log.info(`Reached bottom of stack after ${moved} step(s)`);
60
+ break;
54
61
  }
55
62
 
56
63
  currentBranch = parent;
64
+ moved++;
57
65
  }
58
66
 
59
67
  await git.checkout(currentBranch);
@@ -13,6 +13,7 @@ import {
13
13
  import { getPrNumber } from "../lib/github.ts";
14
14
  import { isAutoYes } from "../lib/ui.ts";
15
15
  import { detectBranchChain } from "../lib/chain.ts";
16
+ import { gateLegacyMetadata } from "../lib/safety.ts";
16
17
  import type { StackMetadata, Branch } from "../types.ts";
17
18
 
18
19
  const HELP = `
@@ -71,6 +72,8 @@ export default async function init(args: string[]): Promise<void> {
71
72
  if (await metadataExists()) {
72
73
  meta = (await readMetadata())!;
73
74
  } else {
75
+ // Don't create a fresh v3 store on top of an unmigrated v2 file.
76
+ await gateLegacyMetadata();
74
77
  meta = await initMetadata();
75
78
  }
76
79
 
@@ -121,7 +124,7 @@ export default async function init(args: string[]): Promise<void> {
121
124
  }
122
125
 
123
126
  // ── Create stack ──
124
- meta = await createStack(meta, stackName, descFlag || "");
127
+ meta = await createStack(meta, stackName, descFlag || "", trunk);
125
128
 
126
129
  // ── Add branches with PR detection ──
127
130
  const prSpinner = p.spinner();
@@ -9,10 +9,12 @@ import {
9
9
  writeMetadata,
10
10
  } from "../lib/metadata.ts";
11
11
  import { renderStackTree } from "../lib/ui.ts";
12
+ import { gateLegacyMetadata } from "../lib/safety.ts";
12
13
 
13
14
  export default async function log(_args: string[]): Promise<void> {
14
15
  // Check if metadata exists
15
16
  if (!(await metadataExists())) {
17
+ await gateLegacyMetadata(); // exits with a migrate hint if a v2 file remains
16
18
  p.log.warn("No stack metadata found");
17
19
  console.log();
18
20
  console.log(` Create your first stack with:`);