gh-stack 0.7.1 → 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.
@@ -1,83 +1,268 @@
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";
4
-
5
- let cachedGitDir: string | null = null;
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
+ }
6
43
 
7
- async function getGitDir(): Promise<string> {
8
- if (!cachedGitDir) {
9
- cachedGitDir = await gitDir();
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 });
10
53
  }
11
- return cachedGitDir;
12
54
  }
13
55
 
14
- /**
15
- * Get the path to the metadata file.
16
- */
17
- export async function metadataPath(): Promise<string> {
18
- const dir = await getGitDir();
19
- return `${dir}/gh-stack-metadata.json`;
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();
20
64
  }
21
65
 
22
66
  /**
23
- * Get the path to the restack state file.
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.)
24
70
  */
71
+ export async function metadataExists(): Promise<boolean> {
72
+ return v3Exists();
73
+ }
74
+
25
75
  export async function restackStatePath(): Promise<string> {
26
- const dir = await getGitDir();
27
- return `${dir}/.gh-stack-sync-state`;
76
+ return restackStateFile();
28
77
  }
29
78
 
30
79
  /**
31
- * Check if metadata file exists.
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.
32
82
  */
33
- export async function metadataExists(): Promise<boolean> {
34
- const path = await metadataPath();
35
- return Bun.file(path).exists();
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;
109
+ }
36
110
  }
37
111
 
38
112
  /**
39
- * Read and parse metadata, with auto-migration from v1.
40
- * Returns null if file doesn't exist.
113
+ * Read and assemble metadata from `.git/.gh-stack/`.
114
+ * Returns null if the store doesn't exist.
41
115
  */
