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.
@@ -1,83 +1,377 @@
1
- // Read/write/validate .git/gh-stack-metadata.json
2
- import type { StackMetadata, StackMetadataV1, Stack, Branch, RestackState } from "../types.ts";
3
- import { gitDir } from "./git.ts";
1
+ // v3 metadata: assemble from / fan out to `.git/.gh-stack/`.
2
+ //
3
+ // The in-memory `StackMetadata` shape is unchanged from v2 — every command
4
+ // still reads `meta.stacks[...]` / `meta.current_stack` and calls
5
+ // `writeMetadata(meta)`. Only the on-disk representation changed: instead of
6
+ // one monolithic JSON file, each stack is its own file and per-branch
7
+ // membership additionally lives in git config (see ./branch-config.ts).
8
+ //
9
+ // Two structural guarantees fall out of per-stack files:
10
+ // • A bad write can't corrupt other stacks (no all-or-nothing blast radius).
11
+ // • A stack can never silently vanish — on write, a stale stack file is moved
12
+ // to `deleted/` (a recoverable tombstone), never just unlinked.
13
+ import type { StackMetadata, Stack, Branch, RestackState } from "../types.ts";
14
+ import { mkdir, readdir, rename, unlink, stat } from "node:fs/promises";
15
+ import {
16
+ ghStackDir,
17
+ activeDir,
18
+ archivedDir,
19
+ deletedDir,
20
+ snapshotsDir,
21
+ currentFile,
22
+ restackStateFile,
23
+ legacyMetadataPath,
24
+ stackFileName,
25
+ stackNameFromFile,
26
+ } from "./paths.ts";
27
+ import {
28
+ allBranchMemberships,
29
+ setBranchMembership,
30
+ clearBranchMembership,
31
+ type BranchMembership,
32
+ } from "./branch-config.ts";
33
+ import { loadSnapshots } from "./snapshot.ts";
34
+
35
+ async function pathExists(p: string): Promise<boolean> {
36
+ try {
37
+ await stat(p);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /** Create the `.gh-stack/` folder and its subdirectories if missing. */
45
+ async function ensureDirs(): Promise<void> {
46
+ for (const dir of [
47
+ await activeDir(),
48
+ await archivedDir(),
49
+ await deletedDir(),
50
+ await snapshotsDir(),
51
+ ]) {
52
+ await mkdir(dir, { recursive: true });
53
+ }
54
+ }
55
+
56
+ /** True if the v3 store (`.git/.gh-stack/`) exists. */
57
+ export async function v3Exists(): Promise<boolean> {
58
+ return pathExists(await ghStackDir());
59
+ }
60
+
61
+ /** True if the legacy v2 monolith exists and hasn't been migrated yet. */
62
+ export async function legacyMetadataExists(): Promise<boolean> {
63
+ return Bun.file(await legacyMetadataPath()).exists();
64
+ }
65
+
66
+ /**
67
+ * Whether usable metadata exists. In v3 this means the `.gh-stack/` store is
68
+ * present. (A bare legacy file is intentionally NOT counted — it must be
69
+ * migrated via `gh-stack doctor` first; the gate in safety.ts surfaces that.)
70
+ */
71
+ export async function metadataExists(): Promise<boolean> {
72
+ return v3Exists();
73
+ }
4
74
 
5
- let cachedGitDir: string | null = null;
75
+ export async function restackStatePath(): Promise<string> {
76
+ return restackStateFile();
77
+ }
6
78
 
7
- async function getGitDir(): Promise<string> {
8
- if (!cachedGitDir) {
9
- cachedGitDir = await gitDir();
79
+ /**
80
+ * Read one directory of `<stack>.json` files into a name→Stack map. Files that
81
+ * fail to parse are skipped (doctor reports them) rather than aborting a read.
82
+ */
83
+ async function readStackDir(dir: string): Promise<Record<string, Stack>> {
84
+ const out: Record<string, Stack> = {};
85
+ let entries: string[];
86
+ try {
87
+ entries = await readdir(dir);
88
+ } catch {
89
+ return out;
90
+ }
91
+ for (const file of entries) {
92
+ if (!file.endsWith(".json")) continue;
93
+ try {
94
+ const stack = (await Bun.file(`${dir}/${file}`).json()) as Stack;
95
+ out[stackNameFromFile(file)] = stack;
96
+ } catch {
97
+ // Malformed stack file — skip; `doctor` will flag it.
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ async function readCurrent(): Promise<string | null> {
104
+ try {
105
+ const text = (await Bun.file(await currentFile()).text()).trim();
106
+ return text.length > 0 ? text : null;
107
+ } catch {
108
+ return null;
10
109
  }
11
- return cachedGitDir;
12
110
  }
13
111
 
14
112
  /**
15
- * Get the path to the metadata file.
113
+ * Read and assemble metadata from `.git/.gh-stack/`.
114
+ * Returns null if the store doesn't exist.
16
115
  */
17
- export async function metadataPath(): Promise<string> {
18
- const dir = await getGitDir();
19
- return `${dir}/gh-stack-metadata.json`;
116
+ export async function readMetadata(): Promise<StackMetadata | null> {
117
+ if (!(await v3Exists())) return null;
118
+ await ensureDirs();
119
+
120
+ const stacks = await readStackDir(await activeDir());
121
+ const archive = await readStackDir(await archivedDir());
122
+ const current = await readCurrent();
123
+ const snapshots = await loadSnapshots();
124
+
125
+ const meta: StackMetadata = {
126
+ version: 3,
127
+ current_stack: current,
128
+ stacks,
129
+ };
130
+ if (Object.keys(archive).length > 0) meta.archive = archive;
131
+ if (snapshots.length > 0) meta.snapshots = snapshots;
132
+
133
+ backfillStackBase(meta);
134
+ // Catch the name-keyed JSON up to any `git branch -m` since the last write.
135
+ // In-memory only — the fix persists the next time a command writes metadata
136
+ // (which also repoints renamed branches' child config). Keeping reads
137
+ // side-effect-free avoids surprising writes from `status`/`--dry-run`.
138
+ await reconcileRenames(meta);
139
+ return meta;
20
140
  }
21
141
 
22
142
  /**
23
- * Get the path to the restack state file.
143
+ * The ref a stack is rooted on. Defaults to "main" for stacks created before
144
+ * the `base` field existed.
24
145
  */
25
- export async function restackStatePath(): Promise<string> {
26
- const dir = await getGitDir();
27
- return `${dir}/.gh-stack-sync-state`;
146
+ export function stackBase(stack: Stack): string {
147
+ return stack.base ?? "main";
148
+ }
149
+
150
+ /** Ensure every stack has an explicit `base` (in-memory only). */
151
+ function backfillStackBase(meta: StackMetadata): void {
152
+ for (const stack of Object.values(meta.stacks)) {
153
+ if (stack.base == null) stack.base = "main";
154
+ }
155
+ for (const stack of Object.values(meta.archive ?? {})) {
156
+ if (stack.base == null) stack.base = "main";
157
+ }
158
+ }
159
+
160
+ function serializeStack(stack: Stack): string {
161
+ const out: Stack = {
162
+ description: stack.description,
163
+ last_branch: stack.last_branch,
164
+ base: stackBase(stack),
165
+ branches: stack.branches,
166
+ };
167
+ return JSON.stringify(out, null, 2) + "\n";
28
168
  }
29
169
 
30
170
  /**
31
- * Check if metadata file exists.
171
+ * Fan `meta` out to disk: write each active/archived stack to its own file,
172
+ * tombstone any stack file that's no longer present, and reconcile git branch
173
+ * config to match active membership.
32
174
  */
33
- export async function metadataExists(): Promise<boolean> {
34
- const path = await metadataPath();
35
- return Bun.file(path).exists();
175
+ export async function writeMetadata(meta: StackMetadata): Promise<void> {
176
+ await ensureDirs();
177
+
178
+ // 0. stamp a stable id on any active branch that lacks one, so a later
179
+ // `git branch -m` can be recognized even before the branch has a PR.
180
+ ensureBranchIds(meta);
181
+
182
+ // 1. current hint
183
+ await Bun.write(await currentFile(), (meta.current_stack ?? "") + "\n");
184
+
185
+ const aDir = await activeDir();
186
+ const arDir = await archivedDir();
187
+
188
+ // 2. write active + archived stack files (write desired state BEFORE
189
+ // removing anything — that's what makes "can't vanish" hold).
190
+ for (const [name, stack] of Object.entries(meta.stacks)) {
191
+ await Bun.write(`${aDir}/${stackFileName(name)}`, serializeStack(stack));
192
+ }
193
+ for (const [name, stack] of Object.entries(meta.archive ?? {})) {
194
+ await Bun.write(`${arDir}/${stackFileName(name)}`, serializeStack(stack));
195
+ }
196
+
197
+ // 3. reconcile stale files. A name that moved to the other category (active
198
+ // ↔ archived) is just unlinked here; one that left both is tombstoned.
199
+ const activeNames = new Set(Object.keys(meta.stacks));
200
+ const archivedNames = new Set(Object.keys(meta.archive ?? {}));
201
+ await reconcileStale(aDir, activeNames, (name) => archivedNames.has(name));
202
+ await reconcileStale(arDir, archivedNames, (name) => activeNames.has(name));
203
+
204
+ // 4. mirror active membership into git config
205
+ await syncBranchConfig(meta);
36
206
  }
37
207
 
38
208
  /**
39
- * Read and parse metadata, with auto-migration from v1.
40
- * Returns null if file doesn't exist.
209
+ * Remove stack files in `dir` whose names aren't in `keep`. A file whose name
210
+ * lives in the other lifecycle bucket (`movedElsewhere`) is unlinked; anything
211
+ * else is moved to `deleted/` as a recoverable tombstone.
41
212
  */
42
- export async function readMetadata(): Promise<StackMetadata | null> {
43
- const path = await metadataPath();
44
- const file = Bun.file(path);
213
+ async function reconcileStale(
214
+ dir: string,
215
+ keep: Set<string>,
216
+ movedElsewhere: (name: string) => boolean,
217
+ ): Promise<void> {
218
+ let entries: string[];
219
+ try {
220
+ entries = await readdir(dir);
221
+ } catch {
222
+ return;
223
+ }
224
+ for (const file of entries) {
225
+ if (!file.endsWith(".json")) continue;
226
+ const name = stackNameFromFile(file);
227
+ if (keep.has(name)) continue;
228
+
229
+ const from = `${dir}/${file}`;
230
+ if (movedElsewhere(name)) {
231
+ await unlink(from).catch(() => {});
232
+ } else {
233
+ // Tombstone rather than delete — a stack must never just disappear.
234
+ const to = `${await deletedDir()}/${file}`;
235
+ await rename(from, to).catch(() => unlink(from).catch(() => {}));
236
+ }
237
+ }
238
+ }
45
239
 
46
- if (!(await file.exists())) {
47
- return null;
240
+ /**
241
+ * Reconcile git branch config so it exactly mirrors active-stack membership.
242
+ * Only branches whose recorded membership differs are touched, to avoid
243
+ * shelling out to `git config` for every branch on every write.
244
+ */
245
+ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
246
+ const desired = new Map<string, BranchMembership>();
247
+ for (const [stackName, stack] of Object.entries(meta.stacks)) {
248
+ for (const [branchName, b] of Object.entries(stack.branches)) {
249
+ desired.set(branchName, { stack: stackName, parent: b.parent, pr: b.pr, id: b.id });
250
+ }
251
+ }
252
+
253
+ const existing = await allBranchMemberships();
254
+
255
+ for (const [branch, m] of desired) {
256
+ const cur = existing.get(branch);
257
+ if (
258
+ !cur ||
259
+ cur.stack !== m.stack ||
260
+ cur.parent !== m.parent ||
261
+ cur.pr !== m.pr ||
262
+ cur.id !== m.id
263
+ ) {
264
+ await setBranchMembership(branch, m);
265
+ }
266
+ }
267
+
268
+ // Branches that still carry gh-stack config but are no longer active members.
269
+ for (const branch of existing.keys()) {
270
+ if (!desired.has(branch)) await clearBranchMembership(branch);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Stamp a stable UUID on every active branch that lacks one. Mutates `meta`;
276
+ * the ids persist via the normal stack-file write and get mirrored into git
277
+ * config by `syncBranchConfig`. A branch's id is its rename anchor.
278
+ */
279
+ function ensureBranchIds(meta: StackMetadata): void {
280
+ for (const stack of Object.values(meta.stacks)) {
281
+ for (const b of Object.values(stack.branches)) {
282
+ if (!b.id) b.id = crypto.randomUUID();
283
+ }
48
284
  }
285
+ }
49
286
 
50
- const raw = await file.json();
51
-
52
- // Auto-migrate v1 -> v2
53
- if (!raw.version) {
54
- const v1 = raw as StackMetadataV1;
55
- const v2: StackMetadata = {
56
- version: 2,
57
- current_stack: v1.current_stack,
58
- stacks: v1.stacks,
59
- };
60
- await writeMetadata(v2);
61
- return v2;
287
+ /**
288
+ * Reconcile the name-keyed JSON after a branch was renamed with `git branch -m`.
289
+ *
290
+ * Git moves a branch's entire `branch.<name>` config section on rename, so the
291
+ * membership config is rename-proof and authoritative for identity; only the
292
+ * JSON (keyed by branch name) drifts. For each branch the config currently knows
293
+ * by name N, if the JSON has no entry under N but does have one — in the same
294
+ * stack — matching N's stable id (or, for legacy/pre-id branches, its PR
295
+ * number), that entry was renamed: re-key it to N and repoint anything that
296
+ * referenced the old name.
297
+ *
298
+ * Mutates `meta` in place; returns true if anything changed. The change persists
299
+ * on the next `writeMetadata`, which also repoints the renamed branch's child
300
+ * `ghstack-parent` config (git rewrites section names, not string values).
301
+ */
302
+ async function reconcileRenames(meta: StackMetadata): Promise<boolean> {
303
+ const config = await allBranchMemberships();
304
+ if (config.size === 0) return false;
305
+
306
+ let changed = false;
307
+ for (const [currentName, m] of config) {
308
+ if (!m.stack) continue;
309
+ const stack = meta.stacks[m.stack];
310
+ if (!stack) continue;
311
+ if (stack.branches[currentName]) continue; // JSON already keyed correctly
312
+
313
+ const oldKey = findStaleKey(stack, m);
314
+ if (!oldKey || oldKey === currentName) continue;
315
+
316
+ renameBranchKey(meta, m.stack, oldKey, currentName);
317
+ changed = true;
62
318
  }
319
+ return changed;
320
+ }
63
321
 
64
- return raw as StackMetadata;
322
+ /** Find a stack's branch entry by stable id (preferred) or PR number (fallback). */
323
+ function findStaleKey(stack: Stack, m: BranchMembership): string | null {
324
+ if (m.id) {
325
+ for (const [name, b] of Object.entries(stack.branches)) {
326
+ if (b.id === m.id) return name;
327
+ }
328
+ }
329
+ if (m.pr != null) {
330
+ for (const [name, b] of Object.entries(stack.branches)) {
331
+ if (b.pr === m.pr) return name;
332
+ }
333
+ }
334
+ return null;
65
335
  }
66
336
 
67
337
  /**
68
- * Write metadata to disk.
338
+ * Re-key a branch from `oldKey` to `newKey` and repoint every reference to the
339
+ * old name: the stack's `last_branch`, and any branch's `parent` or stack's
340
+ * `base` (across active AND archived stacks — a split stack roots on a branch
341
+ * that belongs to its parent stack).
69
342
  */
70
- export async function writeMetadata(data: StackMetadata): Promise<void> {
71
- const path = await metadataPath();
72
- await Bun.write(path, JSON.stringify(data, null, 2) + "\n");
343
+ function renameBranchKey(
344
+ meta: StackMetadata,
345
+ stackName: string,
346
+ oldKey: string,
347
+ newKey: string,
348
+ ): void {
349
+ const stack = meta.stacks[stackName];
350
+ if (!stack) return;
351
+ const entry = stack.branches[oldKey];
352
+ if (!entry) return;
353
+
354
+ stack.branches[newKey] = entry;
355
+ delete stack.branches[oldKey];
356
+ if (stack.last_branch === oldKey) stack.last_branch = newKey;
357
+
358
+ for (const group of [meta.stacks, meta.archive ?? {}]) {
359
+ for (const s of Object.values(group)) {
360
+ if (s.base === oldKey) s.base = newKey;
361
+ for (const b of Object.values(s.branches)) {
362
+ if (b.parent === oldKey) b.parent = newKey;
363
+ }
364
+ }
365
+ }
73
366
  }
74
367
 
75
368
  /**
76
- * Initialize an empty metadata file.
369
+ * Initialize an empty v3 store.
77
370
  */
78
371
  export async function initMetadata(): Promise<StackMetadata> {
372
+ await ensureDirs();
79
373
  const data: StackMetadata = {
80
- version: 2,
374
+ version: 3,
81
375
  current_stack: null,
82
376
  stacks: {},
83
377
  };
@@ -123,11 +417,13 @@ export async function createStack(
123
417
  meta: StackMetadata,
124
418
  name: string,
125
419
  description: string,
420
+ base: string = "main",
126
421
  ): Promise<StackMetadata> {
127
422
  meta.stacks[name] = {
128
423
  description,
129
424
  last_branch: null,
130
425
  branches: {},
426
+ base,
131
427
  };
132
428
  meta.current_stack = name;
133
429
  await writeMetadata(meta);
@@ -184,6 +480,11 @@ export async function removeBranchFromStack(
184
480
  // Remove the branch
185
481
  delete stack.branches[branchName];
186
482
 
483
+ // The removed branch may still exist in git but is no longer a member —
484
+ // drop its membership config explicitly (syncBranchConfig also would, but
485
+ // be direct since the branch is gone from `meta`).
486
+ await clearBranchMembership(branchName);
487
+
187
488
  // Update last_branch if it was the removed one
188
489
  if (stack.last_branch === branchName) {
189
490
  const remaining = Object.keys(stack.branches);
@@ -212,7 +513,7 @@ export function getOrderedBranches(stack: Stack): string[] {
212
513
  }
213
514
  }
214
515
 
215
- addChildrenOf("main");
516
+ addChildrenOf(stackBase(stack));
216
517
  return ordered;
217
518
  }
218
519
 
@@ -274,8 +575,8 @@ export async function updateLastBranch(
274
575
  * Save restack state for resume.
275
576
  */
276
577
  export async function saveRestackState(state: RestackState): Promise<void> {
277
- const path = await restackStatePath();
278
- await Bun.write(path, JSON.stringify(state, null, 2) + "\n");
578
+ await ensureDirs();
579
+ await Bun.write(await restackStateFile(), JSON.stringify(state, null, 2) + "\n");
279
580
  }
280
581
 
281
582
  /**
@@ -283,13 +584,10 @@ export async function saveRestackState(state: RestackState): Promise<void> {
283
584
  * Returns null if no state file exists.
284
585
  */
285
586
  export async function loadRestackState(): Promise<RestackState | null> {
286
- const path = await restackStatePath();
287
- const file = Bun.file(path);
288
-
587
+ const file = Bun.file(await restackStateFile());
289
588
  if (!(await file.exists())) {
290
589
  return null;
291
590
  }
292
-
293
591
  return file.json();
294
592
  }
295
593
 
@@ -297,11 +595,8 @@ export async function loadRestackState(): Promise<RestackState | null> {
297
595
  * Clear restack state file.
298
596
  */
299
597
  export async function clearRestackState(): Promise<void> {
300
- const path = await restackStatePath();
301
- const file = Bun.file(path);
302
-
303
- if (await file.exists()) {
304
- const { unlink } = await import("node:fs/promises");
305
- await unlink(path);
598
+ const path = await restackStateFile();
599
+ if (await Bun.file(path).exists()) {
600
+ await unlink(path).catch(() => {});
306
601
  }
307
602
  }
@@ -0,0 +1,69 @@
1
+ // Filesystem layout for v3 metadata, all under `.git/.gh-stack/`.
2
+ //
3
+ // .git/.gh-stack/
4
+ // current hint: last-active stack name (a cache, not truth)
5
+ // active/<stack>.json topology of a live stack
6
+ // archived/<stack>.json merged/closed stacks
7
+ // deleted/<stack>.json tombstones (recoverable)
8
+ // snapshots/<ts>__<stack>.json append-only pre-operation branch tips
9
+ // restack-state.json in-flight restack/sync resume state
10
+ //
11
+ // Everything lives inside `.git/` and is never committed — gh-stack is a
12
+ // single-developer tool, so there's no need to share metadata across clones.
13
+ import { gitDir } from "./git.ts";
14
+
15
+ let cachedGitDir: string | null = null;
16
+
17
+ async function getGitDir(): Promise<string> {
18
+ if (!cachedGitDir) cachedGitDir = await gitDir();
19
+ return cachedGitDir;
20
+ }
21
+
22
+ /** Root of the v3 metadata folder. */
23
+ export async function ghStackDir(): Promise<string> {
24
+ return `${await getGitDir()}/.gh-stack`;
25
+ }
26
+
27
+ export async function activeDir(): Promise<string> {
28
+ return `${await ghStackDir()}/active`;
29
+ }
30
+ export async function archivedDir(): Promise<string> {
31
+ return `${await ghStackDir()}/archived`;
32
+ }
33
+ export async function deletedDir(): Promise<string> {
34
+ return `${await ghStackDir()}/deleted`;
35
+ }
36
+ export async function snapshotsDir(): Promise<string> {
37
+ return `${await ghStackDir()}/snapshots`;
38
+ }
39
+ export async function currentFile(): Promise<string> {
40
+ return `${await ghStackDir()}/current`;
41
+ }
42
+ export async function restackStateFile(): Promise<string> {
43
+ return `${await ghStackDir()}/restack-state.json`;
44
+ }
45
+
46
+ /** Legacy v2 monolith path (pre-migration). */
47
+ export async function legacyMetadataPath(): Promise<string> {
48
+ return `${await getGitDir()}/gh-stack-metadata.json`;
49
+ }
50
+
51
+ /** Legacy v2 restack-state path (pre-migration). */
52
+ export async function legacyRestackStatePath(): Promise<string> {
53
+ return `${await getGitDir()}/.gh-stack-sync-state`;
54
+ }
55
+
56
+ // Stack names come from branch names, which can contain `/` and other chars
57
+ // that aren't filesystem-safe. Encode to a flat, reversible filename.
58
+ export function stackFileName(stack: string): string {
59
+ return `${encodeURIComponent(stack)}.json`;
60
+ }
61
+
62
+ export function stackNameFromFile(file: string): string {
63
+ return decodeURIComponent(file.replace(/\.json$/, ""));
64
+ }
65
+
66
+ // For test isolation: forget the memoized git dir.
67
+ export function resetGitDirCache(): void {
68
+ cachedGitDir = null;
69
+ }
package/src/lib/safety.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  // Pre-flight safety checks
2
2
  import * as git from "./git.ts";
3
- import { metadataExists, readMetadata, findStackForBranch } from "./metadata.ts";
3
+ import {
4
+ metadataExists,
5
+ legacyMetadataExists,
6
+ readMetadata,
7
+ findStackForBranch,
8
+ stackBase,
9
+ } from "./metadata.ts";
4
10
  import type { StackMetadata, Stack } from "../types.ts";
5
11
  import * as p from "./output.ts";
6
12
  import pc from "picocolors";
@@ -16,10 +22,26 @@ export async function ensureGitRepo(): Promise<void> {
16
22
  }
17
23
 
18
24
  /**
19
- * Ensure metadata file exists. Returns the metadata.
25
+ * If no v3 store exists but a legacy v2 monolith does, halt and point the user
26
+ * at `gh-stack doctor`. Call this from any command that checks for metadata
27
+ * directly (i.e. doesn't go through ensureMetadata) so the migration gate is
28
+ * uniform. No-op when there's no legacy file to migrate.
20
29
  */
30
+ export async function gateLegacyMetadata(): Promise<void> {
31
+ if (await metadataExists()) return;
32
+ if (await legacyMetadataExists()) {
33
+ p.cancel(
34
+ `Stack metadata is in the old format and needs migrating.\n\n Run:\n ${pc.green("gh-stack doctor")}`,
35
+ );
36
+ process.exit(1);
37
+ }
38
+ }
39
+
21
40
  export async function ensureMetadata(): Promise<StackMetadata> {
22
41
  if (!(await metadataExists())) {
42
+ // A v2 monolith still on disk means this repo predates v3 — gate behind an
43
+ // explicit one-time migration rather than silently reading the old format.
44
+ await gateLegacyMetadata();
23
45
  p.cancel(
24
46
  `No stack metadata found.\n\n Create your first stack with:\n ${pc.green("gh-stack init")}`,
25
47
  );
@@ -106,6 +128,7 @@ export async function validateStack(meta: StackMetadata, stackName: string): Pro
106
128
 
107
129
  const branchNames = Object.keys(stack.branches);
108
130
  const errors: string[] = [];
131
+ const base = stackBase(stack);
109
132
 
110
133
  for (const branchName of branchNames) {
111
134
  const branch = stack.branches[branchName]!;
@@ -118,7 +141,10 @@ export async function validateStack(meta: StackMetadata, stackName: string): Pro
118
141
  errors.push(`Branch "${branchName}" cannot be its own parent`);
119
142
  }
120
143
 
121
- if (branch.parent !== "main" && branch.parent !== "master" && !stack.branches[branch.parent]) {
144
+ // A branch's parent must be either the stack's base (the root branch) or
145
+ // another branch in the stack. For a split stack the base is a branch in
146
+ // the parent stack, which legitimately lives outside `stack.branches`.
147
+ if (branch.parent !== base && !stack.branches[branch.parent]) {
122
148
  errors.push(`Branch "${branchName}" has unknown parent "${branch.parent}"`);
123
149
  }
124
150
  }
@@ -138,7 +164,7 @@ export async function ensureValidStack(meta: StackMetadata, stackName: string):
138
164
  }
139
165
  console.log();
140
166
  console.log(
141
- pc.dim("Fix .git/gh-stack-metadata.json or repair the local branches before retrying."),
167
+ pc.dim(`Repair the local branches, or run ${pc.green("gh-stack doctor")}, before retrying.`),
142
168
  );
143
169
  process.exit(1);
144
170
  }
@@ -148,13 +174,13 @@ function validateStackGraph(stack: Stack): string[] {
148
174
  if (branchNames.length === 0) return [];
149
175
 
150
176
  const errors: string[] = [];
177
+ const base = stackBase(stack);
151
178
  const roots = branchNames.filter((branchName) => {
152
- const parent = stack.branches[branchName]!.parent;
153
- return parent === "main" || parent === "master";
179
+ return stack.branches[branchName]!.parent === base;
154
180
  });
155
181
 
156
182
  if (roots.length === 0) {
157
- errors.push("Stack has no root branch with parent main/master");
183
+ errors.push(`Stack has no root branch with parent "${base}"`);
158
184
  }
159
185
  if (roots.length > 1) {
160
186
  errors.push(`Stack must have exactly one root branch; found ${roots.length}`);