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.
- package/README.md +91 -24
- package/package.json +1 -1
- package/src/commands/doctor.ts +268 -0
- package/src/commands/down.ts +14 -6
- package/src/commands/init.ts +4 -1
- package/src/commands/log.ts +2 -0
- package/src/commands/merge.ts +19 -1
- package/src/commands/restack.ts +270 -44
- package/src/commands/split.ts +159 -0
- package/src/commands/status.ts +7 -0
- package/src/commands/submit.ts +30 -88
- package/src/commands/sync.ts +24 -4
- package/src/commands/update-prs.ts +201 -100
- package/src/index.ts +15 -0
- package/src/lib/branch-config.ts +126 -0
- package/src/lib/chain.ts +83 -50
- package/src/lib/git.ts +11 -0
- package/src/lib/github.ts +24 -0
- package/src/lib/metadata.ts +246 -60
- package/src/lib/paths.ts +69 -0
- package/src/lib/safety.ts +33 -7
- package/src/lib/snapshot.ts +83 -14
- package/src/lib/ui.ts +5 -2
- package/src/types.ts +21 -2
package/src/lib/metadata.ts
CHANGED
|
@@ -1,83 +1,268 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
27
|
-
return `${dir}/.gh-stack-sync-state`;
|
|
76
|
+
return restackStateFile();
|
|
28
77
|
}
|
|
29
78
|
|
|
30
79
|
/**
|
|
31
|
-
*
|
|
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
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
|
40
|
-
* Returns null if
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
278
|
-
await Bun.write(
|
|
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
|
|
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
|
|
301
|
-
|
|
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
|
}
|
package/src/lib/paths.ts
ADDED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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}`);
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
213
|
+
const dir = await snapshotsDir();
|
|
214
|
+
await unlink(`${dir}/${snapshotFileName(snapshot)}`).catch(() => {});
|
|
146
215
|
return { meta, snapshot };
|
|
147
216
|
}
|