42
116
  export async function readMetadata(): Promise<StackMetadata | null> {
43
- const path = await metadataPath();
44
- const file = Bun.file(path);
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;
45
132
 
46
- if (!(await file.exists())) {
47
- return null;
133
+ backfillStackBase(meta);
134
+ return meta;
135
+ }
136
+
137
+ /**
138
+ * The ref a stack is rooted on. Defaults to "main" for stacks created before
139
+ * the `base` field existed.
140
+ */
141
+ export function stackBase(stack: Stack): string {
142
+ return stack.base ?? "main";
143
+ }
144
+
145
+ /** Ensure every stack has an explicit `base` (in-memory only). */
146
+ function backfillStackBase(meta: StackMetadata): void {
147
+ for (const stack of Object.values(meta.stacks)) {
148
+ if (stack.base == null) stack.base = "main";
149
+ }
150
+ for (const stack of Object.values(meta.archive ?? {})) {
151
+ if (stack.base == null) stack.base = "main";
48
152
  }
153
+ }
49
154
 
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;
155
+ function serializeStack(stack: Stack): string {
156
+ const out: Stack = {
157
+ description: stack.description,
158
+ last_branch: stack.last_branch,
159
+ base: stackBase(stack),
160
+ branches: stack.branches,
161
+ };
162
+ return JSON.stringify(out, null, 2) + "\n";
163
+ }
164
+
165
+ /**
166
+ * Fan `meta` out to disk: write each active/archived stack to its own file,
167
+ * tombstone any stack file that's no longer present, and reconcile git branch
168
+ * config to match active membership.
169
+ */
170
+ export async function writeMetadata(meta: StackMetadata): Promise<void> {
171
+ await ensureDirs();
172
+
173
+ // 1. current hint
174
+ await Bun.write(await currentFile(), (meta.current_stack ?? "") + "\n");
175
+
176
+ const aDir = await activeDir();
177
+ const arDir = await archivedDir();
178
+
179
+ // 2. write active + archived stack files (write desired state BEFORE
180
+ // removing anything — that's what makes "can't vanish" hold).
181
+ for (const [name, stack] of Object.entries(meta.stacks)) {
182
+ await Bun.write(`${aDir}/${stackFileName(name)}`, serializeStack(stack));
183
+ }
184
+ for (const [name, stack] of Object.entries(meta.archive ?? {})) {
185
+ await Bun.write(`${arDir}/${stackFileName(name)}`, serializeStack(stack));
62
186
  }
63
187
 
64
- return raw as StackMetadata;
188
+ // 3. reconcile stale files. A name that moved to the other category (active
189
+ // ↔ archived) is just unlinked here; one that left both is tombstoned.
190
+ const activeNames = new Set(Object.keys(meta.stacks));
191
+ const archivedNames = new Set(Object.keys(meta.archive ?? {}));
192
+ await reconcileStale(aDir, activeNames, (name) => archivedNames.has(name));
193
+ await reconcileStale(arDir, archivedNames, (name) => activeNames.has(name));
194
+
195
+ // 4. mirror active membership into git config
196
+ await syncBranchConfig(meta);
197
+ }
198
+
199
+ /**
200
+ * Remove stack files in `dir` whose names aren't in `keep`. A file whose name
201
+ * lives in the other lifecycle bucket (`movedElsewhere`) is unlinked; anything
202
+ * else is moved to `deleted/` as a recoverable tombstone.
203
+ */
204
+ async function reconcileStale(
205
+ dir: string,
206
+ keep: Set<string>,
207
+ movedElsewhere: (name: string) => boolean,
208
+ ): Promise<void> {
209
+ let entries: string[];
210
+ try {
211
+ entries = await readdir(dir);
212
+ } catch {
213
+ return;
214
+ }
215
+ for (const file of entries) {
216
+ if (!file.endsWith(".json")) continue;
217
+ const name = stackNameFromFile(file);
218
+ if (keep.has(name)) continue;
219
+
220
+ const from = `${dir}/${file}`;
221
+ if (movedElsewhere(name)) {
222
+ await unlink(from).catch(() => {});
223
+ } else {
224
+ // Tombstone rather than delete — a stack must never just disappear.
225
+ const to = `${await deletedDir()}/${file}`;
226
+ await rename(from, to).catch(() => unlink(from).catch(() => {}));
227
+ }
228
+ }
65
229
  }
66
230
 
67
231
  /**
68
- * Write metadata to disk.
232
+ * Reconcile git branch config so it exactly mirrors active-stack membership.
233
+ * Only branches whose recorded membership differs are touched, to avoid
234
+ * shelling out to `git config` for every branch on every write.
69
235
  */
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");
236
+ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
237
+ const desired = new Map<string, BranchMembership>();
238
+ for (const [stackName, stack] of Object.entries(meta.stacks)) {
239
+ for (const [branchName, b] of Object.entries(stack.branches)) {
240
+ desired.set(branchName, { stack: stackName, parent: b.parent, pr: b.pr });
241
+ }
242
+ }
243
+
244
+ const existing = await allBranchMemberships();
245
+
246
+ for (const [branch, m] of desired) {
247
+ const cur = existing.get(branch);
248
+ if (!cur || cur.stack !== m.stack || cur.parent !== m.parent || cur.pr !== m.pr) {
249
+ await setBranchMembership(branch, m);
250
+ }
251
+ }
252
+
253
+ // Branches that still carry gh-stack config but are no longer active members.
254
+ for (const branch of existing.keys()) {
255
+ if (!desired.has(branch)) await clearBranchMembership(branch);
256
+ }
73
257
  }
74
258
 
75
259
  /**
76
- * Initialize an empty metadata file.
260
+ * Initialize an empty v3 store.
77
261
  */
