gh-stack 0.7.0 → 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 +97 -27
- package/package.json +1 -1
- package/src/commands/delete.ts +66 -12
- 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 +29 -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/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Stacked PR manager for squash-merge workflows.
|
|
4
4
|
|
|
5
|
-
Manages stacked pull requests with metadata stored
|
|
5
|
+
Manages stacked pull requests with metadata stored locally under `.git/.gh-stack/` (never committed). Designed for repositories that use squash-merge (where tools like Graphite break down). Inspired by [Graphite](https://graphite.dev/) (`gt`) but works directly against GitHub — no backend required.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -62,6 +62,11 @@ gh-stack sync
|
|
|
62
62
|
|
|
63
63
|
# Check PR status
|
|
64
64
|
gh-stack status
|
|
65
|
+
|
|
66
|
+
# Split a long in-review chain so new work becomes its own stack
|
|
67
|
+
gh-stack split feat-12 # feat-12.. → new stack based on feat-11
|
|
68
|
+
# ...later, after the original stack merges to main:
|
|
69
|
+
gh-stack restack --onto main # re-root the split stack onto main
|
|
65
70
|
```
|
|
66
71
|
|
|
67
72
|
## Commands
|
|
@@ -133,6 +138,12 @@ restack [--resume] [--dry-run] [--verbose]
|
|
|
133
138
|
|
|
134
139
|
(alias: rebase)
|
|
135
140
|
|
|
141
|
+
restack --onto <ref>
|
|
142
|
+
Re-root the current stack onto a new base ref, changing the stack's
|
|
143
|
+
base. Use this to move a split stack off its parent-stack branch and
|
|
144
|
+
onto main once the parent stack has merged — only the stack's own
|
|
145
|
+
commits replay onto the new base.
|
|
146
|
+
|
|
136
147
|
sync [--dry-run]
|
|
137
148
|
Fetch main, rebase the base branch onto main, then restack all
|
|
138
149
|
children. Snapshots every branch's pre-sync tip so children can be
|
|
@@ -149,9 +160,25 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
|
149
160
|
review the cumulative diff on GitHub. Re-run
|
|
150
161
|
`gh-stack merge` (without --collapse) to finish.
|
|
151
162
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
163
|
+
split [<branch>] [--name <name>]
|
|
164
|
+
Cut the current stack into two at <branch>. The cut branch and every
|
|
165
|
+
branch above it move into a NEW stack whose base is the cut branch's
|
|
166
|
+
parent (which stays in the original stack). Purely a metadata
|
|
167
|
+
operation — no git branches are moved or rebased. Interactive
|
|
168
|
+
selector if no branch is given; you can't split at the stack's root.
|
|
169
|
+
|
|
170
|
+
Use it when a long chain is in review and can't merge yet, but new
|
|
171
|
+
work is piling on top: split at the first "new work" branch so the
|
|
172
|
+
original stack stays the review unit and the new stack rides on its
|
|
173
|
+
tip. Once the original stack merges, re-root the new stack with
|
|
174
|
+
`gh-stack restack --onto main`.
|
|
175
|
+
|
|
176
|
+
delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
177
|
+
Remove a branch from the stack and re-parent its children, then
|
|
178
|
+
delete the underlying local git branch (and the remote branch if
|
|
179
|
+
it was pushed). Interactive selector if no branch specified.
|
|
180
|
+
--keep-branch leaves the git branches untouched (metadata only);
|
|
181
|
+
--no-remote deletes the local branch but keeps the remote.
|
|
155
182
|
```
|
|
156
183
|
|
|
157
184
|
### Info & Maintenance
|
|
@@ -171,6 +198,11 @@ undo
|
|
|
171
198
|
|
|
172
199
|
archive [--restore <name>]
|
|
173
200
|
List archived stacks by default, or restore one by name.
|
|
201
|
+
|
|
202
|
+
doctor
|
|
203
|
+
Migrate old (v2) metadata to the v3 layout, reconcile git branch
|
|
204
|
+
config against the topology files, and flag stacks whose base
|
|
205
|
+
stack appears already-merged into main. Safe to run repeatedly.
|
|
174
206
|
```
|
|
175
207
|
|
|
176
208
|
## Global Options
|
|
@@ -208,47 +240,85 @@ Snapshots also power `gh-stack undo`, so the same data structure does double dut
|
|
|
208
240
|
|
|
209
241
|
### Stack Visualization
|
|
210
242
|
|
|
211
|
-
`submit` automatically adds a stack section to all PR descriptions
|
|
243
|
+
`submit` automatically adds a stack section to all PR descriptions, with each
|
|
244
|
+
branch numbered by its position in the stack (handy for spotting "which one is
|
|
245
|
+
PR 6 of 12"):
|
|
212
246
|
|
|
213
247
|
```
|
|
214
248
|
### 📚 Stacked on
|
|
215
249
|
⚫ main
|
|
216
250
|
┃
|
|
217
|
-
┣━
|
|
251
|
+
┣━ 1. #123 Backend models
|
|
218
252
|
┃
|
|
219
|
-
┗━
|
|
253
|
+
┗━ 2. #124 Frontend UI 👈
|
|
220
254
|
```
|
|
221
255
|
|
|
222
|
-
|
|
256
|
+
The block is rendered entirely from local metadata plus one repo-identity
|
|
257
|
+
lookup for links — no per-PR API call — and `submit` only rewrites a PR
|
|
258
|
+
description whose block actually changed (membership/order/title), so a
|
|
259
|
+
re-submit that touched one branch on a 12-PR stack does ~1 update, not 12.
|
|
260
|
+
Review/CI status is intentionally not shown here. Run `gh-stack update-prs`
|
|
261
|
+
(`--force` to rewrite all) to refresh manually.
|
|
262
|
+
|
|
263
|
+
### Metadata (v3)
|
|
264
|
+
|
|
265
|
+
Stack metadata lives under `.git/.gh-stack/` (never committed) — a folder of
|
|
266
|
+
per-stack files plus git-native branch config, rather than a single JSON blob:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
.git/.gh-stack/
|
|
270
|
+
current hint: last-active stack name
|
|
271
|
+
active/<stack>.json topology of a live stack (ordered branches, base, description)
|
|
272
|
+
archived/<stack>.json merged/closed stacks
|
|
273
|
+
deleted/<stack>.json tombstones (recoverable)
|
|
274
|
+
snapshots/<ts>__<stack>.json append-only, retained per-stack
|
|
275
|
+
restack-state.json in-flight restack/sync resume state
|
|
276
|
+
```
|
|
223
277
|
|
|
224
|
-
|
|
278
|
+
A single `active/<stack>.json` looks like:
|
|
225
279
|
|
|
226
280
|
```json
|
|
227
281
|
{
|
|
228
|
-
"
|
|
229
|
-
"
|
|
230
|
-
"
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
"branches": {
|
|
235
|
-
"kiliman/feature-1": {
|
|
236
|
-
"parent": "main",
|
|
237
|
-
"pr": 21729
|
|
238
|
-
},
|
|
239
|
-
"kiliman/feature-2": {
|
|
240
|
-
"parent": "kiliman/feature-1",
|
|
241
|
-
"pr": 21730
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
282
|
+
"description": "",
|
|
283
|
+
"last_branch": "kiliman/feature-2",
|
|
284
|
+
"base": "main",
|
|
285
|
+
"branches": {
|
|
286
|
+
"kiliman/feature-1": { "parent": "main", "pr": 21729 },
|
|
287
|
+
"kiliman/feature-2": { "parent": "kiliman/feature-1", "pr": 21730 }
|
|
245
288
|
}
|
|
246
289
|
}
|
|
247
290
|
```
|
|
248
291
|
|
|
292
|
+
Per-branch membership is *also* recorded in git's own config, so renaming or
|
|
293
|
+
deleting a branch updates/cleans it automatically:
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
branch.<name>.ghstack-stack <stack>
|
|
297
|
+
branch.<name>.ghstack-parent <parent-branch>
|
|
298
|
+
branch.<name>.ghstack-pr <number>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The two representations cross-check each other; `gh-stack doctor` reconciles
|
|
302
|
+
any drift. Why this shape:
|
|
303
|
+
|
|
304
|
+
- **A stack can't silently vanish** — lifecycle transitions are file moves
|
|
305
|
+
(`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
|
|
306
|
+
just unlinked.
|
|
307
|
+
- **No `current_stack` drift** — the stack you're on is derivable from the
|
|
308
|
+
branch's own config, so a written pointer can't contradict reality.
|
|
309
|
+
- **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
|
|
310
|
+
|
|
311
|
+
#### Migrating from v2
|
|
312
|
+
|
|
313
|
+
Repos created before v3 store a single `.git/gh-stack-metadata.json`. Run
|
|
314
|
+
`gh-stack doctor` once — it fans the monolith out into the layout above,
|
|
315
|
+
backfills branch config, explodes snapshots into per-file records, and keeps a
|
|
316
|
+
`.bak` of the old file. Commands refuse to run on unmigrated metadata and point
|
|
317
|
+
you at `doctor`.
|
|
318
|
+
|
|
249
319
|
### Snapshots
|
|
250
320
|
|
|
251
|
-
Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs. Run `gh-stack undo` to restore.
|
|
321
|
+
Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs as an append-only file under `snapshots/`, retained per-stack. Run `gh-stack undo` to restore.
|
|
252
322
|
|
|
253
323
|
## Example Workflow
|
|
254
324
|
|
package/package.json
CHANGED
package/src/commands/delete.ts
CHANGED
|
@@ -4,7 +4,7 @@ import pc from "picocolors";
|
|
|
4
4
|
import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
|
|
5
5
|
import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
|
|
6
6
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
7
|
-
import { selectBranch } from "../lib/ui.ts";
|
|
7
|
+
import { selectBranch, confirmAction } from "../lib/ui.ts";
|
|
8
8
|
import * as git from "../lib/git.ts";
|
|
9
9
|
|
|
10
10
|
export default async function deleteCmd(args: string[]): Promise<void> {
|
|
@@ -13,14 +13,26 @@ export default async function deleteCmd(args: string[]): Promise<void> {
|
|
|
13
13
|
gh-stack delete — Delete a branch from the stack
|
|
14
14
|
|
|
15
15
|
USAGE
|
|
16
|
-
gh-stack delete [<branch>]
|
|
16
|
+
gh-stack delete [<branch>] [options]
|
|
17
17
|
|
|
18
|
-
Removes the branch and
|
|
19
|
-
|
|
18
|
+
Removes the branch from the stack and deletes the underlying git branch,
|
|
19
|
+
re-parenting any children to the removed branch's parent. If the deleted
|
|
20
|
+
branch was pushed, its remote counterpart is deleted too.
|
|
21
|
+
|
|
22
|
+
If no branch is specified, shows an interactive selector.
|
|
23
|
+
|
|
24
|
+
OPTIONS
|
|
25
|
+
-k, --keep-branch Only remove from the stack metadata; leave the
|
|
26
|
+
local and remote git branches untouched
|
|
27
|
+
--no-remote Delete the local branch but keep the remote branch
|
|
20
28
|
`);
|
|
21
29
|
return;
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
const keepBranch = args.includes("--keep-branch") || args.includes("-k");
|
|
33
|
+
const noRemote = args.includes("--no-remote");
|
|
34
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
35
|
+
|
|
24
36
|
const meta = await ensureMetadata();
|
|
25
37
|
const stackName = ensureCurrentStack(meta);
|
|
26
38
|
await ensureValidStack(meta, stackName);
|
|
@@ -30,7 +42,7 @@ If no branch is specified, shows interactive selector.
|
|
|
30
42
|
p.intro(pc.cyan("Delete Branch from Stack"));
|
|
31
43
|
|
|
32
44
|
// Get branch to delete
|
|
33
|
-
let branchName =
|
|
45
|
+
let branchName = positional[0];
|
|
34
46
|
if (!branchName) {
|
|
35
47
|
branchName = (await selectBranch(stack, "Select branch to delete", currentBranch)) ?? undefined;
|
|
36
48
|
if (!branchName) {
|
|
@@ -46,9 +58,13 @@ If no branch is specified, shows interactive selector.
|
|
|
46
58
|
process.exit(1);
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
// Discover what actually exists in git so we report (and delete) accurately
|
|
62
|
+
const hasLocal = !keepBranch && (await git.localBranchExists(branchName));
|
|
63
|
+
const hasRemote = !keepBranch && !noRemote && (await git.remoteBranchExists(branchName));
|
|
64
|
+
|
|
49
65
|
// Show what will happen
|
|
50
66
|
const children = getChildren(stack, branchName);
|
|
51
|
-
p.log.info(`Removing: ${pc.yellow(branchName)}`);
|
|
67
|
+
p.log.info(`Removing: ${pc.yellow(branchName)} from stack ${pc.blue(stackName)}`);
|
|
52
68
|
p.log.info(`Parent was: ${pc.dim(branch.parent)}`);
|
|
53
69
|
|
|
54
70
|
if (children.length > 0) {
|
|
@@ -60,11 +76,19 @@ If no branch is specified, shows interactive selector.
|
|
|
60
76
|
}
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
if (keepBranch) {
|
|
80
|
+
p.log.info(pc.dim("Keeping git branch (--keep-branch) — stack metadata only"));
|
|
81
|
+
} else {
|
|
82
|
+
if (hasLocal) p.log.info(`Deleting local branch ${pc.yellow(branchName)}`);
|
|
83
|
+
if (hasRemote) p.log.info(`Deleting remote branch ${pc.yellow(`origin/${branchName}`)}`);
|
|
84
|
+
if (!hasLocal && !hasRemote) {
|
|
85
|
+
p.log.info(pc.dim("No git branch found to delete — stack metadata only"));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Confirm (respects --yes / GH_STACK_YES — no interactive hang for agents)
|
|
90
|
+
const confirmed = await confirmAction(`Delete ${pc.yellow(branchName)}?`);
|
|
91
|
+
if (!confirmed) {
|
|
68
92
|
p.cancel("Cancelled");
|
|
69
93
|
process.exit(0);
|
|
70
94
|
}
|
|
@@ -72,8 +96,38 @@ If no branch is specified, shows interactive selector.
|
|
|
72
96
|
// Take snapshot before destructive operation
|
|
73
97
|
await takeSnapshot(meta, stackName, "delete");
|
|
74
98
|
|
|
75
|
-
//
|
|
99
|
+
// If we're sitting on the branch we're about to delete, git won't let us
|
|
100
|
+
// remove it — move to the parent (or trunk) first.
|
|
101
|
+
if (hasLocal && branchName === currentBranch) {
|
|
102
|
+
const fallback = (await git.localBranchExists(branch.parent))
|
|
103
|
+
? branch.parent
|
|
104
|
+
: await git.trunkBranch();
|
|
105
|
+
await git.checkout(fallback);
|
|
106
|
+
p.log.info(`Switched to ${pc.blue(fallback)} (was on the deleted branch)`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Remove from metadata (re-parents children, fixes last_branch)
|
|
76
110
|
await removeBranchFromStack(meta, stackName, branchName);
|
|
77
111
|
|
|
112
|
+
// Delete the actual git branch(es)
|
|
113
|
+
if (hasRemote) {
|
|
114
|
+
const ok = await git.deleteRemoteBranch(branchName);
|
|
115
|
+
if (ok) {
|
|
116
|
+
p.log.success(`Deleted remote branch ${pc.dim(`origin/${branchName}`)}`);
|
|
117
|
+
} else {
|
|
118
|
+
p.log.warn(
|
|
119
|
+
`Could not delete remote branch ${pc.dim(`origin/${branchName}`)} (delete it manually)`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (hasLocal) {
|
|
124
|
+
const ok = await git.deleteLocalBranch(branchName);
|
|
125
|
+
if (ok) {
|
|
126
|
+
p.log.success(`Deleted local branch ${pc.dim(branchName)}`);
|
|
127
|
+
} else {
|
|
128
|
+
p.log.warn(`Could not delete local branch ${pc.dim(branchName)} (delete it manually)`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
78
132
|
p.outro(pc.green(`Removed ${pc.yellow(branchName)} from stack ${pc.blue(stackName)}`));
|
|
79
133
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// gh-stack doctor — migrate v2 → v3, reconcile branch config ↔ topology,
|
|
2
|
+
// and flag stacks whose base stack appears to have merged.
|
|
3
|
+
import * as p from "../lib/output.ts";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { rename, unlink } from "node:fs/promises";
|
|
6
|
+
import * as git from "../lib/git.ts";
|
|
7
|
+
import type { StackMetadata, Snapshot, Stack } from "../types.ts";
|
|
8
|
+
import {
|
|
9
|
+
readMetadata,
|
|
10
|
+
writeMetadata,
|
|
11
|
+
v3Exists,
|
|
12
|
+
legacyMetadataExists,
|
|
13
|
+
stackBase,
|
|
14
|
+
} from "../lib/metadata.ts";
|
|
15
|
+
import { legacyMetadataPath, legacyRestackStatePath, restackStateFile } from "../lib/paths.ts";
|
|
16
|
+
import { writeSnapshotFile } from "../lib/snapshot.ts";
|
|
17
|
+
import { allBranchMemberships } from "../lib/branch-config.ts";
|
|
18
|
+
|
|
19
|
+
const HELP = `
|
|
20
|
+
gh-stack doctor — Migrate & repair stack metadata
|
|
21
|
+
|
|
22
|
+
USAGE
|
|
23
|
+
gh-stack doctor
|
|
24
|
+
|
|
25
|
+
Migrates old (v2) metadata to the v3 layout (per-stack files under
|
|
26
|
+
.git/.gh-stack/ + git branch config), then reconciles branch config
|
|
27
|
+
against the topology files and flags stacks whose base stack looks
|
|
28
|
+
already-merged into main.
|
|
29
|
+
|
|
30
|
+
Safe to run repeatedly — it's idempotent.
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
export default async function doctor(args: string[]): Promise<void> {
|
|
34
|
+
if (args.includes("--help")) {
|
|
35
|
+
console.log(HELP);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
p.intro(pc.cyan("gh-stack doctor"));
|
|
40
|
+
|
|
41
|
+
// ── 1. Migrate v2 → v3 if needed ──
|
|
42
|
+
const migrated = await migrateIfNeeded();
|
|
43
|
+
|
|
44
|
+
if (!(await v3Exists())) {
|
|
45
|
+
p.cancel(
|
|
46
|
+
`No stack metadata found.\n\n Create your first stack with:\n ${pc.green("gh-stack init")}`,
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const meta = await readMetadata();
|
|
52
|
+
if (!meta) {
|
|
53
|
+
p.cancel("Failed to read stack metadata after migration");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 2. Reconcile branch config ↔ topology ──
|
|
58
|
+
await reconcile(meta);
|
|
59
|
+
|
|
60
|
+
// ── 3. Flag base-stack-merged situations (issue #13) ──
|
|
61
|
+
await flagMergedBaseStacks(meta);
|
|
62
|
+
|
|
63
|
+
if (migrated) {
|
|
64
|
+
p.outro(pc.green("Migration complete — metadata is now v3."));
|
|
65
|
+
} else {
|
|
66
|
+
p.outro(pc.green("Metadata looks healthy."));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Migration ──
|
|
71
|
+
|
|
72
|
+
async function migrateIfNeeded(): Promise<boolean> {
|
|
73
|
+
if (!(await legacyMetadataExists())) return false;
|
|
74
|
+
|
|
75
|
+
const legacyPath = await legacyMetadataPath();
|
|
76
|
+
|
|
77
|
+
// If a v3 store already exists alongside the legacy file, don't clobber it —
|
|
78
|
+
// just retire the stale legacy file.
|
|
79
|
+
if (await v3Exists()) {
|
|
80
|
+
p.log.warn(
|
|
81
|
+
`Both v3 metadata and a legacy file exist. Keeping v3 and backing up the legacy file.`,
|
|
82
|
+
);
|
|
83
|
+
await rename(legacyPath, `${legacyPath}.bak`).catch(() => {});
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const s = p.spinner();
|
|
88
|
+
s.start("Migrating v2 metadata → v3...");
|
|
89
|
+
|
|
90
|
+
const raw = (await Bun.file(legacyPath).json()) as Partial<StackMetadata>;
|
|
91
|
+
const stacks: Record<string, Stack> = raw.stacks ?? {};
|
|
92
|
+
const archive: Record<string, Stack> = raw.archive ?? {};
|
|
93
|
+
const snapshots: Snapshot[] = raw.snapshots ?? [];
|
|
94
|
+
|
|
95
|
+
// Backfill base for stacks written before the split feature.
|
|
96
|
+
for (const stack of [...Object.values(stacks), ...Object.values(archive)]) {
|
|
97
|
+
if (stack.base == null) stack.base = "main";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const meta: StackMetadata = {
|
|
101
|
+
version: 3,
|
|
102
|
+
current_stack: raw.current_stack ?? null,
|
|
103
|
+
stacks,
|
|
104
|
+
};
|
|
105
|
+
if (Object.keys(archive).length > 0) meta.archive = archive;
|
|
106
|
+
|
|
107
|
+
// Fan out topology + backfill branch config.
|
|
108
|
+
await writeMetadata(meta);
|
|
109
|
+
|
|
110
|
+
// Explode the snapshots[] array into per-file records, inferring each
|
|
111
|
+
// snapshot's owning stack from its recorded branch set (v2 didn't store it).
|
|
112
|
+
for (const snap of snapshots) {
|
|
113
|
+
const owner = inferStack(snap, stacks, archive) ?? meta.current_stack ?? undefined;
|
|
114
|
+
await writeSnapshotFile({ ...snap, stack: owner });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Migrate the legacy restack-state file, if present.
|
|
118
|
+
const legacyState = await legacyRestackStatePath();
|
|
119
|
+
if (await Bun.file(legacyState).exists()) {
|
|
120
|
+
const content = await Bun.file(legacyState).text();
|
|
121
|
+
await Bun.write(await restackStateFile(), content);
|
|
122
|
+
await unlink(legacyState).catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Retire the legacy monolith (keep a .bak for safety).
|
|
126
|
+
await rename(legacyPath, `${legacyPath}.bak`).catch(() => {});
|
|
127
|
+
|
|
128
|
+
s.stop("Migrated to v3");
|
|
129
|
+
|
|
130
|
+
const stackCount = Object.keys(stacks).length;
|
|
131
|
+
const archiveCount = Object.keys(archive).length;
|
|
132
|
+
p.log.info(
|
|
133
|
+
` ${pc.dim("→")} ${stackCount} active, ${archiveCount} archived, ${snapshots.length} snapshot(s)`,
|
|
134
|
+
);
|
|
135
|
+
p.log.info(` ${pc.dim("→")} Backed up legacy file to ${pc.dim(`${legacyPath}.bak`)}`);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Pick the stack whose branch set best overlaps a snapshot's branches. */
|
|
140
|
+
function inferStack(
|
|
141
|
+
snap: Snapshot,
|
|
142
|
+
stacks: Record<string, Stack>,
|
|
143
|
+
archive: Record<string, Stack>,
|
|
144
|
+
): string | null {
|
|
145
|
+
const recorded = new Set(Object.keys(snap.branches));
|
|
146
|
+
let best: string | null = null;
|
|
147
|
+
let bestOverlap = 0;
|
|
148
|
+
for (const [name, stack] of Object.entries({ ...archive, ...stacks })) {
|
|
149
|
+
let overlap = 0;
|
|
150
|
+
for (const b of Object.keys(stack.branches)) if (recorded.has(b)) overlap++;
|
|
151
|
+
if (overlap > bestOverlap) {
|
|
152
|
+
bestOverlap = overlap;
|
|
153
|
+
best = name;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return best;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Reconciliation ──
|
|
160
|
+
|
|
161
|
+
async function reconcile(meta: StackMetadata): Promise<void> {
|
|
162
|
+
// Re-syncing config is what writeMetadata already does: set desired
|
|
163
|
+
// membership, clear stale. Snapshot the before-state so we can report what
|
|
164
|
+
// drifted, then apply.
|
|
165
|
+
const before = await allBranchMemberships();
|
|
166
|
+
|
|
167
|
+
// Report topology branches that no longer exist locally (can't auto-fix
|
|
168
|
+
// safely — re-parenting children is a judgement call).
|
|
169
|
+
const deadBranches: { stack: string; branch: string }[] = [];
|
|
170
|
+
for (const [name, stack] of Object.entries(meta.stacks)) {
|
|
171
|
+
for (const branch of Object.keys(stack.branches)) {
|
|
172
|
+
if (!(await git.localBranchExists(branch))) {
|
|
173
|
+
deadBranches.push({ stack: name, branch });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build desired membership to diff against `before`.
|
|
179
|
+
const desired = new Set<string>();
|
|
180
|
+
let drift = 0;
|
|
181
|
+
for (const [name, stack] of Object.entries(meta.stacks)) {
|
|
182
|
+
for (const [branch, b] of Object.entries(stack.branches)) {
|
|
183
|
+
desired.add(branch);
|
|
184
|
+
const cur = before.get(branch);
|
|
185
|
+
if (!cur || cur.stack !== name || cur.parent !== b.parent || cur.pr !== b.pr) drift++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let orphans = 0;
|
|
189
|
+
for (const branch of before.keys()) {
|
|
190
|
+
if (!desired.has(branch)) orphans++;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Apply the reconciliation (idempotent).
|
|
194
|
+
await writeMetadata(meta);
|
|
195
|
+
|
|
196
|
+
if (drift > 0 || orphans > 0) {
|
|
197
|
+
p.log.success(
|
|
198
|
+
`Reconciled branch config: ${drift} updated, ${orphans} stale membership(s) cleared.`,
|
|
199
|
+
);
|
|
200
|
+
} else {
|
|
201
|
+
p.log.info("Branch config already in sync with topology.");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (deadBranches.length > 0) {
|
|
205
|
+
console.log();
|
|
206
|
+
p.log.warn(`${deadBranches.length} branch(es) in topology no longer exist locally:`);
|
|
207
|
+
for (const { stack, branch } of deadBranches) {
|
|
208
|
+
console.log(` ${pc.red("•")} ${pc.yellow(branch)} ${pc.dim(`(stack: ${stack})`)}`);
|
|
209
|
+
}
|
|
210
|
+
console.log(
|
|
211
|
+
` ${pc.dim(`Prune with ${pc.green("gh-stack delete <branch>")} or restore the branch.`)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Base-stack-merged detection (issue #13) ──
|
|
217
|
+
|
|
218
|
+
async function flagMergedBaseStacks(meta: StackMetadata): Promise<void> {
|
|
219
|
+
const trunk = await git.trunkBranch();
|
|
220
|
+
const flagged: { stack: string; base: string; reason: string }[] = [];
|
|
221
|
+
|
|
222
|
+
for (const [name, stack] of Object.entries(meta.stacks)) {
|
|
223
|
+
const base = stackBase(stack);
|
|
224
|
+
if (base === "main" || base === "master") continue; // trunk-rooted: not affected
|
|
225
|
+
|
|
226
|
+
const baseExists = await git.localBranchExists(base);
|
|
227
|
+
if (!baseExists) {
|
|
228
|
+
flagged.push({
|
|
229
|
+
stack: name,
|
|
230
|
+
base,
|
|
231
|
+
reason: `base branch ${pc.yellow(base)} is gone (parent stack likely merged)`,
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Base still exists — has it already landed in trunk?
|
|
237
|
+
let baseInTrunk = false;
|
|
238
|
+
for (const ref of [`origin/${trunk}`, trunk]) {
|
|
239
|
+
try {
|
|
240
|
+
if (await git.isAncestor(base, ref)) {
|
|
241
|
+
baseInTrunk = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// ref not available locally — ignore
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (baseInTrunk) {
|
|
249
|
+
flagged.push({
|
|
250
|
+
stack: name,
|
|
251
|
+
base,
|
|
252
|
+
reason: `base ${pc.yellow(base)} is already in ${pc.cyan(trunk)}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (flagged.length === 0) return;
|
|
258
|
+
|
|
259
|
+
console.log();
|
|
260
|
+
p.log.warn("Stacks whose base appears merged into main:");
|
|
261
|
+
for (const { stack, reason } of flagged) {
|
|
262
|
+
console.log(` ${pc.yellow("⚠")} ${pc.yellow(stack)} — ${reason}`);
|
|
263
|
+
}
|
|
264
|
+
console.log();
|
|
265
|
+
console.log(` ${pc.dim("Re-root each onto main once its parent has merged:")}`);
|
|
266
|
+
console.log(` ${pc.green("gh-stack checkout <stack>")}`);
|
|
267
|
+
console.log(` ${pc.green("gh-stack restack --onto main")}`);
|
|
268
|
+
}
|
package/src/commands/down.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
|
-
import { findStackForBranch, writeMetadata } from "../lib/metadata.ts";
|
|
5
|
+
import { findStackForBranch, stackBase, writeMetadata } from "../lib/metadata.ts";
|
|
6
6
|
import { ensureMetadata } from "../lib/safety.ts";
|
|
7
7
|
|
|
8
8
|
export default async function down(args: string[]): Promise<void> {
|
|
@@ -35,6 +35,8 @@ OPTIONS
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const stack = meta.stacks[stackName]!;
|
|
38
|
+
const base = stackBase(stack);
|
|
39
|
+
let moved = 0;
|
|
38
40
|
|
|
39
41
|
for (let i = 0; i < steps; i++) {
|
|
40
42
|
const branchMeta = stack.branches[currentBranch];
|
|
@@ -43,17 +45,23 @@ OPTIONS
|
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// The stack bottoms out at its base (main for a normal stack, or the base
|
|
49
|
+
// branch for a split stack). The root's parent IS the base — stop there
|
|
50
|
+
// rather than crossing into a parent stack.
|
|
46
51
|
const parent = branchMeta.parent;
|
|
47
|
-
if (parent ===
|
|
48
|
-
if (
|
|
52
|
+
if (parent === base) {
|
|
53
|
+
if (moved === 0) {
|
|
49
54
|
p.log.warn(`${pc.yellow(currentBranch)} is already at the bottom of the stack`);
|
|
50
|
-
|
|
51
|
-
p.log.info(`Reached bottom of stack at ${pc.yellow(currentBranch)} after ${i} step(s)`);
|
|
55
|
+
return;
|
|
52
56
|
}
|
|
53
|
-
|
|
57
|
+
// Reached the bottom partway through a multi-step move — stop here but
|
|
58
|
+
// still check out as far as we got.
|
|
59
|
+
p.log.info(`Reached bottom of stack after ${moved} step(s)`);
|
|
60
|
+
break;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
currentBranch = parent;
|
|
64
|
+
moved++;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
await git.checkout(currentBranch);
|
package/src/commands/init.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { getPrNumber } from "../lib/github.ts";
|
|
14
14
|
import { isAutoYes } from "../lib/ui.ts";
|
|
15
15
|
import { detectBranchChain } from "../lib/chain.ts";
|
|
16
|
+
import { gateLegacyMetadata } from "../lib/safety.ts";
|
|
16
17
|
import type { StackMetadata, Branch } from "../types.ts";
|
|
17
18
|
|
|
18
19
|
const HELP = `
|
|
@@ -71,6 +72,8 @@ export default async function init(args: string[]): Promise<void> {
|
|
|
71
72
|
if (await metadataExists()) {
|
|
72
73
|
meta = (await readMetadata())!;
|
|
73
74
|
} else {
|
|
75
|
+
// Don't create a fresh v3 store on top of an unmigrated v2 file.
|
|
76
|
+
await gateLegacyMetadata();
|
|
74
77
|
meta = await initMetadata();
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -121,7 +124,7 @@ export default async function init(args: string[]): Promise<void> {
|
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
// ── Create stack ──
|
|
124
|
-
meta = await createStack(meta, stackName, descFlag || "");
|
|
127
|
+
meta = await createStack(meta, stackName, descFlag || "", trunk);
|
|
125
128
|
|
|
126
129
|
// ── Add branches with PR detection ──
|
|
127
130
|
const prSpinner = p.spinner();
|
package/src/commands/log.ts
CHANGED
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
writeMetadata,
|
|
10
10
|
} from "../lib/metadata.ts";
|
|
11
11
|
import { renderStackTree } from "../lib/ui.ts";
|
|
12
|
+
import { gateLegacyMetadata } from "../lib/safety.ts";
|
|
12
13
|
|
|
13
14
|
export default async function log(_args: string[]): Promise<void> {
|
|
14
15
|
// Check if metadata exists
|
|
15
16
|
if (!(await metadataExists())) {
|
|
17
|
+
await gateLegacyMetadata(); // exits with a migrate hint if a v2 file remains
|
|
16
18
|
p.log.warn("No stack metadata found");
|
|
17
19
|
console.log();
|
|
18
20
|
console.log(` Create your first stack with:`);
|