gh-stack 0.7.1 → 0.10.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
@@ -143,12 +154,40 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
143
154
  then enables auto-merge for the base PR into main. All merges
144
155
  happen on GitHub so PRs show as "Merged", Linear tickets close
145
156
  automatically, and GitHub Actions fire normally. Skips already-
146
- merged PRs (safe to re-run). Waits for GitHub between merges.
157
+ merged PRs (safe to re-run). Waits for GitHub between merges, and
158
+ self-heals its spurious "Head branch is out of date" (a stale PR
159
+ head pointer after a child squash) by re-syncing and retrying.
147
160
  --collapse Stop after collapsing the stack into the base PR;
148
161
  leaves the base PR open against main so you can
149
162
  review the cumulative diff on GitHub. Re-run
150
163
  `gh-stack merge` (without --collapse) to finish.
151
164
 
165
+ split [<branch>] [--name <name>]
166
+ Cut the current stack into two at <branch>. The cut branch and every
167
+ branch above it move into a NEW stack whose base is the cut branch's
168
+ parent (which stays in the original stack). Purely a metadata
169
+ operation — no git branches are moved or rebased. Interactive
170
+ selector if no branch is given; you can't split at the stack's root.
171
+
172
+ Use it when a long chain is in review and can't merge yet, but new
173
+ work is piling on top: split at the first "new work" branch so the
174
+ original stack stays the review unit and the new stack rides on its
175
+ tip. Once the original stack merges, re-root the new stack with
176
+ `gh-stack restack --onto main`.
177
+
178
+ rename [<old-name>] <new-name>
179
+ Rename a tracked branch (runs `git branch -m`) and re-key the stack
180
+ topology so it stays tracked, re-parenting any children that pointed
181
+ at the old name. With one argument, renames the current branch. Built
182
+ for the throwaway-branch → ticketed-PR flow (`feature-wip` →
183
+ `feature-BEE-1234`) without hand-editing metadata.
184
+
185
+ You don't strictly need this command: gh-stack also auto-reconciles
186
+ after a plain `git branch -m` (the per-branch git config is rename-
187
+ proof, so the next gh-stack command heals the name-keyed topology).
188
+ Renaming a branch that already has a PR is local-only — the GitHub PR
189
+ still tracks the old remote branch.
190
+
152
191
  delete [<branch>] [-k|--keep-branch] [--no-remote]
153
192
  Remove a branch from the stack and re-parent its children, then
