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/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
|
}
|
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
|
-
|
|
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
|
|
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
|