gh-stack 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -24
- package/package.json +1 -1
- package/src/commands/doctor.ts +268 -0
- package/src/commands/down.ts +14 -6
- package/src/commands/init.ts +4 -1
- package/src/commands/log.ts +2 -0
- package/src/commands/merge.ts +19 -1
- package/src/commands/restack.ts +270 -44
- package/src/commands/split.ts +159 -0
- package/src/commands/status.ts +7 -0
- package/src/commands/submit.ts +30 -88
- package/src/commands/sync.ts +24 -4
- package/src/commands/update-prs.ts +201 -100
- package/src/index.ts +15 -0
- package/src/lib/branch-config.ts +126 -0
- package/src/lib/chain.ts +83 -50
- package/src/lib/git.ts +11 -0
- package/src/lib/github.ts +24 -0
- package/src/lib/metadata.ts +246 -60
- package/src/lib/paths.ts +69 -0
- package/src/lib/safety.ts +33 -7
- package/src/lib/snapshot.ts +83 -14
- package/src/lib/ui.ts +5 -2
- package/src/types.ts +21 -2
package/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,6 +160,19 @@ 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
|
|
|
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
|
+
|
|
152
176
|
delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
153
177
|
Remove a branch from the stack and re-parent its children, then
|
|
154
178
|
delete the underlying local git branch (and the remote branch if
|
|
@@ -174,6 +198,11 @@ undo
|
|
|
174
198
|
|
|
175
199
|
archive [--restore <name>]
|
|
176
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.
|
|
177
206
|
```
|
|
178
207
|
|
|
179
208
|
## Global Options
|
|
@@ -211,47 +240,85 @@ Snapshots also power `gh-stack undo`, so the same data structure does double dut
|
|
|
211
240
|
|
|
212
241
|
### Stack Visualization
|
|
213
242
|
|
|
214
|
-
`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"):
|
|
215
246
|
|
|
216
247
|
```
|
|
217
248
|
### 📚 Stacked on
|
|
218
249
|
⚫ main
|
|
219
250
|
┃
|
|
220
|
-
┣━
|
|
251
|
+
┣━ 1. #123 Backend models
|
|
221
252
|
┃
|
|
222
|
-
┗━
|
|
253
|
+
┗━ 2. #124 Frontend UI 👈
|
|
223
254
|
```
|
|
224
255
|
|
|
225
|
-
|
|
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
|
+
```
|
|
226
277
|
|
|
227
|
-
|
|
278
|
+
A single `active/<stack>.json` looks like:
|
|
228
279
|
|
|
229
280
|
```json
|
|
230
281
|
{
|
|
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
|
-
}
|
|
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 }
|
|
248
288
|
}
|
|
249
289
|
}
|
|
250
290
|
```
|
|
251
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
|
+
|
|
252
319
|
### Snapshots
|
|
253
320
|
|
|
254
|
-
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.
|
|
255
322
|
|
|
256
323
|
## Example Workflow
|
|
257
324
|
|
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:`);
|
package/src/commands/merge.ts
CHANGED
|
@@ -3,7 +3,12 @@ import * as p from "../lib/output.ts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { $ } from "bun";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
findStackForBranch,
|
|
8
|
+
getOrderedBranches,
|
|
9
|
+
stackBase,
|
|
10
|
+
writeMetadata,
|
|
11
|
+
} from "../lib/metadata.ts";
|
|
7
12
|
import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
|
|
8
13
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
9
14
|
import { getPrMergeState } from "../lib/github.ts";
|
|
@@ -84,6 +89,19 @@ OPTIONS
|
|
|
84
89
|
await ensureValidStack(meta, stackName);
|
|
85
90
|
const ordered = getOrderedBranches(stack);
|
|
86
91
|
|
|
92
|
+
// A split stack is rooted on a branch in another stack, so its base PR
|
|
93
|
+
// doesn't target main — merging it now would land it against the wrong base.
|
|
94
|
+
// Re-root onto main first (after the parent stack merges).
|
|
95
|
+
const base = stackBase(stack);
|
|
96
|
+
if (base !== "main" && base !== "master") {
|
|
97
|
+
p.cancel(
|
|
98
|
+
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
|
|
99
|
+
` Merge (or finish) the parent stack first, then re-root onto main:\n` +
|
|
100
|
+
` ${pc.green("gh-stack restack --onto main")}`,
|
|
101
|
+
);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
if (ordered.length <= 1) {
|
|
88
106
|
// Single branch — just enable auto-merge
|
|
89
107
|
const basePr = stack.branches[ordered[0]!]?.pr;
|