154
193
  delete the underlying local git branch (and the remote branch if
@@ -164,8 +203,25 @@ log
164
203
  Display the current stack as a tree with branch numbers,
165
204
  PR info, and descriptions. This is the default command.
166
205
 
206
+ update-prs [--force]
207
+ Refresh the "📚 Stacked on" visualization block in every PR
208
+ description for the current stack. PRs whose rendered block is
209
+ unchanged are skipped; --force rewrites all of them. (submit does
210
+ this automatically — use this to refresh without pushing.)
211
+
212
+ stacks [--current] [--json]
213
+ List all stacks and their topology. Read-only and network-free —
214
+ emits stack membership straight from local metadata, so external
215
+ tools (status scripts, dashboards) can consume --json as a stable
216
+ interface instead of parsing the .git/.gh-stack/ store directly.
217
+ --current Only the stack containing the current branch
218
+ --json Topology as JSON: { current_stack, current_branch,
219
+ stacks: [{ name, description, base, is_current,
220
+ branches: [{ branch, parent, pr, description }] }] }
221
+
167
222
  status [--current] [--json]
168
223
  PR dashboard showing review state, CI status, and merge readiness.
224
+ Unlike `stacks`, this hits the network to fetch live PR/CI status.
169
225
  --current Show only the current stack or standalone PR
170
226
  --json Structured JSON output (progress goes to stderr)
171
227
 
@@ -174,6 +230,11 @@ undo
174
230
 
175
231
  archive [--restore <name>]
176
232
  List archived stacks by default, or restore one by name.
233
+
234
+ doctor
235
+ Migrate old (v2) metadata to the v3 layout, reconcile git branch
236
+ config against the topology files, and flag stacks whose base
237
+ stack appears already-merged into main. Safe to run repeatedly.
177
238
  ```
178
239
 
179
240
  ## Global Options
@@ -211,47 +272,92 @@ Snapshots also power `gh-stack undo`, so the same data structure does double dut
211
272
 
212
273
  ### Stack Visualization
213
274
 
214
- `submit` automatically adds a stack section to all PR descriptions:
275
+ `submit` automatically adds a stack section to all PR descriptions, with each
276
+ branch numbered by its position in the stack (handy for spotting "which one is
277
+ PR 6 of 12"):
215
278
 
216
279
  ```
217
280
  ### 📚 Stacked on
218
281
  ⚫ main
219
282
  ┃
220
- ┣━ ✅ #123 Backend models
283
+ ┣━ 1. #123 Backend models
221
284
  ┃
222
- ┗━ ⏳ #124 Frontend UI 👈
285
+ ┗━ 2. #124 Frontend UI 👈
223
286
  ```
224
287
 
225
- ### Metadata
288
+ The block is rendered entirely from local metadata plus one repo-identity
289
+ lookup for links — no per-PR API call — and `submit` only rewrites a PR
290
+ description whose block actually changed (membership/order/title), so a
291
+ re-submit that touched one branch on a 12-PR stack does ~1 update, not 12.
292
+ Review/CI status is intentionally not shown here. Run `gh-stack update-prs`
293
+ (`--force` to rewrite all) to refresh manually.
294
+
295
+ ### Metadata (v3)
226
296
 
227
- Stack metadata lives at `.git/gh-stack-metadata.json` (never committed):
297
+ Stack metadata lives under `.git/.gh-stack/` (never committed) — a folder of
298
+ per-stack files plus git-native branch config, rather than a single JSON blob:
299
+
300
+ ```
301
+ .git/.gh-stack/
302
+ current hint: last-active stack name
303
+ active/<stack>.json topology of a live stack (ordered branches, base, description)
304
+ archived/<stack>.json merged/closed stacks
305
+ deleted/<stack>.json tombstones (recoverable)
306
+ snapshots/<ts>__<stack>.json append-only, retained per-stack
307
+ restack-state.json in-flight restack/sync resume state
308
+ ```
309
+
310
+ A single `active/<stack>.json` looks like:
228
311
 
229
312
  ```json
230
313
  {
231
- "version": 2,
232
- "current_stack": "kiliman/feature-2",
233
- "stacks": {
234
- "kiliman/feature-2": {
235
- "description": "",
236
- "last_branch": "kiliman/feature-2",
237
- "branches": {
238
- "kiliman/feature-1": {
239
- "parent": "main",
240
- "pr": 21729
241
- },
242
- "kiliman/feature-2": {
243
- "parent": "kiliman/feature-1",
244
- "pr": 21730
245
- }
246
- }
247
- }
314
+ "description": "",
315
+ "last_branch": "kiliman/feature-2",
316
+ "base": "main",
317
+ "branches": {
318
+ "kiliman/feature-1": { "parent": "main", "pr": 21729 },
319
+ "kiliman/feature-2": { "parent": "kiliman/feature-1", "pr": 21730 }
248
320
  }
249
321
  }
250
322
  ```
251
323
 
324
+ Per-branch membership is *also* recorded in git's own config, so renaming or
325
+ deleting a branch updates/cleans it automatically:
326
+
327
+ ```
328
+ branch.<name>.ghstack-stack <stack>
329
+ branch.<name>.ghstack-parent <parent-branch>
330
+ branch.<name>.ghstack-pr <number>
331
+ branch.<name>.ghstack-id <uuid> stable identity (survives rename)
332
+ ```
333
+
334
+ The two representations cross-check each other; `gh-stack doctor` reconciles
335
+ any drift. Why this shape:
336
+
337
+ - **A stack can't silently vanish** — lifecycle transitions are file moves
338
+ (`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
339
+ just unlinked.
340
+ - **No `current_stack` drift** — the stack you're on is derivable from the
341
+ branch's own config, so a written pointer can't contradict reality.
342
+ - **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
343
+ - **Renames heal themselves** — `git branch -m old new` moves the whole
344
+ `branch.<old>` config section to `branch.<new>` (id and all), but the JSON is
345
+ keyed by name and goes stale. On the next command, gh-stack matches the moved
346
+ config to its stale JSON entry by `ghstack-id` (or PR number for older
347
+ branches), re-keys it, and re-points any children. So `gh-stack rename` — or
348
+ even a raw `git branch -m` — never needs manual metadata surgery.
349
+
350
+ #### Migrating from v2
351
+
352
+ Repos created before v3 store a single `.git/gh-stack-metadata.json`. Run
353
+ `gh-stack doctor` once — it fans the monolith out into the layout above,
354
+ backfills branch config, explodes snapshots into per-file records, and keeps a
355
+ `.bak` of the old file. Commands refuse to run on unmigrated metadata and point
356
+ you at `doctor`.
357
+
252
358
  ### Snapshots
253
359
 
254
- Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs. Run `gh-stack undo` to restore.
360
+ 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.
255
361
 
256
362
  ## Example Workflow
257
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.7.1",
3
+ "version": "0.10.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,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:`);