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,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
  }
package/src/lib/ui.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import * as p from "./output.ts";
3
3
  import pc from "picocolors";
4
4
  import type { Stack, StackMetadata } from "../types.ts";
5
- import { getOrderedBranches } from "./metadata.ts";
5
+ import { getOrderedBranches, stackBase } from "./metadata.ts";
6
6
 
7
7
  // ── Auto-yes mode (for agents/CI) ──
8
8
 
@@ -32,7 +32,10 @@ export function renderStackTree(
32
32
  const showNumbers = options?.showNumbers ?? true;
33
33
  const lines: string[] = [];
34
34
 
35
- lines.push(`${pc.green("◯")} main`);
35
+ const base = stackBase(stack);
36
+ const baseIsTrunk = base === "main" || base === "master";
37
+ const baseHint = baseIsTrunk ? "" : pc.dim(" (base — in parent stack)");
38
+ lines.push(`${pc.green("◯")} ${base}${baseHint}`);
36
39
 
37
40
  for (let i = 0; i < ordered.length; i++) {
38
41
  const branchName = ordered[i]!;
package/src/types.ts CHANGED
@@ -1,7 +1,13 @@
1
- // gh-stack metadata schema (v2)
1
+ // gh-stack metadata schema.
2
+ //
3
+ // `StackMetadata` is the in-memory shape shared by every command. Its on-disk
4
+ // representation has evolved (v1 monolith → v2 monolith → v3 per-stack files +
5
+ // git branch config), but the in-memory object stays stable so commands never
6
+ // need to know which storage version produced it. `version` reflects the
7
+ // store the object was assembled from.
2
8
 
3
9
  export interface StackMetadata {
4
- version: 2;
10
+ version: 2 | 3;
5
11
  current_stack: string | null;
6
12
  stacks: Record<string, Stack>;
7
13
  archive?: Record<string, Stack>;
@@ -12,18 +18,38 @@ export interface Stack {
12
18
  description: string;
13
19
  last_branch: string | null;
14
20
  branches: Record<string, Branch>;
21
+ // The ref this stack is rooted on. Usually the trunk ("main"), but for a
22
+ // stack that was split off another (see `gh-stack split`), this is a branch
23
+ // belonging to the parent stack. The stack's root branch is the one whose
24
+ // `parent` equals this value. Optional on disk for back-compat; treat a
25
+ // missing value as "main" via `stackBase()`.
26
+ base?: string;
15
27
  }
16
28
 
17
29
  export interface Branch {
18
30
  parent: string; // "main" or another branch name
19
31
  pr?: number; // GitHub PR number
20
32
  description?: string; // Human-readable label
33
+
34
+ // Stable per-branch identity (UUID), stamped on first write and mirrored into
35
+ // git config as `branch.<name>.ghstack-id`. Because `git branch -m` moves the
36
+ // whole config section but the JSON is keyed by name, this id is what lets us
37
+ // recognize a renamed branch and re-key its (now-stale) JSON entry — even
38
+ // before it has a PR number to match on. See metadata.ts `reconcileRenames`.
39
+ id?: string;
40
+
41
+ // ── submit caches (v0.9.0, issue #16) ──
42
+ // Let `submit` skip redundant network calls when nothing relevant changed.
43
+ prTitle?: string; // last-known GitHub PR title, for rendering the stack viz without a fetch
44
+ prBase?: string; // last base we set on the PR via `gh pr edit --base`; skip the edit when parent is unchanged
45
+ vizHash?: string; // hash of the stack-viz block we last wrote to this PR; skip the PATCH when it would be identical
21
46
  }
22
47
 
23
48
  export interface Snapshot {
24
49
  timestamp: string; // ISO 8601
25
50
  operation: string; // "restack" | "merge" | "sync" | "remove"
26
51
  branches: Record<string, string>; // branch name -> commit SHA
52
+ stack?: string; // owning stack (v3: drives per-stack retention + file naming)
27
53
  }
28
54
 
29
55
  // Resume state for restack --resume