gh-stack 0.9.0 → 0.11.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 +61 -11
- 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 +171 -24
- package/src/commands/rename.ts +111 -0
- package/src/commands/split.ts +12 -2
- package/src/commands/stacks.ts +138 -0
- package/src/index.ts +10 -0
- package/src/lib/branch-config.ts +6 -0
- package/src/lib/git.ts +10 -0
- package/src/lib/github.ts +49 -5
- package/src/lib/metadata.ts +194 -2
- package/src/lib/safety.ts +17 -0
- package/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -154,18 +154,22 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
|
154
154
|
then enables auto-merge for the base PR into main. All merges
|
|
155
155
|
happen on GitHub so PRs show as "Merged", Linear tickets close
|
|
156
156
|
automatically, and GitHub Actions fire normally. Skips already-
|
|
157
|
-
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.
|
|
158
160
|
--collapse Stop after collapsing the stack into the base PR;
|
|
159
161
|
leaves the base PR open against main so you can
|
|
160
162
|
review the cumulative diff on GitHub. Re-run
|
|
161
163
|
`gh-stack merge` (without --collapse) to finish.
|
|
162
164
|
|
|
163
165
|
split [<branch>] [--name <name>]
|
|
164
|
-
Cut
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
Cut a stack into two at <branch>. The cut branch and every branch
|
|
167
|
+
above it move into a NEW stack whose base is the cut branch's parent
|
|
168
|
+
(which stays in the original stack). The target stack is the one
|
|
169
|
+
containing <branch>, so this works from anywhere — not only while
|
|
170
|
+
standing on it. Purely a metadata operation — no git branches are
|
|
171
|
+
moved or rebased. Interactive selector (current stack) if no branch
|
|
172
|
+
is given; you can't split at the stack's root.
|
|
169
173
|
|
|
170
174
|
Use it when a long chain is in review and can't merge yet, but new
|
|
171
175
|
work is piling on top: split at the first "new work" branch so the
|
|
@@ -173,10 +177,25 @@ split [<branch>] [--name <name>]
|
|
|
173
177
|
tip. Once the original stack merges, re-root the new stack with
|
|
174
178
|
`gh-stack restack --onto main`.
|
|
175
179
|
|
|
180
|
+
rename [<old-name>] <new-name>
|
|
181
|
+
Rename a tracked branch (runs `git branch -m`) and re-key the stack
|
|
182
|
+
topology so it stays tracked, re-parenting any children that pointed
|
|
183
|
+
at the old name. With one argument, renames the current branch. Built
|
|
184
|
+
for the throwaway-branch → ticketed-PR flow (`feature-wip` →
|
|
185
|
+
`feature-BEE-1234`) without hand-editing metadata.
|
|
186
|
+
|
|
187
|
+
You don't strictly need this command: gh-stack also auto-reconciles
|
|
188
|
+
after a plain `git branch -m` (the per-branch git config is rename-
|
|
189
|
+
proof, so the next gh-stack command heals the name-keyed topology).
|
|
190
|
+
Renaming a branch that already has a PR is local-only — the GitHub PR
|
|
191
|
+
still tracks the old remote branch.
|
|
192
|
+
|
|
176
193
|
delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
177
|
-
Remove a branch from
|
|
194
|
+
Remove a branch from its stack and re-parent its children, then
|
|
178
195
|
delete the underlying local git branch (and the remote branch if
|
|
179
|
-
it was pushed).
|
|
196
|
+
it was pushed). The <branch> argument names its own stack, so this
|
|
197
|
+
works from anywhere. Interactive selector (current stack) if none
|
|
198
|
+
specified.
|
|
180
199
|
--keep-branch leaves the git branches untouched (metadata only);
|
|
181
200
|
--no-remote deletes the local branch but keeps the remote.
|
|
182
201
|
```
|
|
@@ -188,8 +207,30 @@ log
|
|
|
188
207
|
Display the current stack as a tree with branch numbers,
|
|
189
208
|
PR info, and descriptions. This is the default command.
|
|
190
209
|
|
|
210
|
+
If the current branch isn't tracked yet, log inspects its ancestry
|
|
211
|
+
and points at the next step: if it sits on top of an existing stack,
|
|
212
|
+
`gh-stack submit` to add it; if it's stacked on untracked branches,
|
|
213
|
+
submit to create the stack and open PRs; if it's on trunk, `init`.
|
|
214
|
+
|
|
215
|
+
update-prs [--force]
|
|
216
|
+
Refresh the "📚 Stacked on" visualization block in every PR
|
|
217
|
+
description for the current stack. PRs whose rendered block is
|
|
218
|
+
unchanged are skipped; --force rewrites all of them. (submit does
|
|
219
|
+
this automatically — use this to refresh without pushing.)
|
|
220
|
+
|
|
221
|
+
stacks [--current] [--json]
|
|
222
|
+
List all stacks and their topology. Read-only and network-free —
|
|
223
|
+
emits stack membership straight from local metadata, so external
|
|
224
|
+
tools (status scripts, dashboards) can consume --json as a stable
|
|
225
|
+
interface instead of parsing the .git/.gh-stack/ store directly.
|
|
226
|
+
--current Only the stack containing the current branch
|
|
227
|
+
--json Topology as JSON: { current_stack, current_branch,
|
|
228
|
+
stacks: [{ name, description, base, is_current,
|
|
229
|
+
branches: [{ branch, parent, pr, description }] }] }
|
|
230
|
+
|
|
191
231
|
status [--current] [--json]
|
|
192
232
|
PR dashboard showing review state, CI status, and merge readiness.
|
|
233
|
+
Unlike `stacks`, this hits the network to fetch live PR/CI status.
|
|
193
234
|
--current Show only the current stack or standalone PR
|
|
194
235
|
--json Structured JSON output (progress goes to stderr)
|
|
195
236
|
|
|
@@ -267,7 +308,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
|
|
|
267
308
|
|
|
268
309
|
```
|
|
269
310
|
.git/.gh-stack/
|
|
270
|
-
current
|
|
311
|
+
current derived stack of the current branch (self-heals on read)
|
|
271
312
|
active/<stack>.json topology of a live stack (ordered branches, base, description)
|
|
272
313
|
archived/<stack>.json merged/closed stacks
|
|
273
314
|
deleted/<stack>.json tombstones (recoverable)
|
|
@@ -296,6 +337,7 @@ deleting a branch updates/cleans it automatically:
|
|
|
296
337
|
branch.<name>.ghstack-stack <stack>
|
|
297
338
|
branch.<name>.ghstack-parent <parent-branch>
|
|
298
339
|
branch.<name>.ghstack-pr <number>
|
|
340
|
+
branch.<name>.ghstack-id <uuid> stable identity (survives rename)
|
|
299
341
|
```
|
|
300
342
|
|
|
301
343
|
The two representations cross-check each other; `gh-stack doctor` reconciles
|
|
@@ -304,9 +346,17 @@ any drift. Why this shape:
|
|
|
304
346
|
- **A stack can't silently vanish** — lifecycle transitions are file moves
|
|
305
347
|
(`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
|
|
306
348
|
just unlinked.
|
|
307
|
-
- **No `current_stack` drift** — the stack you're on is
|
|
308
|
-
|
|
349
|
+
- **No `current_stack` drift** — the stack you're on is derived from the branch
|
|
350
|
+
you've checked out (its own membership, or the nearest tracked ancestor for a
|
|
351
|
+
new branch stacked on top), re-resolved and self-healed on every read, so a
|
|
352
|
+
written pointer can never contradict reality.
|
|
309
353
|
- **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
|
|
354
|
+
- **Renames heal themselves** — `git branch -m old new` moves the whole
|
|
355
|
+
`branch.<old>` config section to `branch.<new>` (id and all), but the JSON is
|
|
356
|
+
keyed by name and goes stale. On the next command, gh-stack matches the moved
|
|
357
|
+
config to its stale JSON entry by `ghstack-id` (or PR number for older
|
|
358
|
+
branches), re-keys it, and re-points any children. So `gh-stack rename` — or
|
|
359
|
+
even a raw `git branch -m` — never needs manual metadata surgery.
|
|
310
360
|
|
|
311
361
|
#### Migrating from v2
|
|
312
362
|
|
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
|
@@ -11,12 +11,140 @@ import {
|
|
|
11
11
|
} from "../lib/metadata.ts";
|
|
12
12
|
import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
|
|
13
13
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
14
|
-
import { getPrMergeState } from "../lib/github.ts";
|
|
14
|
+
import { getBranchRefSha, getPrMergeState, resyncPrHead } from "../lib/github.ts";
|
|
15
15
|
import { confirmAction } from "../lib/ui.ts";
|
|
16
16
|
|
|
17
17
|
const MAX_RETRIES = 12;
|
|
18
18
|
const RETRY_DELAY_MS = 5000;
|
|
19
19
|
|
|
20
|
+
// After GitHub rejects a merge with the "still recomputing" signature, back off
|
|
21
|
+
// (capped) and re-poll before retrying. One entry per retry; length+1 = max
|
|
22
|
+
// attempts. Bounds total transient wait per PR to ~30s.
|
|
23
|
+
const MERGE_RETRY_BACKOFF_MS = [2000, 4000, 8000, 8000, 8000];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* GitHub returns these when `mergePullRequest` rejects a merge it considers
|
|
27
|
+
* out of date. In a top-down cascade this is almost always one of two timing
|
|
28
|
+
* artifacts (NOT a genuine conflict): a stale PR head pointer (most common —
|
|
29
|
+
* the child squash moved this branch but GitHub didn't advance the PR's
|
|
30
|
+
* `headRefOid`), or the brief async mergeability-recompute window.
|
|
31
|
+
*/
|
|
32
|
+
export function isTransientMergeError(stderr: string): boolean {
|
|
33
|
+
const s = stderr.toLowerCase();
|
|
34
|
+
return (
|
|
35
|
+
s.includes("head branch is out of date") ||
|
|
36
|
+
s.includes("base branch was modified") ||
|
|
37
|
+
s.includes("review and try the merge again")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Squash-merge a PR, healing the two timing artifacts a top-down cascade
|
|
43
|
+
* provokes — neither of which is a real conflict:
|
|
44
|
+
*
|
|
45
|
+
* 1. **Stale PR head (the dominant case).** Squash-merging the child moves
|
|
46
|
+
* this PR's branch server-side, but GitHub sometimes fails to advance the
|
|
47
|
+
* PR's recorded `headRefOid`. Every status read (`mergeable`,
|
|
48
|
+
* `mergeStateStatus`, base…head compare) is computed against that stale
|
|
49
|
+
* head and reports CLEAN, yet `mergePullRequest` checks the live ref and
|
|
50
|
+
* rejects "Head branch is out of date" — indefinitely. We detect it by
|
|
51
|
+
* comparing the PR's head to the live branch ref, and fix it by
|
|
52
|
+
* close→reopen (`resyncPrHead`), which forces GitHub to re-read the branch.
|
|
53
|
+
* 2. **Mergeability recompute window.** Heads already match but GitHub is
|
|
54
|
+
* still recomputing (`mergeStateStatus: UNKNOWN`); back off and re-poll.
|
|
55
|
+
*
|
|
56
|
+
* Only a state that settles genuinely non-mergeable (real conflict / failing
|
|
57
|
+
* required check) — or exhausted retries — is a hard failure.
|
|
58
|
+
*/
|
|
59
|
+
async function squashMergeWithRetry(
|
|
60
|
+
prNumber: number,
|
|
61
|
+
branch: string,
|
|
62
|
+
deleteFlag: boolean,
|
|
63
|
+
s: ReturnType<typeof p.spinner>,
|
|
64
|
+
): Promise<{ ok: true } | { ok: false; stderr: string; genuine: boolean }> {
|
|
65
|
+
const maxAttempts = MERGE_RETRY_BACKOFF_MS.length + 1;
|
|
66
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
67
|
+
const ghArgs = ["pr", "merge", String(prNumber), "--squash"];
|
|
68
|
+
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
69
|
+
|
|
70
|
+
const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
|
|
71
|
+
const stderr = await new Response(proc.stderr).text();
|
|
72
|
+
const exitCode = await proc.exited;
|
|
73
|
+
if (exitCode === 0) return { ok: true };
|
|
74
|
+
|
|
75
|
+
// Raced with another merge / a prior re-run? If it's already merged, done.
|
|
76
|
+
const state = await getPrMergeState(prNumber);
|
|
77
|
+
if (state?.state === "MERGED") return { ok: true };
|
|
78
|
+
|
|
79
|
+
const transient = isTransientMergeError(stderr);
|
|
80
|
+
if (!transient || attempt === maxAttempts) {
|
|
81
|
+
return { ok: false, stderr: stderr.trim(), genuine: !transient };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Is the PR's head stuck behind the live branch tip? If so, the merge will
|
|
85
|
+
// fail forever until GitHub re-reads the branch — nudge it via close/reopen
|
|
86
|
+
// rather than burning the backoff budget polling a status that never moves.
|
|
87
|
+
if (await ensureFreshHead(prNumber, branch, s)) {
|
|
88
|
+
await waitForMergeable(prNumber, s);
|
|
89
|
+
continue; // retry the merge with a freshly-synced head
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Heads match — genuine recompute window. Back off and re-poll; if it
|
|
93
|
+
// settles non-mergeable instead, it was a real conflict after all.
|
|
94
|
+
const delay = MERGE_RETRY_BACKOFF_MS[attempt - 1]!;
|
|
95
|
+
s.message(
|
|
96
|
+
`PR #${prNumber}: GitHub still recomputing mergeability — retrying in ${delay / 1000}s (${attempt}/${maxAttempts - 1})...`,
|
|
97
|
+
);
|
|
98
|
+
await Bun.sleep(delay);
|
|
99
|
+
const ready = await waitForMergeable(prNumber, s);
|
|
100
|
+
if (!ready) return { ok: false, stderr: stderr.trim(), genuine: true };
|
|
101
|
+
}
|
|
102
|
+
// Unreachable — the loop returns on the final attempt.
|
|
103
|
+
return { ok: false, stderr: "merge retries exhausted", genuine: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* After a `resyncPrHead` nudge, poll until GitHub advances the PR's recorded
|
|
108
|
+
* head to the live branch SHA (or we give up). Bounded by MAX_RETRIES.
|
|
109
|
+
*/
|
|
110
|
+
async function waitForHeadSync(
|
|
111
|
+
prNumber: number,
|
|
112
|
+
liveSha: string,
|
|
113
|
+
spinner: ReturnType<typeof p.spinner>,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
116
|
+
const state = await getPrMergeState(prNumber);
|
|
117
|
+
if (state?.headRefOid === liveSha) return true;
|
|
118
|
+
if (state?.state === "MERGED") return true;
|
|
119
|
+
spinner.message(`Waiting for PR #${prNumber} head to sync... (${attempt}/${MAX_RETRIES})`);
|
|
120
|
+
await Bun.sleep(RETRY_DELAY_MS);
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Heal a stuck PR head pointer. If the PR's recorded head has fallen behind the
|
|
127
|
+
* live branch ref (GitHub missed the head-sync after a child squash landed on
|
|
128
|
+
* this branch), force a re-read via close/reopen and wait for the head to catch
|
|
129
|
+
* up. Returns true if a nudge was performed; false (a no-op) when the heads
|
|
130
|
+
* already match, the PR is already merged, or the live ref can't be resolved.
|
|
131
|
+
*/
|
|
132
|
+
async function ensureFreshHead(
|
|
133
|
+
prNumber: number,
|
|
134
|
+
branch: string,
|
|
135
|
+
s: ReturnType<typeof p.spinner>,
|
|
136
|
+
): Promise<boolean> {
|
|
137
|
+
const liveSha = await getBranchRefSha(branch);
|
|
138
|
+
const state = await getPrMergeState(prNumber);
|
|
139
|
+
if (!liveSha || !state?.headRefOid || state.state === "MERGED") return false;
|
|
140
|
+
if (state.headRefOid === liveSha) return false;
|
|
141
|
+
|
|
142
|
+
s.message(`PR #${prNumber}: head out of sync with ${pc.yellow(branch)} — nudging GitHub...`);
|
|
143
|
+
await resyncPrHead(prNumber);
|
|
144
|
+
await waitForHeadSync(prNumber, liveSha, s);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
20
148
|
/**
|
|
21
149
|
* Wait for a PR to become mergeable after a previous merge lands.
|
|
22
150
|
* GitHub needs time to process the new commit on the target branch.
|
|
@@ -62,7 +190,10 @@ All merges happen on GitHub, so PRs show as "Merged", Linear tickets
|
|
|
62
190
|
close automatically, and all GitHub Actions/webhooks fire normally.
|
|
63
191
|
|
|
64
192
|
Skips PRs that are already merged (safe to re-run after partial failure).
|
|
65
|
-
Waits for GitHub to process between merges
|
|
193
|
+
Waits for GitHub to process between merges, and self-heals GitHub's
|
|
194
|
+
spurious "Head branch is out of date" (a stale PR head pointer after a
|
|
195
|
+
child squash) by re-syncing the branch and retrying — only a genuine
|
|
196
|
+
conflict or failing required check is reported as a hard failure.
|
|
66
197
|
|
|
67
198
|
OPTIONS
|
|
68
199
|
-d, --delete-branch Delete remote branches after merging
|
|
@@ -230,21 +361,22 @@ OPTIONS
|
|
|
230
361
|
|
|
231
362
|
s.message(`Squash-merging PR #${childPrNum} via GitHub...`);
|
|
232
363
|
|
|
233
|
-
const
|
|
234
|
-
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
364
|
+
const result = await squashMergeWithRetry(childPrNum, childBranch, deleteFlag, s);
|
|
235
365
|
|
|
236
|
-
|
|
237
|
-
stdout: "pipe",
|
|
238
|
-
stderr: "pipe",
|
|
239
|
-
});
|
|
240
|
-
const stderr = await new Response(proc.stderr).text();
|
|
241
|
-
const exitCode = await proc.exited;
|
|
242
|
-
|
|
243
|
-
if (exitCode === 0) {
|
|
366
|
+
if (result.ok) {
|
|
244
367
|
s.stop(`Merged PR #${childPrNum} into ${pc.blue(parentBranch)}`);
|
|
245
368
|
} else {
|
|
246
369
|
s.stop(pc.red(`Failed to merge PR #${childPrNum}`));
|
|
247
|
-
p.log.error(stderr
|
|
370
|
+
p.log.error(result.stderr);
|
|
371
|
+
if (result.genuine) {
|
|
372
|
+
p.log.info(
|
|
373
|
+
"This looks like a real conflict or a failing required check — resolve it on GitHub.",
|
|
374
|
+
);
|
|
375
|
+
} else {
|
|
376
|
+
p.log.info(
|
|
377
|
+
"GitHub's mergeability recompute didn't settle in time (transient lag, not a conflict).",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
248
380
|
p.log.info("Re-run merge to continue from where you left off.");
|
|
249
381
|
process.exit(2);
|
|
250
382
|
}
|
|
@@ -328,25 +460,38 @@ OPTIONS
|
|
|
328
460
|
|
|
329
461
|
const autoSpinner = p.spinner();
|
|
330
462
|
|
|
331
|
-
// Wait for base PR to be ready (may need time after last intermediate merge)
|
|
463
|
+
// Wait for base PR to be ready (may need time after last intermediate merge).
|
|
464
|
+
// The last intermediate merge moved this branch, so — like the cascade — the
|
|
465
|
+
// base PR's head can be stuck behind the live ref; heal it before enabling
|
|
466
|
+
// auto-merge, then once more if GitHub still rejects the first attempt.
|
|
332
467
|
autoSpinner.start(`Waiting for PR #${basePr} to be ready...`);
|
|
468
|
+
await ensureFreshHead(basePr!, baseBranch, autoSpinner);
|
|
333
469
|
await waitForMergeable(basePr!, autoSpinner);
|
|
334
470
|
|
|
335
471
|
autoSpinner.message(`Enabling auto-merge for PR #${basePr}...`);
|
|
336
472
|
|
|
337
|
-
|
|
473
|
+
const enableAutoMerge = async (): Promise<{ ok: boolean; stderr: string }> => {
|
|
338
474
|
const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
|
|
339
475
|
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
476
|
+
const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
|
|
477
|
+
const stderr = await new Response(proc.stderr).text();
|
|
478
|
+
const exitCode = await proc.exited;
|
|
479
|
+
return { ok: exitCode === 0, stderr: stderr.trim() };
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
let auto = await enableAutoMerge();
|
|
483
|
+
if (!auto.ok && isTransientMergeError(auto.stderr)) {
|
|
484
|
+
// Same stale-head artifact as the cascade — re-sync and try once more.
|
|
485
|
+
await ensureFreshHead(basePr!, baseBranch, autoSpinner);
|
|
486
|
+
await waitForMergeable(basePr!, autoSpinner);
|
|
487
|
+
auto = await enableAutoMerge();
|
|
488
|
+
}
|
|
340
489
|
|
|
341
|
-
|
|
342
|
-
stdout: "pipe",
|
|
343
|
-
stderr: "pipe",
|
|
344
|
-
});
|
|
345
|
-
await proc.exited;
|
|
346
|
-
|
|
490
|
+
if (auto.ok) {
|
|
347
491
|
autoSpinner.stop(`Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`);
|
|
348
|
-
}
|
|
492
|
+
} else {
|
|
349
493
|
autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
|
|
494
|
+
if (auto.stderr) p.log.error(auto.stderr);
|
|
350
495
|
p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
|
|
351
496
|
}
|
|
352
497
|
|
|
@@ -368,8 +513,10 @@ OPTIONS
|
|
|
368
513
|
meta.archive[stackName] = { ...stack };
|
|
369
514
|
delete meta.stacks[stackName];
|
|
370
515
|
if (meta.current_stack === stackName) {
|
|
371
|
-
|
|
372
|
-
|
|
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;
|
|
373
520
|
}
|
|
374
521
|
await writeMetadata(meta);
|
|
375
522
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// gh-stack rename — Rename a tracked branch and keep metadata in sync.
|
|
2
|
+
//
|
|
3
|
+
// The common flow: build on a throwaway branch (`kiliman/feature-wip`), then
|
|
4
|
+
// rename it to a ticketed convention (`kiliman/feature-BEE-1234`) before
|
|
5
|
+
// submitting. `git branch -m` moves the branch's rename-proof config section,
|
|
6
|
+
// and gh-stack reconciles the name-keyed topology files on the next read — this
|
|
7
|
+
// command is just the discoverable front door that does both and persists
|
|
8
|
+
// immediately. (Renaming with raw `git branch -m` still self-heals; see
|
|
9
|
+
// `reconcileRenames` in lib/metadata.ts.)
|
|
10
|
+
import pc from "picocolors";
|
|
11
|
+
import * as p from "../lib/output.ts";
|
|
12
|
+
import * as git from "../lib/git.ts";
|
|
13
|
+
import { findStackForBranch, readMetadata, writeMetadata } from "../lib/metadata.ts";
|
|
14
|
+
import { ensureMetadata } from "../lib/safety.ts";
|
|
15
|
+
import { confirmAction } from "../lib/ui.ts";
|
|
16
|
+
|
|
17
|
+
const HELP = `
|
|
18
|
+
gh-stack rename — Rename a tracked branch and update metadata
|
|
19
|
+
|
|
20
|
+
USAGE
|
|
21
|
+
gh-stack rename <new-name> Rename the current branch
|
|
22
|
+
gh-stack rename <old-name> <new-name>
|
|
23
|
+
|
|
24
|
+
Renames the local git branch (\`git branch -m\`) and re-keys the stack
|
|
25
|
+
topology so the branch stays tracked — re-parenting any children that
|
|
26
|
+
pointed at the old name. Use this for the throwaway-branch → ticketed-PR
|
|
27
|
+
flow without hand-editing metadata.
|
|
28
|
+
|
|
29
|
+
Renaming a branch that already has a PR only renames it locally; the
|
|
30
|
+
GitHub PR still tracks the old remote branch.
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
export default async function rename(args: string[]): Promise<void> {
|
|
34
|
+
if (args.includes("--help")) {
|
|
35
|
+
console.log(HELP);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
40
|
+
if (positionals.length === 0 || positionals.length > 2) {
|
|
41
|
+
p.cancel("Usage: gh-stack rename [<old-name>] <new-name>");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const meta = await ensureMetadata();
|
|
46
|
+
const current = await git.currentBranch();
|
|
47
|
+
|
|
48
|
+
const oldName = positionals.length === 2 ? positionals[0]! : current;
|
|
49
|
+
const newName = positionals.length === 2 ? positionals[1]! : positionals[0]!;
|
|
50
|
+
|
|
51
|
+
// ── Validate ──
|
|
52
|
+
if (oldName === newName) {
|
|
53
|
+
p.cancel("Old and new names are identical — nothing to do.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (newName === "main" || newName === "master") {
|
|
57
|
+
p.cancel(`Refusing to rename a branch to ${pc.red(newName)}.`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
if (!(await git.localBranchExists(oldName))) {
|
|
61
|
+
p.cancel(`Branch ${pc.yellow(oldName)} does not exist locally.`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
if (await git.localBranchExists(newName)) {
|
|
65
|
+
p.cancel(`A branch named ${pc.yellow(newName)} already exists.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stackName = findStackForBranch(meta, oldName);
|
|
70
|
+
if (!stackName) {
|
|
71
|
+
p.cancel(
|
|
72
|
+
`Branch ${pc.yellow(oldName)} isn't tracked in a stack.\n\n Rename it with plain git instead:\n ${pc.green(`git branch -m ${oldName} ${newName}`)}`,
|
|
73
|
+
);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const hadPr = meta.stacks[stackName]?.branches[oldName]?.pr;
|
|
78
|
+
|
|
79
|
+
// ── Confirm ──
|
|
80
|
+
const ok = await confirmAction(
|
|
81
|
+
`Rename ${pc.yellow(oldName)} → ${pc.green(newName)} (stack: ${stackName})?`,
|
|
82
|
+
);
|
|
83
|
+
if (!ok) {
|
|
84
|
+
p.cancel("Rename cancelled.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Rename + persist ──
|
|
89
|
+
if (!(await git.renameBranch(oldName, newName))) {
|
|
90
|
+
p.cancel(`git failed to rename ${pc.yellow(oldName)} → ${pc.yellow(newName)}.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Re-read so reconcileRenames re-keys the topology from the (moved) config,
|
|
95
|
+
// then write it back to persist the new name + repoint child config.
|
|
96
|
+
const updated = await readMetadata();
|
|
97
|
+
if (!updated) {
|
|
98
|
+
p.cancel("Renamed the branch, but failed to re-read metadata.");
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
await writeMetadata(updated);
|
|
102
|
+
|
|
103
|
+
p.log.success(`Renamed ${pc.yellow(oldName)} → ${pc.green(newName)}`);
|
|
104
|
+
|
|
105
|
+
if (hadPr) {
|
|
106
|
+
p.log.warn(
|
|
107
|
+
`PR ${pc.cyan(`#${hadPr}`)} still tracks the old remote branch ${pc.dim(oldName)}.\n` +
|
|
108
|
+
` This rename was local only — update the PR's branch on GitHub if needed.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
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();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// gh-stack stacks — List all stacks and their topology (network-free).
|
|
2
|
+
//
|
|
3
|
+
// This is the machine-readable interface to stack metadata: `--json` emits the
|
|
4
|
+
// full topology (stacks, ordered branches, PR numbers, base) so external tools
|
|
5
|
+
// never have to reach into `.git/.gh-stack/` and parse the storage format
|
|
6
|
+
// themselves. It performs NO network calls and does NOT mutate metadata.
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import * as git from "../lib/git.ts";
|
|
9
|
+
import {
|
|
10
|
+
readMetadata,
|
|
11
|
+
metadataExists,
|
|
12
|
+
legacyMetadataExists,
|
|
13
|
+
getOrderedBranches,
|
|
14
|
+
stackBase,
|
|
15
|
+
} from "../lib/metadata.ts";
|
|
16
|
+
|
|
17
|
+
export default async function stacks(args: string[]): Promise<void> {
|
|
18
|
+
if (args.includes("--help")) {
|
|
19
|
+
console.log(`
|
|
20
|
+
gh-stack stacks — List all stacks and their topology
|
|
21
|
+
|
|
22
|
+
USAGE
|
|
23
|
+
gh-stack stacks [options]
|
|
24
|
+
|
|
25
|
+
OPTIONS
|
|
26
|
+
--json Output topology as JSON (for tools/scripts; no network calls)
|
|
27
|
+
--current Only the stack containing the current branch
|
|
28
|
+
|
|
29
|
+
Read-only and network-free — emits stack membership straight from local
|
|
30
|
+
metadata. Use --json as a stable interface so scripts don't parse the
|
|
31
|
+
.git/.gh-stack/ store directly.
|
|
32
|
+
`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const jsonMode = args.includes("--json");
|
|
37
|
+
const currentOnly = args.includes("--current");
|
|
38
|
+
|
|
39
|
+
// Read-only: don't hard-block on unmigrated v2 metadata — just note it to
|
|
40
|
+
// stderr (keeps stdout/JSON clean) and emit an empty result.
|
|
41
|
+
if (!(await metadataExists())) {
|
|
42
|
+
if (await legacyMetadataExists()) {
|
|
43
|
+
process.stderr.write("Note: stack metadata is unmigrated (v2). Run `gh-stack doctor`.\n");
|
|
44
|
+
}
|
|
45
|
+
if (jsonMode) {
|
|
46
|
+
console.log(
|
|
47
|
+
JSON.stringify({ current_stack: null, current_branch: null, stacks: [] }, null, 2),
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
console.log("No stacks found.");
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const meta = await readMetadata();
|
|
56
|
+
if (!meta) {
|
|
57
|
+
if (jsonMode) {
|
|
58
|
+
console.log(
|
|
59
|
+
JSON.stringify({ current_stack: null, current_branch: null, stacks: [] }, null, 2),
|
|
60
|
+
);
|
|
61
|
+
} else {
|
|
62
|
+
console.log("No stacks found.");
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let currentBranch: string | null = null;
|
|
68
|
+
try {
|
|
69
|
+
currentBranch = await git.currentBranch();
|
|
70
|
+
} catch {
|
|
71
|
+
// Detached HEAD or not on a branch — fine, just no "current" context.
|
|
72
|
+
}
|
|
73
|
+
|
|
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;
|
|
78
|
+
|
|
79
|
+
const entries = Object.entries(meta.stacks)
|
|
80
|
+
.filter(([name]) => !currentOnly || name === currentStackName)
|
|
81
|
+
.map(([name, stack]) => {
|
|
82
|
+
const ordered = getOrderedBranches(stack);
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
description: stack.description || "",
|
|
86
|
+
base: stackBase(stack),
|
|
87
|
+
is_current: name === currentStackName,
|
|
88
|
+
branches: ordered.map((branchName) => {
|
|
89
|
+
const b = stack.branches[branchName]!;
|
|
90
|
+
return {
|
|
91
|
+
branch: branchName,
|
|
92
|
+
parent: b.parent,
|
|
93
|
+
pr: b.pr ?? null,
|
|
94
|
+
description: b.description ?? null,
|
|
95
|
+
};
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (jsonMode) {
|
|
101
|
+
console.log(
|
|
102
|
+
JSON.stringify(
|
|
103
|
+
{
|
|
104
|
+
current_stack: currentStackName,
|
|
105
|
+
current_branch: currentBranch,
|
|
106
|
+
stacks: entries,
|
|
107
|
+
},
|
|
108
|
+
null,
|
|
109
|
+
2,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Human-readable ──
|
|
116
|
+
if (entries.length === 0) {
|
|
117
|
+
console.log(currentOnly ? "Current branch is not in a stack." : "No stacks found.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log();
|
|
122
|
+
for (const stack of entries) {
|
|
123
|
+
const marker = stack.is_current ? pc.yellow(" (current)") : "";
|
|
124
|
+
console.log(`${pc.blue("📚")} ${pc.yellow(stack.name)}${marker} ${pc.dim(`→ ${stack.base}`)}`);
|
|
125
|
+
if (stack.description) {
|
|
126
|
+
console.log(` ${pc.dim(stack.description)}`);
|
|
127
|
+
}
|
|
128
|
+
for (let i = 0; i < stack.branches.length; i++) {
|
|
129
|
+
const b = stack.branches[i]!;
|
|
130
|
+
const isLast = i === stack.branches.length - 1;
|
|
131
|
+
const tree = isLast ? "┗━" : "┣━";
|
|
132
|
+
const prNum = b.pr ? pc.dim(`#${b.pr}`) : pc.dim("(no PR)");
|
|
133
|
+
const cur = currentBranch === b.branch ? pc.yellow(" 👈") : "";
|
|
134
|
+
console.log(` ${tree} ${i + 1}. ${b.branch} ${prNum}${cur}`);
|
|
135
|
+
}
|
|
136
|
+
console.log();
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -73,6 +73,10 @@ switch (command) {
|
|
|
73
73
|
await (await import("./commands/split.ts")).default(commandArgs);
|
|
74
74
|
break;
|
|
75
75
|
|
|
76
|
+
case "rename":
|
|
77
|
+
await (await import("./commands/rename.ts")).default(commandArgs);
|
|
78
|
+
break;
|
|
79
|
+
|
|
76
80
|
case "checkout":
|
|
77
81
|
case "co":
|
|
78
82
|
await (await import("./commands/checkout.ts")).default(commandArgs);
|
|
@@ -115,6 +119,10 @@ switch (command) {
|
|
|
115
119
|
await (await import("./commands/update-prs.ts")).default(commandArgs);
|
|
116
120
|
break;
|
|
117
121
|
|
|
122
|
+
case "stacks":
|
|
123
|
+
await (await import("./commands/stacks.ts")).default(commandArgs);
|
|
124
|
+
break;
|
|
125
|
+
|
|
118
126
|
case "archive":
|
|
119
127
|
await (await import("./commands/archive.ts")).default(commandArgs);
|
|
120
128
|
break;
|
|
@@ -172,12 +180,14 @@ ${bold("STACK NAVIGATION")}
|
|
|
172
180
|
${green("top")} Jump to the tip of the stack
|
|
173
181
|
${green("bottom")} Jump to the base of the stack
|
|
174
182
|
${green("ls")} List branches with numbers
|
|
183
|
+
${green("stacks")} List all stacks (--json for tools)
|
|
175
184
|
|
|
176
185
|
${bold("STACK MANAGEMENT")}
|
|
177
186
|
${green("restack")} Rebase children onto updated parents
|
|
178
187
|
${green("sync")} Sync base with main + restack all
|
|
179
188
|
${green("merge")} Local squash-merge top-down
|
|
180
189
|
${green("split")} Cut a stack into two at a branch
|
|
190
|
+
${green("rename")} Rename a tracked branch (keeps metadata in sync)
|
|
181
191
|
${green("delete")} Remove a branch from the stack
|
|
182
192
|
|
|
183
193
|
${bold("INFO & MAINTENANCE")}
|
package/src/lib/branch-config.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// branch.<name>.ghstack-stack <stack>
|
|
7
7
|
// branch.<name>.ghstack-parent <parent-branch>
|
|
8
8
|
// branch.<name>.ghstack-pr <number>
|
|
9
|
+
// branch.<name>.ghstack-id <uuid> (stable identity; survives rename)
|
|
9
10
|
//
|
|
10
11
|
// Why config and not JSON: git maintains the `branch.<name>` section for us.
|
|
11
12
|
// • `git branch -m old new` → the section moves → rename tracking free
|
|
@@ -23,12 +24,14 @@ export interface BranchMembership {
|
|
|
23
24
|
stack?: string;
|
|
24
25
|
parent?: string;
|
|
25
26
|
pr?: number;
|
|
27
|
+
id?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const KEY_PREFIX = "ghstack-";
|
|
29
31
|
const STACK_KEY = "ghstack-stack";
|
|
30
32
|
const PARENT_KEY = "ghstack-parent";
|
|
31
33
|
const PR_KEY = "ghstack-pr";
|
|
34
|
+
const ID_KEY = "ghstack-id";
|
|
32
35
|
|
|
33
36
|
function configKey(branch: string, key: string): string {
|
|
34
37
|
return `branch.${branch}.${key}`;
|
|
@@ -46,6 +49,7 @@ export async function setBranchMembership(
|
|
|
46
49
|
await setOrUnset(branch, STACK_KEY, membership.stack);
|
|
47
50
|
await setOrUnset(branch, PARENT_KEY, membership.parent);
|
|
48
51
|
await setOrUnset(branch, PR_KEY, membership.pr != null ? String(membership.pr) : undefined);
|
|
52
|
+
await setOrUnset(branch, ID_KEY, membership.id);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
async function setOrUnset(branch: string, key: string, value: string | undefined): Promise<void> {
|
|
@@ -78,6 +82,7 @@ export async function clearBranchMembership(branch: string): Promise<void> {
|
|
|
78
82
|
await unsetKey(branch, STACK_KEY);
|
|
79
83
|
await unsetKey(branch, PARENT_KEY);
|
|
80
84
|
await unsetKey(branch, PR_KEY);
|
|
85
|
+
await unsetKey(branch, ID_KEY);
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
/**
|
|
@@ -115,6 +120,7 @@ export async function allBranchMemberships(): Promise<Map<string, BranchMembersh
|
|
|
115
120
|
const m = out.get(branch) ?? {};
|
|
116
121
|
if (field === STACK_KEY) m.stack = value;
|
|
117
122
|
else if (field === PARENT_KEY) m.parent = value;
|
|
123
|
+
else if (field === ID_KEY) m.id = value;
|
|
118
124
|
else if (field === PR_KEY) {
|
|
119
125
|
const n = Number(value);
|
|
120
126
|
if (Number.isFinite(n)) m.pr = n;
|
package/src/lib/git.ts
CHANGED
|
@@ -179,6 +179,16 @@ export async function deleteLocalBranch(branch: string): Promise<boolean> {
|
|
|
179
179
|
return exitCode === 0;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Rename a local branch (`git branch -m`). Git moves the whole `branch.<old>`
|
|
184
|
+
* config section to `branch.<new>`, so gh-stack membership rides along for free.
|
|
185
|
+
* Returns false if the rename failed (e.g. the new name already exists).
|
|
186
|
+
*/
|
|
187
|
+
export async function renameBranch(oldName: string, newName: string): Promise<boolean> {
|
|
188
|
+
const { exitCode } = await $`git branch -m ${oldName} ${newName}`.nothrow().quiet();
|
|
189
|
+
return exitCode === 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
182
192
|
/**
|
|
183
193
|
* Delete a branch on the remote (origin). Returns false on failure.
|
|
184
194
|
*/
|
package/src/lib/github.ts
CHANGED
|
@@ -150,19 +150,63 @@ export async function getPrBody(prNumber: number): Promise<string | null> {
|
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
152
|
* Get the merge state of a PR.
|
|
153
|
-
* Returns { state, mergeable, mergeStateStatus }.
|
|
153
|
+
* Returns { state, mergeable, mergeStateStatus, headRefName, headRefOid }.
|
|
154
|
+
*
|
|
155
|
+
* NOTE: `mergeable`/`mergeStateStatus` are computed against the PR's *recorded*
|
|
156
|
+
* head (`headRefOid`), which GitHub occasionally fails to advance after a child
|
|
157
|
+
* squash-merge lands on this branch. Compare `headRefOid` against the live ref
|
|
158
|
+
* (`getBranchRefSha`) to detect that staleness — see `merge.ts`.
|
|
154
159
|
*/
|
|
155
|
-
export async function getPrMergeState(
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
export async function getPrMergeState(prNumber: number): Promise<{
|
|
161
|
+
state: string;
|
|
162
|
+
mergeable: string;
|
|
163
|
+
mergeStateStatus: string;
|
|
164
|
+
headRefName: string;
|
|
165
|
+
headRefOid: string;
|
|
166
|
+
} | null> {
|
|
158
167
|
try {
|
|
159
|
-
const result =
|
|
168
|
+
const result =
|
|
169
|
+
await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid`.text();
|
|
160
170
|
return JSON.parse(result.trim());
|
|
161
171
|
} catch {
|
|
162
172
|
return null;
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the live SHA of a branch ref on the remote (`origin`), independent of
|
|
178
|
+
* any PR's cached head. Returns null if the ref can't be resolved.
|
|
179
|
+
*/
|
|
180
|
+
export async function getBranchRefSha(branch: string): Promise<string | null> {
|
|
181
|
+
const nwo = await getRepoNwo();
|
|
182
|
+
if (!nwo) return null;
|
|
183
|
+
try {
|
|
184
|
+
const sha = (
|
|
185
|
+
await $`gh api repos/${nwo}/git/ref/heads/${branch} --jq .object.sha`.text()
|
|
186
|
+
).trim();
|
|
187
|
+
return sha.length > 0 ? sha : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Force GitHub to re-read a PR's head branch by closing and immediately
|
|
195
|
+
* reopening it. Used when the PR's recorded `headRefOid` is stuck behind the
|
|
196
|
+
* live branch tip (a child squash-merge moved the branch but GitHub missed the
|
|
197
|
+
* head-sync), which makes `mergePullRequest` reject a "Head branch is out of
|
|
198
|
+
* date" even though every status read says the PR is clean.
|
|
199
|
+
*/
|
|
200
|
+
export async function resyncPrHead(prNumber: number): Promise<boolean> {
|
|
201
|
+
try {
|
|
202
|
+
await $`gh pr close ${prNumber}`.quiet();
|
|
203
|
+
await $`gh pr reopen ${prNumber}`.quiet();
|
|
204
|
+
return true;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
166
210
|
/**
|
|
167
211
|
* Close a PR with a comment.
|
|
168
212
|
*/
|
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 {
|
|
@@ -131,9 +132,96 @@ export async function readMetadata(): Promise<StackMetadata | null> {
|
|
|
131
132
|
if (snapshots.length > 0) meta.snapshots = snapshots;
|
|
132
133
|
|
|
133
134
|
backfillStackBase(meta);
|
|
135
|
+
// Catch the name-keyed JSON up to any `git branch -m` since the last write.
|
|
136
|
+
// In-memory only — the fix persists the next time a command writes metadata
|
|
137
|
+
// (which also repoints renamed branches' child config). Keeping reads
|
|
138
|
+
// side-effect-free avoids surprising writes from `status`/`--dry-run`.
|
|
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);
|
|
134
144
|
return meta;
|
|
135
145
|
}
|
|
136
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
|
+
|
|
137
225
|
/**
|
|
138
226
|
* The ref a stack is rooted on. Defaults to "main" for stacks created before
|
|
139
227
|
* the `base` field existed.
|
|
@@ -170,6 +258,10 @@ function serializeStack(stack: Stack): string {
|
|
|
170
258
|
export async function writeMetadata(meta: StackMetadata): Promise<void> {
|
|
171
259
|
await ensureDirs();
|
|
172
260
|
|
|
261
|
+
// 0. stamp a stable id on any active branch that lacks one, so a later
|
|
262
|
+
// `git branch -m` can be recognized even before the branch has a PR.
|
|
263
|
+
ensureBranchIds(meta);
|
|
264
|
+
|
|
173
265
|
// 1. current hint
|
|
174
266
|
await Bun.write(await currentFile(), (meta.current_stack ?? "") + "\n");
|
|
175
267
|
|
|
@@ -237,7 +329,7 @@ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
|
|
|
237
329
|
const desired = new Map<string, BranchMembership>();
|
|
238
330
|
for (const [stackName, stack] of Object.entries(meta.stacks)) {
|
|
239
331
|
for (const [branchName, b] of Object.entries(stack.branches)) {
|
|
240
|
-
desired.set(branchName, { stack: stackName, parent: b.parent, pr: b.pr });
|
|
332
|
+
desired.set(branchName, { stack: stackName, parent: b.parent, pr: b.pr, id: b.id });
|
|
241
333
|
}
|
|
242
334
|
}
|
|
243
335
|
|
|
@@ -245,7 +337,13 @@ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
|
|
|
245
337
|
|
|
246
338
|
for (const [branch, m] of desired) {
|
|
247
339
|
const cur = existing.get(branch);
|
|
248
|
-
if (
|
|
340
|
+
if (
|
|
341
|
+
!cur ||
|
|
342
|
+
cur.stack !== m.stack ||
|
|
343
|
+
cur.parent !== m.parent ||
|
|
344
|
+
cur.pr !== m.pr ||
|
|
345
|
+
cur.id !== m.id
|
|
346
|
+
) {
|
|
249
347
|
await setBranchMembership(branch, m);
|
|
250
348
|
}
|
|
251
349
|
}
|
|
@@ -256,6 +354,100 @@ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
|
|
|
256
354
|
}
|
|
257
355
|
}
|
|
258
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Stamp a stable UUID on every active branch that lacks one. Mutates `meta`;
|
|
359
|
+
* the ids persist via the normal stack-file write and get mirrored into git
|
|
360
|
+
* config by `syncBranchConfig`. A branch's id is its rename anchor.
|
|
361
|
+
*/
|
|
362
|
+
function ensureBranchIds(meta: StackMetadata): void {
|
|
363
|
+
for (const stack of Object.values(meta.stacks)) {
|
|
364
|
+
for (const b of Object.values(stack.branches)) {
|
|
365
|
+
if (!b.id) b.id = crypto.randomUUID();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Reconcile the name-keyed JSON after a branch was renamed with `git branch -m`.
|
|
372
|
+
*
|
|
373
|
+
* Git moves a branch's entire `branch.<name>` config section on rename, so the
|
|
374
|
+
* membership config is rename-proof and authoritative for identity; only the
|
|
375
|
+
* JSON (keyed by branch name) drifts. For each branch the config currently knows
|
|
376
|
+
* by name N, if the JSON has no entry under N but does have one — in the same
|
|
377
|
+
* stack — matching N's stable id (or, for legacy/pre-id branches, its PR
|
|
378
|
+
* number), that entry was renamed: re-key it to N and repoint anything that
|
|
379
|
+
* referenced the old name.
|
|
380
|
+
*
|
|
381
|
+
* Mutates `meta` in place; returns true if anything changed. The change persists
|
|
382
|
+
* on the next `writeMetadata`, which also repoints the renamed branch's child
|
|
383
|
+
* `ghstack-parent` config (git rewrites section names, not string values).
|
|
384
|
+
*/
|
|
385
|
+
async function reconcileRenames(meta: StackMetadata): Promise<boolean> {
|
|
386
|
+
const config = await allBranchMemberships();
|
|
387
|
+
if (config.size === 0) return false;
|
|
388
|
+
|
|
389
|
+
let changed = false;
|
|
390
|
+
for (const [currentName, m] of config) {
|
|
391
|
+
if (!m.stack) continue;
|
|
392
|
+
const stack = meta.stacks[m.stack];
|
|
393
|
+
if (!stack) continue;
|
|
394
|
+
if (stack.branches[currentName]) continue; // JSON already keyed correctly
|
|
395
|
+
|
|
396
|
+
const oldKey = findStaleKey(stack, m);
|
|
397
|
+
if (!oldKey || oldKey === currentName) continue;
|
|
398
|
+
|
|
399
|
+
renameBranchKey(meta, m.stack, oldKey, currentName);
|
|
400
|
+
changed = true;
|
|
401
|
+
}
|
|
402
|
+
return changed;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Find a stack's branch entry by stable id (preferred) or PR number (fallback). */
|
|
406
|
+
function findStaleKey(stack: Stack, m: BranchMembership): string | null {
|
|
407
|
+
if (m.id) {
|
|
408
|
+
for (const [name, b] of Object.entries(stack.branches)) {
|
|
409
|
+
if (b.id === m.id) return name;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (m.pr != null) {
|
|
413
|
+
for (const [name, b] of Object.entries(stack.branches)) {
|
|
414
|
+
if (b.pr === m.pr) return name;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Re-key a branch from `oldKey` to `newKey` and repoint every reference to the
|
|
422
|
+
* old name: the stack's `last_branch`, and any branch's `parent` or stack's
|
|
423
|
+
* `base` (across active AND archived stacks — a split stack roots on a branch
|
|
424
|
+
* that belongs to its parent stack).
|
|
425
|
+
*/
|
|
426
|
+
function renameBranchKey(
|
|
427
|
+
meta: StackMetadata,
|
|
428
|
+
stackName: string,
|
|
429
|
+
oldKey: string,
|
|
430
|
+
newKey: string,
|
|
431
|
+
): void {
|
|
432
|
+
const stack = meta.stacks[stackName];
|
|
433
|
+
if (!stack) return;
|
|
434
|
+
const entry = stack.branches[oldKey];
|
|
435
|
+
if (!entry) return;
|
|
436
|
+
|
|
437
|
+
stack.branches[newKey] = entry;
|
|
438
|
+
delete stack.branches[oldKey];
|
|
439
|
+
if (stack.last_branch === oldKey) stack.last_branch = newKey;
|
|
440
|
+
|
|
441
|
+
for (const group of [meta.stacks, meta.archive ?? {}]) {
|
|
442
|
+
for (const s of Object.values(group)) {
|
|
443
|
+
if (s.base === oldKey) s.base = newKey;
|
|
444
|
+
for (const b of Object.values(s.branches)) {
|
|
445
|
+
if (b.parent === oldKey) b.parent = newKey;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
259
451
|
/**
|
|
260
452
|
* Initialize an empty v3 store.
|
|
261
453
|
*/
|
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
|
*/
|
package/src/types.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface Branch {
|
|
|
31
31
|
pr?: number; // GitHub PR number
|
|
32
32
|
description?: string; // Human-readable label
|
|
33
33
|
|
|
34
|
+
// Stable per-branch identity (UUID), stamped on first write and mirrored into
|
|
35
|
+
// git config as `branch.<name>.ghstack-id`. Because `git branch -m` moves the
|
|
36
|
+
// whole config section but the JSON is keyed by name, this id is what lets us
|
|
37
|
+
// recognize a renamed branch and re-key its (now-stale) JSON entry — even
|
|
38
|
+
// before it has a PR number to match on. See metadata.ts `reconcileRenames`.
|
|
39
|
+
id?: string;
|
|
40
|
+
|
|
34
41
|
// ── submit caches (v0.9.0, issue #16) ──
|
|
35
42
|
// Let `submit` skip redundant network calls when nothing relevant changed.
|
|
36
43
|
prTitle?: string; // last-known GitHub PR title, for rendering the stack viz without a fetch
|