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/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
|
|
@@ -143,12 +154,40 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
|
143
154
|
then enables auto-merge for the base PR into main. All merges
|
|
144
155
|
happen on GitHub so PRs show as "Merged", Linear tickets close
|
|
145
156
|
automatically, and GitHub Actions fire normally. Skips already-
|
|
146
|
-
merged PRs (safe to re-run). Waits for GitHub between merges
|
|
157
|
+
merged PRs (safe to re-run). Waits for GitHub between merges, and
|
|
158
|
+
self-heals its spurious "Head branch is out of date" (a stale PR
|
|
159
|
+
head pointer after a child squash) by re-syncing and retrying.
|
|
147
160
|
--collapse Stop after collapsing the stack into the base PR;
|
|
148
161
|
leaves the base PR open against main so you can
|
|
149
162
|
review the cumulative diff on GitHub. Re-run
|
|
150
163
|
`gh-stack merge` (without --collapse) to finish.
|
|
151
164
|
|
|
165
|
+
split [<branch>] [--name <name>]
|
|
166
|
+
Cut the current stack into two at <branch>. The cut branch and every
|
|
167
|
+
branch above it move into a NEW stack whose base is the cut branch's
|
|
168
|
+
parent (which stays in the original stack). Purely a metadata
|
|
169
|
+
operation — no git branches are moved or rebased. Interactive
|
|
170
|
+
selector if no branch is given; you can't split at the stack's root.
|
|
171
|
+
|
|
172
|
+
Use it when a long chain is in review and can't merge yet, but new
|
|
173
|
+
work is piling on top: split at the first "new work" branch so the
|
|
174
|
+
original stack stays the review unit and the new stack rides on its
|
|
175
|
+
tip. Once the original stack merges, re-root the new stack with
|
|
176
|
+
`gh-stack restack --onto main`.
|
|
177
|
+
|
|
178
|
+
rename [<old-name>] <new-name>
|
|
179
|
+
Rename a tracked branch (runs `git branch -m`) and re-key the stack
|
|
180
|
+
topology so it stays tracked, re-parenting any children that pointed
|
|
181
|
+
at the old name. With one argument, renames the current branch. Built
|
|
182
|
+
for the throwaway-branch → ticketed-PR flow (`feature-wip` →
|
|
183
|
+
`feature-BEE-1234`) without hand-editing metadata.
|
|
184
|
+
|
|
185
|
+
You don't strictly need this command: gh-stack also auto-reconciles
|
|
186
|
+
after a plain `git branch -m` (the per-branch git config is rename-
|
|
187
|
+
proof, so the next gh-stack command heals the name-keyed topology).
|
|
188
|
+
Renaming a branch that already has a PR is local-only — the GitHub PR
|
|
189
|
+
still tracks the old remote branch.
|
|
190
|
+
|
|
152
191
|
delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
153
192
|
Remove a branch from the stack and re-parent its children, then
|
|
154
193
|
delete the underlying local git branch (and the remote branch if
|
|
@@ -164,8 +203,25 @@ log
|
|
|
164
203
|
Display the current stack as a tree with branch numbers,
|
|
165
204
|
PR info, and descriptions. This is the default command.
|
|
166
205
|
|
|
206
|
+
update-prs [--force]
|
|
207
|
+
Refresh the "📚 Stacked on" visualization block in every PR
|
|
208
|
+
description for the current stack. PRs whose rendered block is
|
|
209
|
+
unchanged are skipped; --force rewrites all of them. (submit does
|
|
210
|
+
this automatically — use this to refresh without pushing.)
|
|
211
|
+
|
|
212
|
+
stacks [--current] [--json]
|
|
213
|
+
List all stacks and their topology. Read-only and network-free —
|
|
214
|
+
emits stack membership straight from local metadata, so external
|
|
215
|
+
tools (status scripts, dashboards) can consume --json as a stable
|
|
216
|
+
interface instead of parsing the .git/.gh-stack/ store directly.
|
|
217
|
+
--current Only the stack containing the current branch
|
|
218
|
+
--json Topology as JSON: { current_stack, current_branch,
|
|
219
|
+
stacks: [{ name, description, base, is_current,
|
|
220
|
+
branches: [{ branch, parent, pr, description }] }] }
|
|
221
|
+
|
|
167
222
|
status [--current] [--json]
|
|
168
223
|
PR dashboard showing review state, CI status, and merge readiness.
|
|
224
|
+
Unlike `stacks`, this hits the network to fetch live PR/CI status.
|
|
169
225
|
--current Show only the current stack or standalone PR
|
|
170
226
|
--json Structured JSON output (progress goes to stderr)
|
|
171
227
|
|
|
@@ -174,6 +230,11 @@ undo
|
|
|
174
230
|
|
|
175
231
|
archive [--restore <name>]
|
|
176
232
|
List archived stacks by default, or restore one by name.
|
|
233
|
+
|
|
234
|
+
doctor
|
|
235
|
+
Migrate old (v2) metadata to the v3 layout, reconcile git branch
|
|
236
|
+
config against the topology files, and flag stacks whose base
|
|
237
|
+
stack appears already-merged into main. Safe to run repeatedly.
|
|
177
238
|
```
|
|
178
239
|
|
|
179
240
|
## Global Options
|
|
@@ -211,47 +272,92 @@ Snapshots also power `gh-stack undo`, so the same data structure does double dut
|
|
|
211
272
|
|
|
212
273
|
### Stack Visualization
|
|
213
274
|
|
|
214
|
-
`submit` automatically adds a stack section to all PR descriptions
|
|
275
|
+
`submit` automatically adds a stack section to all PR descriptions, with each
|
|
276
|
+
branch numbered by its position in the stack (handy for spotting "which one is
|
|
277
|
+
PR 6 of 12"):
|
|
215
278
|
|
|
216
279
|
```
|
|
217
280
|
### 📚 Stacked on
|
|
218
281
|
⚫ main
|
|
219
282
|
┃
|
|
220
|
-
┣━
|
|
283
|
+
┣━ 1. #123 Backend models
|
|
221
284
|
┃
|
|
222
|
-
┗━
|
|
285
|
+
┗━ 2. #124 Frontend UI 👈
|
|
223
286
|
```
|
|
224
287
|
|
|
225
|
-
|
|
288
|
+
The block is rendered entirely from local metadata plus one repo-identity
|
|
289
|
+
lookup for links — no per-PR API call — and `submit` only rewrites a PR
|
|
290
|
+
description whose block actually changed (membership/order/title), so a
|
|
291
|
+
re-submit that touched one branch on a 12-PR stack does ~1 update, not 12.
|
|
292
|
+
Review/CI status is intentionally not shown here. Run `gh-stack update-prs`
|
|
293
|
+
(`--force` to rewrite all) to refresh manually.
|
|
294
|
+
|
|
295
|
+
### Metadata (v3)
|
|
226
296
|
|
|
227
|
-
Stack metadata lives
|
|
297
|
+
Stack metadata lives under `.git/.gh-stack/` (never committed) — a folder of
|
|
298
|
+
per-stack files plus git-native branch config, rather than a single JSON blob:
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
.git/.gh-stack/
|
|
302
|
+
current hint: last-active stack name
|
|
303
|
+
active/<stack>.json topology of a live stack (ordered branches, base, description)
|
|
304
|
+
archived/<stack>.json merged/closed stacks
|
|
305
|
+
deleted/<stack>.json tombstones (recoverable)
|
|
306
|
+
snapshots/<ts>__<stack>.json append-only, retained per-stack
|
|
307
|
+
restack-state.json in-flight restack/sync resume state
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
A single `active/<stack>.json` looks like:
|
|
228
311
|
|
|
229
312
|
```json
|
|
230
313
|
{
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
-
"
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
"branches": {
|
|
238
|
-
"kiliman/feature-1": {
|
|
239
|
-
"parent": "main",
|
|
240
|
-
"pr": 21729
|
|
241
|
-
},
|
|
242
|
-
"kiliman/feature-2": {
|
|
243
|
-
"parent": "kiliman/feature-1",
|
|
244
|
-
"pr": 21730
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
314
|
+
"description": "",
|
|
315
|
+
"last_branch": "kiliman/feature-2",
|
|
316
|
+
"base": "main",
|
|
317
|
+
"branches": {
|
|
318
|
+
"kiliman/feature-1": { "parent": "main", "pr": 21729 },
|
|
319
|
+
"kiliman/feature-2": { "parent": "kiliman/feature-1", "pr": 21730 }
|
|
248
320
|
}
|
|
249
321
|
}
|
|
250
322
|
```
|
|
251
323
|
|
|
324
|
+
Per-branch membership is *also* recorded in git's own config, so renaming or
|
|
325
|
+
deleting a branch updates/cleans it automatically:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
branch.<name>.ghstack-stack <stack>
|
|
329
|
+
branch.<name>.ghstack-parent <parent-branch>
|
|
330
|
+
branch.<name>.ghstack-pr <number>
|
|
331
|
+
branch.<name>.ghstack-id <uuid> stable identity (survives rename)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The two representations cross-check each other; `gh-stack doctor` reconciles
|
|
335
|
+
any drift. Why this shape:
|
|
336
|
+
|
|
337
|
+
- **A stack can't silently vanish** — lifecycle transitions are file moves
|
|
338
|
+
(`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
|
|
339
|
+
just unlinked.
|
|
340
|
+
- **No `current_stack` drift** — the stack you're on is derivable from the
|
|
341
|
+
branch's own config, so a written pointer can't contradict reality.
|
|
342
|
+
- **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
|
|
343
|
+
- **Renames heal themselves** — `git branch -m old new` moves the whole
|
|
344
|
+
`branch.<old>` config section to `branch.<new>` (id and all), but the JSON is
|
|
345
|
+
keyed by name and goes stale. On the next command, gh-stack matches the moved
|
|
346
|
+
config to its stale JSON entry by `ghstack-id` (or PR number for older
|
|
347
|
+
branches), re-keys it, and re-points any children. So `gh-stack rename` — or
|
|
348
|
+
even a raw `git branch -m` — never needs manual metadata surgery.
|
|
349
|
+
|
|
350
|
+
#### Migrating from v2
|
|
351
|
+
|
|
352
|
+
Repos created before v3 store a single `.git/gh-stack-metadata.json`. Run
|
|
353
|
+
`gh-stack doctor` once — it fans the monolith out into the layout above,
|
|
354
|
+
backfills branch config, explodes snapshots into per-file records, and keeps a
|
|
355
|
+
`.bak` of the old file. Commands refuse to run on unmigrated metadata and point
|
|
356
|
+
you at `doctor`.
|
|
357
|
+
|
|
252
358
|
### Snapshots
|
|
253
359
|
|
|
254
|
-
Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs. Run `gh-stack undo` to restore.
|
|
360
|
+
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.
|
|
255
361
|
|
|
256
362
|
## Example Workflow
|
|
257
363
|
|
package/package.json
CHANGED
|
@@ -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:`);
|