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 +131 -25
- 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 +186 -23
- package/src/commands/rename.ts +111 -0
- package/src/commands/restack.ts +270 -44
- package/src/commands/split.ts +159 -0
- package/src/commands/stacks.ts +136 -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 +25 -0
- package/src/lib/branch-config.ts +132 -0
- package/src/lib/chain.ts +83 -50
- package/src/lib/git.ts +21 -0
- package/src/lib/github.ts +73 -5
- package/src/lib/metadata.ts +354 -59
- 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 +28 -2
package/src/lib/metadata.ts
CHANGED
|
@@ -1,83 +1,377 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
75
|
+
export async function restackStatePath(): Promise<string> {
|
|
76
|
+
return restackStateFile();
|
|
77
|
+
}
|
|
6
78
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
113
|
+
* Read and assemble metadata from `.git/.gh-stack/`.
|
|
114
|
+
* Returns null if the store doesn't exist.
|
|
16
115
|
*/
|
|
17
|
-
export async function
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
143
|
+
* The ref a stack is rooted on. Defaults to "main" for stacks created before
|
|
144
|
+
* the `base` field existed.
|
|
24
145
|
*/
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
278
|
-
await Bun.write(
|
|
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
|
|
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
|
|
301
|
-
|
|
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
|
}
|
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}`);
|