78
262
  export async function initMetadata(): Promise<StackMetadata> {
263
+ await ensureDirs();
79
264
  const data: StackMetadata = {
80
- version: 2,
265
+ version: 3,
81
266
  current_stack: null,
82
267
  stacks: {},
83
268
  };
@@ -123,11 +308,13 @@ export async function createStack(
123
308
  meta: StackMetadata,
124
309
  name: string,
125
310
  description: string,
311
+ base: string = "main",
126
312
  ): Promise<StackMetadata> {
127
313
  meta.stacks[name] = {
128
314
  description,
129
315
  last_branch: null,
130
316
  branches: {},
317
+ base,
131
318
  };
132
319
  meta.current_stack = name;
133
320
  await writeMetadata(meta);
@@ -184,6 +371,11 @@ export async function removeBranchFromStack(
184
371
  // Remove the branch
185
372
  delete stack.branches[branchName];
186
373
 
374
+ // The removed branch may still exist in git but is no longer a member —
375
+ // drop its membership config explicitly (syncBranchConfig also would, but
376
+ // be direct since the branch is gone from `meta`).
377
+ await clearBranchMembership(branchName);
378
+
187
379
  // Update last_branch if it was the removed one
188
380
  if (stack.last_branch === branchName) {
189
381
  const remaining = Object.keys(stack.branches);
@@ -212,7 +404,7 @@ export function getOrderedBranches(stack: Stack): string[] {
212
404
  }
213
405
  }
214
406
 
215
- addChildrenOf("main");
407
+ addChildrenOf(stackBase(stack));
216
408
  return ordered;
217
409
  }
218
410
 
@@ -274,8 +466,8 @@ export async function updateLastBranch(
274
466
  * Save restack state for resume.
275
467
  */
276
468
  export async function saveRestackState(state: RestackState): Promise<void> {
277
- const path = await restackStatePath();
278
- await Bun.write(path, JSON.stringify(state, null, 2) + "\n");
469
+ await ensureDirs();
470
+ await Bun.write(await restackStateFile(), JSON.stringify(state, null, 2) + "\n");
279
471
  }
280
472
 
281
473
  /**
@@ -283,13 +475,10 @@ export async function saveRestackState(state: RestackState): Promise<void> {
283
475
  * Returns null if no state file exists.
284
476
  */
285
477
  export async function loadRestackState(): Promise<RestackState | null> {
286
- const path = await restackStatePath();
287
- const file = Bun.file(path);
288
-
478
+ const file = Bun.file(await restackStateFile());
289
479
  if (!(await file.exists())) {
290
480
  return null;
291
481
  }
292
-
293
482
  return file.json();
294
483
  }
295
484
 
@@ -297,11 +486,8 @@ export async function loadRestackState(): Promise<RestackState | null> {
297
486
  * Clear restack state file.
298
487
  */
299
488
  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);
489
+ const path = await restackStateFile();
490
+ if (await Bun.file(path).exists()) {
491
+ await unlink(path).catch(() => {});
306
492
  }
307
493
  }
@@ -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}`);
@@ -1,7 +1,17 @@
1
- // Snapshot system for undo support and pre-rewrite base recovery
1
+ // Snapshot system for undo support and pre-rewrite base recovery.
2
+ //
3
+ // v3 stores each snapshot as its own append-only file under
4
+ // `.git/.gh-stack/snapshots/`, retained per-stack. This replaces the single
5
+ // growing `snapshots[]` array of v2 — the array was rewritten on every command
6
+ // (churn + a conflict magnet) and global trimming could evict a dependent
7
+ // stack's only record while a busy sibling stack churned (see issue #13).
2
8
  import type { StackMetadata, Snapshot } from "../types.ts";
3
9
  import * as git from "./git.ts";
4
- import { writeMetadata, getOrderedBranches } from "./metadata.ts";
10
+ import { getOrderedBranches } from "./metadata.ts";
11
+ import { snapshotsDir } from "./paths.ts";
12
+ import { mkdir, readdir, unlink } from "node:fs/promises";
13
+
14
+ const MAX_SNAPSHOTS_PER_STACK = 10;
5
15
 
6
16
  /**
7
17
  * Find the pre-rewrite SHA for a parent branch by walking snapshots newest-first.
@@ -76,10 +86,54 @@ export async function findPreRewriteSha(
76
86
  return null;
77
87
  }
78
88
 
79
- const MAX_SNAPSHOTS = 10;
89
+ // Snapshot files are named `<sortable-timestamp>__<encoded-stack>.json`. The
90
+ // timestamp keeps colons/dots out of the filename (Windows-safe) while sorting
91
+ // chronologically as a plain string; the stack name is URL-encoded so `/` in a
92
+ // branch-derived name doesn't create subdirectories.
93
+ function snapshotFileName(snapshot: Snapshot): string {
94
+ const ts = snapshot.timestamp.replace(/[:.]/g, "-");
95
+ return `${ts}__${encodeURIComponent(snapshot.stack ?? "")}.json`;
96
+ }
97
+
98
+ /** Write a fully-formed snapshot to its file. Used by takeSnapshot and the
99
+ * v2→v3 migration (which replays historical snapshots without touching git). */
100
+ export async function writeSnapshotFile(snapshot: Snapshot): Promise<void> {
101
+ const dir = await snapshotsDir();
102
+ await mkdir(dir, { recursive: true });
103
+ await Bun.write(`${dir}/${snapshotFileName(snapshot)}`, JSON.stringify(snapshot, null, 2) + "\n");
104
+ }
80
105
 
81
106
  /**
82
- * Take a snapshot of all branch HEADs in the current stack before a destructive operation.
107
+ * Load all snapshots from disk, oldest → newest. This populates
108
+ * `meta.snapshots` on read so the in-memory shape matches v2.
109
+ */
110
+ export async function loadSnapshots(): Promise<Snapshot[]> {
111
+ const dir = await snapshotsDir();
112
+ let entries: string[];
113
+ try {
114
+ entries = await readdir(dir);
115
+ } catch {
116
+ return [];
117
+ }
118
+
119
+ const snapshots: Snapshot[] = [];
120
+ for (const file of entries) {
121
+ if (!file.endsWith(".json")) continue;
122
+ try {
123
+ snapshots.push((await Bun.file(`${dir}/${file}`).json()) as Snapshot);
124
+ } catch {
125
+ // Skip malformed snapshot files.
126
+ }
127
+ }
128
+
129
+ // ISO 8601 timestamps sort chronologically as strings.
130
+ return snapshots.toSorted((a, b) => a.timestamp.localeCompare(b.timestamp));
131
+ }
132
+
133
+ /**
134
+ * Take a snapshot of all branch HEADs in the current stack before a destructive
135
+ * operation. Writes one append-only file and trims that stack's history to the
136
+ * most recent N — sibling stacks are never touched.
83
137
  */
84
138
  export async function takeSnapshot(
85
139
  meta: StackMetadata,
@@ -104,23 +158,37 @@ export async function takeSnapshot(
104
158
  timestamp: new Date().toISOString(),
105
159
  operation,
106
160
  branches,
161
+ stack: stackName,
107
162
  };
108
163
 
109
- if (!meta.snapshots) {
110
- meta.snapshots = [];
111
- }
164
+ await writeSnapshotFile(snapshot);
112
165
 
166
+ // Keep in-memory array consistent for callers that read it afterward.
167
+ if (!meta.snapshots) meta.snapshots = [];
113
168
  meta.snapshots.push(snapshot);
114
169
 
115
- // Keep only the last N snapshots
116
- if (meta.snapshots.length > MAX_SNAPSHOTS) {
117
- meta.snapshots = meta.snapshots.slice(-MAX_SNAPSHOTS);
118
- }
170
+ await gcStackSnapshots(stackName);
119
171
 
120
- await writeMetadata(meta);
121
172
  return meta;
122
173
  }
123
174
 
175
+ /** Trim a single stack's snapshot files to the most recent N. */
176
+ async function gcStackSnapshots(stackName: string): Promise<void> {
177
+ const dir = await snapshotsDir();
178
+ const suffix = `__${encodeURIComponent(stackName)}.json`;
179
+ let entries: string[];
180
+ try {
181
+ entries = await readdir(dir);
182
+ } catch {
183
+ return;
184
+ }
185
+ const mine = entries.filter((f) => f.endsWith(suffix)).toSorted(); // sortable ts prefix
186
+ const excess = mine.length - MAX_SNAPSHOTS_PER_STACK;
187
+ for (let i = 0; i < excess; i++) {
188
+ await unlink(`${dir}/${mine[i]!}`).catch(() => {});
189
+ }
190
+ }
191
+
124
192
  /**
125
193
  * Get the last snapshot (for undo).
126
194
  */
@@ -132,7 +200,7 @@ export function getLastSnapshot(meta: StackMetadata): Snapshot | null {
132
200
  }
133
201
 
134
202
  /**
135
- * Pop the last snapshot (remove it after restoring).
203
+ * Pop the last snapshot (remove it after restoring) — deletes its file too.
136
204
  */
137
205
  export async function popSnapshot(
138
206
  meta: StackMetadata,
@@ -142,6 +210,7 @@ export async function popSnapshot(
142
210
  }
143
211
 
144
212
  const snapshot = meta.snapshots.pop()!;
145
- await writeMetadata(meta);
213
+ const dir = await snapshotsDir();
214
+ await unlink(`${dir}/${snapshotFileName(snapshot)}`).catch(() => {});
146
215
  return { meta, snapshot };
147
216
  }