gh-stack 0.10.0 → 0.12.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 +46 -17
- package/package.json +1 -1
- package/src/commands/delete.ts +12 -2
- package/src/commands/list.ts +7 -9
- package/src/commands/log.ts +99 -19
- package/src/commands/merge.ts +4 -2
- package/src/commands/split.ts +12 -2
- package/src/commands/stacks.ts +5 -3
- package/src/commands/submit.ts +10 -7
- package/src/commands/update-prs.ts +76 -8
- package/src/lib/github.ts +12 -0
- package/src/lib/metadata.ts +83 -0
- package/src/lib/safety.ts +17 -0
package/README.md
CHANGED
|
@@ -85,8 +85,9 @@ create <branch-name> [--description <desc>]
|
|
|
85
85
|
|
|
86
86
|
submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
|
|
87
87
|
Push all downstack branches to GitHub, create PRs for branches
|
|
88
|
-
that don't have them,
|
|
89
|
-
|
|
88
|
+
that don't have them, number each PR title with its stack position
|
|
89
|
+
`(N/M)`, and update all PR descriptions with stack visualization.
|
|
90
|
+
Idempotent — safe to run repeatedly.
|
|
90
91
|
|
|
91
92
|
Self-healing: if the current branch isn't tracked in a stack yet,
|
|
92
93
|
submit auto-detects the chain from trunk → current, registers (or
|
|
@@ -163,11 +164,13 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
|
163
164
|
`gh-stack merge` (without --collapse) to finish.
|
|
164
165
|
|
|
165
166
|
split [<branch>] [--name <name>]
|
|
166
|
-
Cut
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
Cut a stack into two at <branch>. The cut branch and every branch
|
|
168
|
+
above it move into a NEW stack whose base is the cut branch's parent
|
|
169
|
+
(which stays in the original stack). The target stack is the one
|
|
170
|
+
containing <branch>, so this works from anywhere — not only while
|
|
171
|
+
standing on it. Purely a metadata operation — no git branches are
|
|
172
|
+
moved or rebased. Interactive selector (current stack) if no branch
|
|
173
|
+
is given; you can't split at the stack's root.
|
|
171
174
|
|
|
172
175
|
Use it when a long chain is in review and can't merge yet, but new
|
|
173
176
|
work is piling on top: split at the first "new work" branch so the
|
|
@@ -189,9 +192,11 @@ rename [<old-name>] <new-name>
|
|
|
189
192
|
still tracks the old remote branch.
|
|
190
193
|
|
|
191
194
|
delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
192
|
-
Remove a branch from
|
|
195
|
+
Remove a branch from its stack and re-parent its children, then
|
|
193
196
|
delete the underlying local git branch (and the remote branch if
|
|
194
|
-
it was pushed).
|
|
197
|
+
it was pushed). The <branch> argument names its own stack, so this
|
|
198
|
+
works from anywhere. Interactive selector (current stack) if none
|
|
199
|
+
specified.
|
|
195
200
|
--keep-branch leaves the git branches untouched (metadata only);
|
|
196
201
|
--no-remote deletes the local branch but keeps the remote.
|
|
197
202
|
```
|
|
@@ -203,6 +208,11 @@ log
|
|
|
203
208
|
Display the current stack as a tree with branch numbers,
|
|
204
209
|
PR info, and descriptions. This is the default command.
|
|
205
210
|
|
|
211
|
+
If the current branch isn't tracked yet, log inspects its ancestry
|
|
212
|
+
and points at the next step: if it sits on top of an existing stack,
|
|
213
|
+
`gh-stack submit` to add it; if it's stacked on untracked branches,
|
|
214
|
+
submit to create the stack and open PRs; if it's on trunk, `init`.
|
|
215
|
+
|
|
206
216
|
update-prs [--force]
|
|
207
217
|
Refresh the "📚 Stacked on" visualization block in every PR
|
|
208
218
|
description for the current stack. PRs whose rendered block is
|
|
@@ -270,19 +280,36 @@ gh-stack solves this by snapshotting every branch's tip **before** any destructi
|
|
|
270
280
|
|
|
271
281
|
Snapshots also power `gh-stack undo`, so the same data structure does double duty.
|
|
272
282
|
|
|
283
|
+
### Stack Position in PR Titles
|
|
284
|
+
|
|
285
|
+
`submit` keeps each PR's **title** suffixed with its stack position as `(N/M)`,
|
|
286
|
+
so you can see the order from a bare "needs your review" list — where only
|
|
287
|
+
titles show, not descriptions — and review bottom-up:
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
feat(paid-subs): tiers overview list [BEE-20531] (1/4)
|
|
291
|
+
feat(paid-subs): tier detail page [BEE-20550] (2/4)
|
|
292
|
+
feat(paid-subs): tier create wizard [BEE-20579] (3/4)
|
|
293
|
+
feat(paid-subs): advanced settings step [BEE-20587] (4/4)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Parentheses (not brackets) avoid colliding with `[BEE-1234]` ticket tags. It's
|
|
297
|
+
self-healing and idempotent: re-running `submit` after adding or reordering a
|
|
298
|
+
branch renumbers the whole stack, and a no-op re-submit changes nothing. The
|
|
299
|
+
position is the only managed part of the title — your text (and ticket tags)
|
|
300
|
+
are preserved. Single-PR stacks get no suffix.
|
|
301
|
+
|
|
273
302
|
### Stack Visualization
|
|
274
303
|
|
|
275
|
-
`submit`
|
|
276
|
-
branch numbered by its position in the stack (handy for spotting "which one is
|
|
277
|
-
PR 6 of 12"):
|
|
304
|
+
`submit` also adds a stack section to every PR description:
|
|
278
305
|
|
|
279
306
|
```
|
|
280
307
|
### 📚 Stacked on
|
|
281
308
|
⚫ main
|
|
282
309
|
┃
|
|
283
|
-
┣━
|
|
310
|
+
┣━ #123 feat: backend models (1/2)
|
|
284
311
|
┃
|
|
285
|
-
┗━
|
|
312
|
+
┗━ #124 feat: frontend UI (2/2) 👈
|
|
286
313
|
```
|
|
287
314
|
|
|
288
315
|
The block is rendered entirely from local metadata plus one repo-identity
|
|
@@ -299,7 +326,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
|
|
|
299
326
|
|
|
300
327
|
```
|
|
301
328
|
.git/.gh-stack/
|
|
302
|
-
current
|
|
329
|
+
current derived stack of the current branch (self-heals on read)
|
|
303
330
|
active/<stack>.json topology of a live stack (ordered branches, base, description)
|
|
304
331
|
archived/<stack>.json merged/closed stacks
|
|
305
332
|
deleted/<stack>.json tombstones (recoverable)
|
|
@@ -337,8 +364,10 @@ any drift. Why this shape:
|
|
|
337
364
|
- **A stack can't silently vanish** — lifecycle transitions are file moves
|
|
338
365
|
(`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
|
|
339
366
|
just unlinked.
|
|
340
|
-
- **No `current_stack` drift** — the stack you're on is
|
|
341
|
-
|
|
367
|
+
- **No `current_stack` drift** — the stack you're on is derived from the branch
|
|
368
|
+
you've checked out (its own membership, or the nearest tracked ancestor for a
|
|
369
|
+
new branch stacked on top), re-resolved and self-healed on every read, so a
|
|
370
|
+
written pointer can never contradict reality.
|
|
342
371
|
- **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
|
|
343
372
|
- **Renames heal themselves** — `git branch -m old new` moves the whole
|
|
344
373
|
`branch.<old>` config section to `branch.<new>` (id and all), but the JSON is
|
package/package.json
CHANGED
package/src/commands/delete.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ensureMetadata,
|
|
7
|
+
ensureCurrentStack,
|
|
8
|
+
ensureValidStack,
|
|
9
|
+
resolveStackForBranchArg,
|
|
10
|
+
} from "../lib/safety.ts";
|
|
6
11
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
7
12
|
import { selectBranch, confirmAction } from "../lib/ui.ts";
|
|
8
13
|
import * as git from "../lib/git.ts";
|
|
@@ -34,7 +39,12 @@ OPTIONS
|
|
|
34
39
|
const positional = args.filter((a) => !a.startsWith("-"));
|
|
35
40
|
|
|
36
41
|
const meta = await ensureMetadata();
|
|
37
|
-
|
|
42
|
+
// The branch to delete names its own stack — resolve from it so
|
|
43
|
+
// `delete <branch>` works from anywhere. Only fall back to the current stack
|
|
44
|
+
// (the branch you're on) in interactive mode, when no branch was given.
|
|
45
|
+
const stackName = positional[0]
|
|
46
|
+
? resolveStackForBranchArg(meta, positional[0]!)
|
|
47
|
+
: ensureCurrentStack(meta);
|
|
38
48
|
await ensureValidStack(meta, stackName);
|
|
39
49
|
const stack = meta.stacks[stackName]!;
|
|
40
50
|
const currentBranch = await git.currentBranch();
|
package/src/commands/list.ts
CHANGED
|
@@ -25,24 +25,22 @@ ALIASES
|
|
|
25
25
|
const meta = await ensureMetadata();
|
|
26
26
|
const currentBranch = await git.currentBranch();
|
|
27
27
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// The current stack is whichever stack the checked-out branch belongs to —
|
|
29
|
+
// never the persisted `current_stack` hint. If this branch is in no stack,
|
|
30
|
+
// there is no current stack; don't resurface a previously-used one.
|
|
31
|
+
const stackName = findStackForBranch(meta, currentBranch);
|
|
32
32
|
|
|
33
33
|
if (!stackName || !meta.stacks[stackName]) {
|
|
34
|
-
console.log("
|
|
34
|
+
console.log("Branch is not in any stack.");
|
|
35
35
|
process.exit(1);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const stack = meta.stacks[stackName]!;
|
|
39
39
|
const ordered = getOrderedBranches(stack);
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// We're on a branch in this stack — record it as current + its last_branch.
|
|
42
42
|
meta.current_stack = stackName;
|
|
43
|
-
|
|
44
|
-
stack.last_branch = currentBranch;
|
|
45
|
-
}
|
|
43
|
+
stack.last_branch = currentBranch;
|
|
46
44
|
await writeMetadata(meta);
|
|
47
45
|
|
|
48
46
|
// Header
|
package/src/commands/log.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "../lib/metadata.ts";
|
|
11
11
|
import { renderStackTree } from "../lib/ui.ts";
|
|
12
12
|
import { gateLegacyMetadata } from "../lib/safety.ts";
|
|
13
|
+
import { detectBranchChain } from "../lib/chain.ts";
|
|
14
|
+
import type { StackMetadata } from "../types.ts";
|
|
13
15
|
|
|
14
16
|
export default async function log(_args: string[]): Promise<void> {
|
|
15
17
|
// Check if metadata exists
|
|
@@ -30,33 +32,21 @@ export default async function log(_args: string[]): Promise<void> {
|
|
|
30
32
|
|
|
31
33
|
const branch = await git.currentBranch();
|
|
32
34
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!stackName) {
|
|
38
|
-
// Fall back to current_stack
|
|
39
|
-
stackName = meta.current_stack;
|
|
40
|
-
}
|
|
35
|
+
// The current stack is whichever stack the checked-out branch belongs to —
|
|
36
|
+
// never the persisted `current_stack` hint. If this branch is in no stack,
|
|
37
|
+
// there is no current stack; don't resurface a previously-used one.
|
|
38
|
+
const stackName = findStackForBranch(meta, branch);
|
|
41
39
|
|
|
42
40
|
if (!stackName || !meta.stacks[stackName]) {
|
|
43
|
-
|
|
44
|
-
console.log();
|
|
45
|
-
console.log(` Add it to a stack with:`);
|
|
46
|
-
console.log(` ${pc.green("gh-stack add")}`);
|
|
47
|
-
console.log();
|
|
48
|
-
console.log(` Or create a new stack:`);
|
|
49
|
-
console.log(` ${pc.green("gh-stack init")}`);
|
|
41
|
+
await suggestNextStep(meta, branch);
|
|
50
42
|
process.exit(1);
|
|
51
43
|
}
|
|
52
44
|
|
|
53
45
|
const stack = meta.stacks[stackName]!;
|
|
54
46
|
|
|
55
|
-
//
|
|
47
|
+
// We're on a branch in this stack — record it as current + its last_branch.
|
|
56
48
|
meta.current_stack = stackName;
|
|
57
|
-
|
|
58
|
-
stack.last_branch = branch;
|
|
59
|
-
}
|
|
49
|
+
stack.last_branch = branch;
|
|
60
50
|
await writeMetadata(meta);
|
|
61
51
|
|
|
62
52
|
// Header
|
|
@@ -76,3 +66,93 @@ export default async function log(_args: string[]): Promise<void> {
|
|
|
76
66
|
console.log(pc.dim(`Tip: Switch stacks with 'gh-stack checkout --stack'`));
|
|
77
67
|
console.log();
|
|
78
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The current branch isn't a tracked stack member. Inspect its local ancestry —
|
|
72
|
+
* the same chain `submit` would self-heal — and point at the lowest-friction
|
|
73
|
+
* next step:
|
|
74
|
+
* - sits on top of an existing stack → `submit` to ADD it to that stack;
|
|
75
|
+
* - stacked on untracked branches only → `submit` to CREATE a new stack;
|
|
76
|
+
* - based directly on trunk → `submit` to push + open a PR;
|
|
77
|
+
* - on trunk → `init`.
|
|
78
|
+
*/
|
|
79
|
+
async function suggestNextStep(meta: StackMetadata, branch: string): Promise<void> {
|
|
80
|
+
const trunk = await git.trunkBranch();
|
|
81
|
+
|
|
82
|
+
if (branch === trunk || branch === "main" || branch === "master") {
|
|
83
|
+
p.log.warn(`You're on ${pc.blue(branch)} — not a stack branch`);
|
|
84
|
+
console.log();
|
|
85
|
+
console.log(` Checkout a feature branch, then start a stack with:`);
|
|
86
|
+
console.log(` ${pc.green("gh-stack init")}`);
|
|
87
|
+
console.log();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Reconstruct trunk→current the way `submit` does (local branches whose tips
|
|
92
|
+
// are ancestors of HEAD). [current] alone means it's based right off trunk.
|
|
93
|
+
let chain: string[] = [branch];
|
|
94
|
+
try {
|
|
95
|
+
chain = await detectBranchChain(branch, trunk);
|
|
96
|
+
} catch {
|
|
97
|
+
// Fall back to treating it as a single branch off trunk.
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Is this chain sitting on top of an existing tracked stack? Find the nearest
|
|
101
|
+
// ancestor in the chain that's already a stack member.
|
|
102
|
+
let attachStack: string | null = null;
|
|
103
|
+
for (let i = chain.length - 2; i >= 0; i--) {
|
|
104
|
+
const owner = findStackForBranch(meta, chain[i]!);
|
|
105
|
+
if (owner) {
|
|
106
|
+
attachStack = owner;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
p.log.warn(`Branch ${pc.blue(branch)} isn't tracked in a stack yet`);
|
|
112
|
+
console.log();
|
|
113
|
+
|
|
114
|
+
if (attachStack) {
|
|
115
|
+
// Extends an existing stack — submit ADDS the untracked tail to it.
|
|
116
|
+
console.log(` It sits on top of stack ${pc.yellow(attachStack)}:`);
|
|
117
|
+
console.log();
|
|
118
|
+
console.log(` ${pc.dim(trunk)}`);
|
|
119
|
+
for (let i = 0; i < chain.length; i++) {
|
|
120
|
+
const b = chain[i]!;
|
|
121
|
+
const isLast = i === chain.length - 1;
|
|
122
|
+
const tree = isLast ? " ┗━" : " ┣━";
|
|
123
|
+
const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
|
|
124
|
+
const tag = findStackForBranch(meta, b) ? pc.dim(" (in stack)") : pc.green(" +");
|
|
125
|
+
console.log(`${tree} ${label}${tag}`);
|
|
126
|
+
if (!isLast) console.log(" ┃");
|
|
127
|
+
}
|
|
128
|
+
console.log();
|
|
129
|
+
console.log(` Add it to the stack:`);
|
|
130
|
+
console.log(` ${pc.green("gh-stack submit")}`);
|
|
131
|
+
console.log();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (chain.length > 1) {
|
|
136
|
+
// Stacked on untracked branches only — submit CREATES a new stack.
|
|
137
|
+
console.log(` It's stacked on ${chain.length - 1} other local branch(es):`);
|
|
138
|
+
console.log();
|
|
139
|
+
console.log(` ${pc.dim(trunk)}`);
|
|
140
|
+
for (let i = 0; i < chain.length; i++) {
|
|
141
|
+
const b = chain[i]!;
|
|
142
|
+
const isLast = i === chain.length - 1;
|
|
143
|
+
const tree = isLast ? " ┗━" : " ┣━";
|
|
144
|
+
const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
|
|
145
|
+
console.log(`${tree} ${label}`);
|
|
146
|
+
if (!isLast) console.log(" ┃");
|
|
147
|
+
}
|
|
148
|
+
console.log();
|
|
149
|
+
console.log(` Create the stack and open PRs for the whole chain:`);
|
|
150
|
+
} else {
|
|
151
|
+
// A single branch based directly on trunk.
|
|
152
|
+
console.log(` It's based directly on ${pc.dim(trunk)}.`);
|
|
153
|
+
console.log();
|
|
154
|
+
console.log(` Push it and open a PR — this also starts the stack:`);
|
|
155
|
+
}
|
|
156
|
+
console.log(` ${pc.green("gh-stack submit")}`);
|
|
157
|
+
console.log();
|
|
158
|
+
}
|
package/src/commands/merge.ts
CHANGED
|
@@ -513,8 +513,10 @@ OPTIONS
|
|
|
513
513
|
meta.archive[stackName] = { ...stack };
|
|
514
514
|
delete meta.stacks[stackName];
|
|
515
515
|
if (meta.current_stack === stackName) {
|
|
516
|
-
|
|
517
|
-
|
|
516
|
+
// Don't auto-pick a random leftover stack as "current" — that switches you
|
|
517
|
+
// to a stack you never checked out. The current stack is derived from the
|
|
518
|
+
// branch you're on; after a merge you're on main, so default to none.
|
|
519
|
+
meta.current_stack = null;
|
|
518
520
|
}
|
|
519
521
|
await writeMetadata(meta);
|
|
520
522
|
|
package/src/commands/split.ts
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
import * as p from "../lib/output.ts";
|
|
13
13
|
import pc from "picocolors";
|
|
14
14
|
import { buildRebaseChain, getOrderedBranches, stackBase, writeMetadata } from "../lib/metadata.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
ensureMetadata,
|
|
17
|
+
ensureCurrentStack,
|
|
18
|
+
ensureValidStack,
|
|
19
|
+
resolveStackForBranchArg,
|
|
20
|
+
} from "../lib/safety.ts";
|
|
16
21
|
import { selectBranch, confirmAction } from "../lib/ui.ts";
|
|
17
22
|
import * as git from "../lib/git.ts";
|
|
18
23
|
import type { Stack } from "../types.ts";
|
|
@@ -55,7 +60,12 @@ export default async function split(args: string[]): Promise<void> {
|
|
|
55
60
|
const positional = args.filter((a) => !a.startsWith("-"));
|
|
56
61
|
|
|
57
62
|
const meta = await ensureMetadata();
|
|
58
|
-
|
|
63
|
+
// The cut branch names its own stack — resolve from it so `split <branch>`
|
|
64
|
+
// works from anywhere. Only fall back to the current stack (the branch you're
|
|
65
|
+
// on) in interactive mode, when no branch was given.
|
|
66
|
+
const stackName = positional[0]
|
|
67
|
+
? resolveStackForBranchArg(meta, positional[0]!)
|
|
68
|
+
: ensureCurrentStack(meta);
|
|
59
69
|
await ensureValidStack(meta, stackName);
|
|
60
70
|
const stack = meta.stacks[stackName]!;
|
|
61
71
|
const currentBranch = await git.currentBranch();
|
package/src/commands/stacks.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
metadataExists,
|
|
12
12
|
legacyMetadataExists,
|
|
13
13
|
getOrderedBranches,
|
|
14
|
-
findStackForBranch,
|
|
15
14
|
stackBase,
|
|
16
15
|
} from "../lib/metadata.ts";
|
|
17
16
|
|
|
@@ -72,7 +71,10 @@ metadata. Use --json as a stable interface so scripts don't parse the
|
|
|
72
71
|
// Detached HEAD or not on a branch — fine, just no "current" context.
|
|
73
72
|
}
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
// readMetadata already resolved the current stack authoritatively: the
|
|
75
|
+
// branch's own stack, or — for a new branch built on top of one — the nearest
|
|
76
|
+
// tracked ancestor's stack, or null. Trust it rather than re-deriving.
|
|
77
|
+
const currentStackName = meta.current_stack;
|
|
76
78
|
|
|
77
79
|
const entries = Object.entries(meta.stacks)
|
|
78
80
|
.filter(([name]) => !currentOnly || name === currentStackName)
|
|
@@ -99,7 +101,7 @@ metadata. Use --json as a stable interface so scripts don't parse the
|
|
|
99
101
|
console.log(
|
|
100
102
|
JSON.stringify(
|
|
101
103
|
{
|
|
102
|
-
current_stack: currentStackName
|
|
104
|
+
current_stack: currentStackName,
|
|
103
105
|
current_branch: currentBranch,
|
|
104
106
|
stacks: entries,
|
|
105
107
|
},
|
package/src/commands/submit.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { isAutoYes } from "../lib/ui.ts";
|
|
|
13
13
|
import type { StackMetadata } from "../types.ts";
|
|
14
14
|
import { getPrNumber } from "../lib/github.ts";
|
|
15
15
|
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
16
|
-
import { refreshStackViz, branchToTitle } from "./update-prs.ts";
|
|
16
|
+
import { reconcilePrTitles, refreshStackViz, branchToTitle } from "./update-prs.ts";
|
|
17
17
|
|
|
18
18
|
const HELP = `
|
|
19
19
|
gh-stack submit — Push branches and create/update PRs
|
|
@@ -353,14 +353,17 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
353
353
|
return;
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
// ── Phase 2:
|
|
357
|
-
//
|
|
358
|
-
|
|
356
|
+
// ── Phase 2: Reconcile PR titles (stack position) then refresh the stack
|
|
357
|
+
// visualization. Titles first so the viz renders the numbered titles. Both
|
|
358
|
+
// render locally and only hit the network for PRs that actually changed
|
|
359
|
+
// (see issue #16), so a no-op re-submit is free. ──
|
|
360
|
+
p.log.info(pc.cyan("Updating PR titles & stack visualization..."));
|
|
361
|
+
const titles = await reconcilePrTitles(stack, ordered);
|
|
359
362
|
const viz = await refreshStackViz(meta, stack, ordered);
|
|
360
363
|
|
|
361
364
|
// One persist for everything we cached this run (discovered PRs, base edits,
|
|
362
|
-
// backfilled titles, viz hashes).
|
|
363
|
-
if (metadataChanged || viz.dirty) {
|
|
365
|
+
// backfilled/renumbered titles, viz hashes).
|
|
366
|
+
if (metadataChanged || titles.dirty || viz.dirty) {
|
|
364
367
|
await writeMetadata(meta);
|
|
365
368
|
}
|
|
366
369
|
|
|
@@ -368,7 +371,7 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
368
371
|
console.log();
|
|
369
372
|
p.outro(
|
|
370
373
|
pc.green(
|
|
371
|
-
`Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s)` +
|
|
374
|
+
`Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s), ${titles.updated} title(s)` +
|
|
372
375
|
(viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
|
|
373
376
|
),
|
|
374
377
|
);
|
|
@@ -16,8 +16,15 @@ import {
|
|
|
16
16
|
writeMetadata,
|
|
17
17
|
} from "../lib/metadata.ts";
|
|
18
18
|
import { ensureMetadata } from "../lib/safety.ts";
|
|
19
|
-
import {
|
|
20
|
-
|
|
19
|
+
import {
|
|
20
|
+
getPrInfo,
|
|
21
|
+
getPrBody,
|
|
22
|
+
updatePrBody,
|
|
23
|
+
updatePrTitle,
|
|
24
|
+
getRepoNwo,
|
|
25
|
+
prUrlFor,
|
|
26
|
+
} from "../lib/github.ts";
|
|
27
|
+
import type { StackMetadata, Stack, Branch } from "../types.ts";
|
|
21
28
|
|
|
22
29
|
/** The ref a stack is rooted on, for rendering the base node of the viz. */
|
|
23
30
|
export interface BaseRef {
|
|
@@ -103,6 +110,66 @@ function mergeViz(body: string, viz: string): string {
|
|
|
103
110
|
return base ? `${base}\n\n${viz}` : viz;
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
// ── PR-title stack numbering ──
|
|
114
|
+
//
|
|
115
|
+
// The stack position lives in the PR title as a ` (N/M)` suffix so reviewers
|
|
116
|
+
// can see order from a bare "needs review" list (parens, not brackets, to avoid
|
|
117
|
+
// colliding with `[BEE-1234]` ticket tags). The cached `prTitle` tracks exactly
|
|
118
|
+
// what's on GitHub; we recover the base title by stripping the suffix and
|
|
119
|
+
// re-applying the current position, so reordering/adding a branch renumbers the
|
|
120
|
+
// whole stack and a no-op re-submit touches nothing.
|
|
121
|
+
|
|
122
|
+
/** Matches a trailing ` (N/M)` stack-position suffix, e.g. " (2/4)". */
|
|
123
|
+
const SEQ_SUFFIX = /\s*\(\d+\/\d+\)\s*$/;
|
|
124
|
+
|
|
125
|
+
/** Strip a trailing ` (N/M)` suffix to recover the base PR title. */
|
|
126
|
+
export function stripSeqSuffix(title: string): string {
|
|
127
|
+
return title.replace(SEQ_SUFFIX, "").trimEnd();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The title a branch at `position` of `total` should have. Idempotent. */
|
|
131
|
+
export function numberedTitle(title: string, position: number, total: number): string {
|
|
132
|
+
const base = stripSeqSuffix(title);
|
|
133
|
+
return total >= 2 ? `${base} (${position}/${total})` : base;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface TitleResult {
|
|
137
|
+
updated: number; // PR titles we edited on GitHub
|
|
138
|
+
dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reconcile every PR title in the stack to carry its `(N/M)` stack position.
|
|
143
|
+
* Backfills any missing cached titles first (so we never clobber a real GitHub
|
|
144
|
+
* title with a branch-name guess), renders the desired title from the cached
|
|
145
|
+
* one, and only calls `gh pr edit --title` when it actually changed — a no-op
|
|
146
|
+
* re-submit makes zero edits, while adding/reordering a branch renumbers the
|
|
147
|
+
* whole stack. Run this BEFORE refreshStackViz so the viz shows numbered titles.
|
|
148
|
+
*/
|
|
149
|
+
export async function reconcilePrTitles(stack: Stack, ordered: string[]): Promise<TitleResult> {
|
|
150
|
+
const dirtyBackfill = await backfillTitles(stack, ordered);
|
|
151
|
+
const total = ordered.length;
|
|
152
|
+
|
|
153
|
+
const plans: { pr: number; desired: string; bm: Branch }[] = [];
|
|
154
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
155
|
+
const bm = stack.branches[ordered[i]!]!;
|
|
156
|
+
// Need a known base title — never guess from the branch name for an
|
|
157
|
+
// existing PR (backfill couldn't fetch it → skip rather than clobber).
|
|
158
|
+
if (bm.pr == null || !bm.prTitle) continue;
|
|
159
|
+
const desired = numberedTitle(bm.prTitle, i + 1, total);
|
|
160
|
+
if (desired !== bm.prTitle) plans.push({ pr: bm.pr, desired, bm });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const oks = await mapLimit(plans, 8, async (plan) => {
|
|
164
|
+
const ok = await updatePrTitle(plan.pr, plan.desired);
|
|
165
|
+
if (ok) plan.bm.prTitle = plan.desired;
|
|
166
|
+
return ok;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const updated = oks.filter(Boolean).length;
|
|
170
|
+
return { updated, dirty: dirtyBackfill || updated > 0 };
|
|
171
|
+
}
|
|
172
|
+
|
|
106
173
|
export interface VizResult {
|
|
107
174
|
updated: number; // PRs whose description we PATCHed
|
|
108
175
|
unchanged: number; // PRs skipped because their rendered block was identical
|
|
@@ -192,16 +259,17 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
|
|
|
192
259
|
console.log();
|
|
193
260
|
|
|
194
261
|
const s = p.spinner();
|
|
195
|
-
s.start("Updating PR descriptions...");
|
|
262
|
+
s.start("Updating PR titles & descriptions...");
|
|
263
|
+
const titles = await reconcilePrTitles(stack, ordered);
|
|
196
264
|
const result = await refreshStackViz(meta, stack, ordered, { force });
|
|
197
265
|
s.stop("Done");
|
|
198
266
|
|
|
199
|
-
if (result.dirty) await writeMetadata(meta);
|
|
267
|
+
if (titles.dirty || result.dirty) await writeMetadata(meta);
|
|
200
268
|
|
|
201
269
|
console.log();
|
|
202
270
|
p.outro(
|
|
203
271
|
pc.green(
|
|
204
|
-
`Updated ${result.updated}
|
|
272
|
+
`Updated ${result.updated} description(s), ${titles.updated} title(s)` +
|
|
205
273
|
(result.unchanged > 0 ? `, ${result.unchanged} unchanged` : "") +
|
|
206
274
|
(result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
|
|
207
275
|
),
|
|
@@ -249,9 +317,9 @@ export function buildStackViz(
|
|
|
249
317
|
// Tree character
|
|
250
318
|
const tree = isLast ? "┗━" : "┣━";
|
|
251
319
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
lines.push(`${tree} ${
|
|
320
|
+
// No leading `N.` — the stack position now lives in the PR title itself as
|
|
321
|
+
// a `(N/M)` suffix (see reconcilePrTitles), so the tree just shows titles.
|
|
322
|
+
lines.push(`${tree} ${prLink} ${info.prTitle}${marker}`);
|
|
255
323
|
|
|
256
324
|
if (!isLast) {
|
|
257
325
|
lines.push("┃");
|
package/src/lib/github.ts
CHANGED
|
@@ -136,6 +136,18 @@ export async function updatePrBody(prNumber: number, body: string): Promise<bool
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Update a PR's title.
|
|
141
|
+
*/
|
|
142
|
+
export async function updatePrTitle(prNumber: number, title: string): Promise<boolean> {
|
|
143
|
+
try {
|
|
144
|
+
await $`gh pr edit ${prNumber} --title ${title}`.quiet();
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
139
151
|
/**
|
|
140
152
|
* Get PR body text.
|
|
141
153
|
*/
|
package/src/lib/metadata.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type BranchMembership,
|
|
32
32
|
} from "./branch-config.ts";
|
|
33
33
|
import { loadSnapshots } from "./snapshot.ts";
|
|
34
|
+
import * as git from "./git.ts";
|
|
34
35
|
|
|
35
36
|
async function pathExists(p: string): Promise<boolean> {
|
|
36
37
|
try {
|
|
@@ -136,9 +137,91 @@ export async function readMetadata(): Promise<StackMetadata | null> {
|
|
|
136
137
|
// (which also repoints renamed branches' child config). Keeping reads
|
|
137
138
|
// side-effect-free avoids surprising writes from `status`/`--dry-run`.
|
|
138
139
|
await reconcileRenames(meta);
|
|
140
|
+
// The current stack is *derived* from the branch you're on, never trusted
|
|
141
|
+
// from the stored hint. Every read re-verifies it (and corrects the on-disk
|
|
142
|
+
// pointer) so no command can act on a stack you've already left.
|
|
143
|
+
await reconcileCurrentStack(meta);
|
|
139
144
|
return meta;
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Resolve and pin `meta.current_stack` to whichever active stack owns the
|
|
149
|
+
* checked-out branch — the single source of truth for "what stack am I on."
|
|
150
|
+
*
|
|
151
|
+
* Resolution, mirroring the on-disk authority order:
|
|
152
|
+
* 1. The branch's git config (`branch.<name>.ghstack-stack`) — rename-proof
|
|
153
|
+
* membership — *if* the named stack is active and still lists the branch.
|
|
154
|
+
* 2. Otherwise scan the assembled active stacks for the branch by name.
|
|
155
|
+
* 3. Otherwise walk up the branch's ancestry: a new branch built on top of a
|
|
156
|
+
* stack belongs to that stack (until submitted), so the nearest *tracked*
|
|
157
|
+
* ancestor's stack is the current one. Only tracked branches are probed,
|
|
158
|
+
* so this stays cheap even with many local branches.
|
|
159
|
+
* 4. Otherwise (ancestry reaches trunk with nothing tracked, or HEAD
|
|
160
|
+
* detached) there is no current stack → null.
|
|
161
|
+
*
|
|
162
|
+
* The resolved value is also written back to the `current` pointer file when it
|
|
163
|
+
* drifts, so a stale hint left by a prior session, a merge, or a plain
|
|
164
|
+
* `git checkout` self-heals on the very next command. Only the pointer file is
|
|
165
|
+
* touched — stack topology, refs, and snapshots are never written here, so this
|
|
166
|
+
* stays safe for read-only / `--dry-run` commands.
|
|
167
|
+
*/
|
|
168
|
+
async function reconcileCurrentStack(meta: StackMetadata): Promise<void> {
|
|
169
|
+
let branch: string | null = null;
|
|
170
|
+
try {
|
|
171
|
+
branch = await git.currentBranch();
|
|
172
|
+
} catch {
|
|
173
|
+
// Detached HEAD / not on a branch → no current stack.
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let resolved: string | null = null;
|
|
177
|
+
if (branch) {
|
|
178
|
+
// 1. Trust branch config only if the topology still backs it up.
|
|
179
|
+
const config = await allBranchMemberships();
|
|
180
|
+
const configStack = config.get(branch)?.stack;
|
|
181
|
+
if (configStack && meta.stacks[configStack]?.branches[branch]) {
|
|
182
|
+
resolved = configStack;
|
|
183
|
+
} else {
|
|
184
|
+
// 2. Fall back to scanning the active stacks for direct membership.
|
|
185
|
+
resolved = findStackForBranch(meta, branch);
|
|
186
|
+
}
|
|
187
|
+
// 3. Untracked branch — inherit the stack of its nearest tracked ancestor.
|
|
188
|
+
if (!resolved) {
|
|
189
|
+
resolved = await nearestTrackedAncestorStack(meta, branch);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (meta.current_stack !== resolved) {
|
|
194
|
+
meta.current_stack = resolved;
|
|
195
|
+
await Bun.write(await currentFile(), (resolved ?? "") + "\n");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Walk up `branch`'s ancestry and return the stack of the nearest branch that's
|
|
201
|
+
* already a tracked member of an active stack — i.e. the stack this branch sits
|
|
202
|
+
* on top of. Probes only tracked branches (small N), picking the closest one by
|
|
203
|
+
* commit distance, so a chain that spans two stacks resolves to the nearer one.
|
|
204
|
+
* Returns null when no tracked branch is an ancestor (the branch traces back to
|
|
205
|
+
* trunk without crossing a stack).
|
|
206
|
+
*/
|
|
207
|
+
export async function nearestTrackedAncestorStack(
|
|
208
|
+
meta: StackMetadata,
|
|
209
|
+
branch: string,
|
|
210
|
+
): Promise<string | null> {
|
|
211
|
+
let best: { stack: string; distance: number } | null = null;
|
|
212
|
+
for (const [stackName, stack] of Object.entries(meta.stacks)) {
|
|
213
|
+
for (const member of Object.keys(stack.branches)) {
|
|
214
|
+
if (member === branch) continue;
|
|
215
|
+
if (!(await git.isAncestor(member, branch))) continue;
|
|
216
|
+
const distance = await git.commitCount(member, branch);
|
|
217
|
+
if (distance > 0 && (!best || distance < best.distance)) {
|
|
218
|
+
best = { stack: stackName, distance };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return best?.stack ?? null;
|
|
223
|
+
}
|
|
224
|
+
|
|
142
225
|
/**
|
|
143
226
|
* The ref a stack is rooted on. Defaults to "main" for stacks created before
|
|
144
227
|
* the `base` field existed.
|
package/src/lib/safety.ts
CHANGED
|
@@ -71,6 +71,23 @@ export function ensureCurrentStack(meta: StackMetadata): string {
|
|
|
71
71
|
return stack;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the stack to operate on from a branch argument. The branch names its
|
|
76
|
+
* own stack unambiguously, so commands that take a branch (split, delete) use
|
|
77
|
+
* this instead of trusting the current stack — letting `<cmd> <branch>` work
|
|
78
|
+
* from anywhere, not only while standing on that stack.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveStackForBranchArg(meta: StackMetadata, branch: string): string {
|
|
81
|
+
const stackName = findStackForBranch(meta, branch);
|
|
82
|
+
if (!stackName) {
|
|
83
|
+
p.cancel(
|
|
84
|
+
`Branch ${pc.yellow(branch)} is not in any stack.\n\n See your stacks with:\n ${pc.green("gh-stack stacks")}`,
|
|
85
|
+
);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
return stackName;
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
/**
|
|
75
92
|
* Ensure current branch is in a stack. Returns the stack name.
|
|
76
93
|
*/
|