gh-stack 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -1
- package/package.json +1 -1
- package/src/commands/merge.ts +167 -22
- package/src/commands/rename.ts +111 -0
- package/src/commands/stacks.ts +136 -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 +111 -2
- package/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -154,7 +154,9 @@ 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
|
|
@@ -173,6 +175,19 @@ split [<branch>] [--name <name>]
|
|
|
173
175
|
tip. Once the original stack merges, re-root the new stack with
|
|
174
176
|
`gh-stack restack --onto main`.
|
|
175
177
|
|
|
178
|
+
rename [<old-name>] <new-name>
|
|
179
|
+
Rename a tracked branch (runs `git branch -m`) and re-key the stack
|
|
180
|
+
topology so it stays tracked, re-parenting any children that pointed
|
|
181
|
+
at the old name. With one argument, renames the current branch. Built
|
|
182
|
+
for the throwaway-branch → ticketed-PR flow (`feature-wip` →
|
|
183
|
+
`feature-BEE-1234`) without hand-editing metadata.
|
|
184
|
+
|
|
185
|
+
You don't strictly need this command: gh-stack also auto-reconciles
|
|
186
|
+
after a plain `git branch -m` (the per-branch git config is rename-
|
|
187
|
+
proof, so the next gh-stack command heals the name-keyed topology).
|
|
188
|
+
Renaming a branch that already has a PR is local-only — the GitHub PR
|
|
189
|
+
still tracks the old remote branch.
|
|
190
|
+
|
|
176
191
|
delete [<branch>] [-k|--keep-branch] [--no-remote]
|
|
177
192
|
Remove a branch from the stack and re-parent its children, then
|
|
178
193
|
delete the underlying local git branch (and the remote branch if
|
|
@@ -188,8 +203,25 @@ log
|
|
|
188
203
|
Display the current stack as a tree with branch numbers,
|
|
189
204
|
PR info, and descriptions. This is the default command.
|
|
190
205
|
|
|
206
|
+
update-prs [--force]
|
|
207
|
+
Refresh the "📚 Stacked on" visualization block in every PR
|
|
208
|
+
description for the current stack. PRs whose rendered block is
|
|
209
|
+
unchanged are skipped; --force rewrites all of them. (submit does
|
|
210
|
+
this automatically — use this to refresh without pushing.)
|
|
211
|
+
|
|
212
|
+
stacks [--current] [--json]
|
|
213
|
+
List all stacks and their topology. Read-only and network-free —
|
|
214
|
+
emits stack membership straight from local metadata, so external
|
|
215
|
+
tools (status scripts, dashboards) can consume --json as a stable
|
|
216
|
+
interface instead of parsing the .git/.gh-stack/ store directly.
|
|
217
|
+
--current Only the stack containing the current branch
|
|
218
|
+
--json Topology as JSON: { current_stack, current_branch,
|
|
219
|
+
stacks: [{ name, description, base, is_current,
|
|
220
|
+
branches: [{ branch, parent, pr, description }] }] }
|
|
221
|
+
|
|
191
222
|
status [--current] [--json]
|
|
192
223
|
PR dashboard showing review state, CI status, and merge readiness.
|
|
224
|
+
Unlike `stacks`, this hits the network to fetch live PR/CI status.
|
|
193
225
|
--current Show only the current stack or standalone PR
|
|
194
226
|
--json Structured JSON output (progress goes to stderr)
|
|
195
227
|
|
|
@@ -296,6 +328,7 @@ deleting a branch updates/cleans it automatically:
|
|
|
296
328
|
branch.<name>.ghstack-stack <stack>
|
|
297
329
|
branch.<name>.ghstack-parent <parent-branch>
|
|
298
330
|
branch.<name>.ghstack-pr <number>
|
|
331
|
+
branch.<name>.ghstack-id <uuid> stable identity (survives rename)
|
|
299
332
|
```
|
|
300
333
|
|
|
301
334
|
The two representations cross-check each other; `gh-stack doctor` reconciles
|
|
@@ -307,6 +340,12 @@ any drift. Why this shape:
|
|
|
307
340
|
- **No `current_stack` drift** — the stack you're on is derivable from the
|
|
308
341
|
branch's own config, so a written pointer can't contradict reality.
|
|
309
342
|
- **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
|
|
343
|
+
- **Renames heal themselves** — `git branch -m old new` moves the whole
|
|
344
|
+
`branch.<old>` config section to `branch.<new>` (id and all), but the JSON is
|
|
345
|
+
keyed by name and goes stale. On the next command, gh-stack matches the moved
|
|
346
|
+
config to its stale JSON entry by `ghstack-id` (or PR number for older
|
|
347
|
+
branches), re-keys it, and re-points any children. So `gh-stack rename` — or
|
|
348
|
+
even a raw `git branch -m` — never needs manual metadata surgery.
|
|
310
349
|
|
|
311
350
|
#### Migrating from v2
|
|
312
351
|
|
package/package.json
CHANGED
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
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
findStackForBranch,
|
|
15
|
+
stackBase,
|
|
16
|
+
} from "../lib/metadata.ts";
|
|
17
|
+
|
|
18
|
+
export default async function stacks(args: string[]): Promise<void> {
|
|
19
|
+
if (args.includes("--help")) {
|
|
20
|
+
console.log(`
|
|
21
|
+
gh-stack stacks — List all stacks and their topology
|
|
22
|
+
|
|
23
|
+
USAGE
|
|
24
|
+
gh-stack stacks [options]
|
|
25
|
+
|
|
26
|
+
OPTIONS
|
|
27
|
+
--json Output topology as JSON (for tools/scripts; no network calls)
|
|
28
|
+
--current Only the stack containing the current branch
|
|
29
|
+
|
|
30
|
+
Read-only and network-free — emits stack membership straight from local
|
|
31
|
+
metadata. Use --json as a stable interface so scripts don't parse the
|
|
32
|
+
.git/.gh-stack/ store directly.
|
|
33
|
+
`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const jsonMode = args.includes("--json");
|
|
38
|
+
const currentOnly = args.includes("--current");
|
|
39
|
+
|
|
40
|
+
// Read-only: don't hard-block on unmigrated v2 metadata — just note it to
|
|
41
|
+
// stderr (keeps stdout/JSON clean) and emit an empty result.
|
|
42
|
+
if (!(await metadataExists())) {
|
|
43
|
+
if (await legacyMetadataExists()) {
|
|
44
|
+
process.stderr.write("Note: stack metadata is unmigrated (v2). Run `gh-stack doctor`.\n");
|
|
45
|
+
}
|
|
46
|
+
if (jsonMode) {
|
|
47
|
+
console.log(
|
|
48
|
+
JSON.stringify({ current_stack: null, current_branch: null, stacks: [] }, null, 2),
|
|
49
|
+
);
|
|
50
|
+
} else {
|
|
51
|
+
console.log("No stacks found.");
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const meta = await readMetadata();
|
|
57
|
+
if (!meta) {
|
|
58
|
+
if (jsonMode) {
|
|
59
|
+
console.log(
|
|
60
|
+
JSON.stringify({ current_stack: null, current_branch: null, stacks: [] }, null, 2),
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
console.log("No stacks found.");
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let currentBranch: string | null = null;
|
|
69
|
+
try {
|
|
70
|
+
currentBranch = await git.currentBranch();
|
|
71
|
+
} catch {
|
|
72
|
+
// Detached HEAD or not on a branch — fine, just no "current" context.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const currentStackName = currentBranch ? findStackForBranch(meta, currentBranch) : null;
|
|
76
|
+
|
|
77
|
+
const entries = Object.entries(meta.stacks)
|
|
78
|
+
.filter(([name]) => !currentOnly || name === currentStackName)
|
|
79
|
+
.map(([name, stack]) => {
|
|
80
|
+
const ordered = getOrderedBranches(stack);
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
description: stack.description || "",
|
|
84
|
+
base: stackBase(stack),
|
|
85
|
+
is_current: name === currentStackName,
|
|
86
|
+
branches: ordered.map((branchName) => {
|
|
87
|
+
const b = stack.branches[branchName]!;
|
|
88
|
+
return {
|
|
89
|
+
branch: branchName,
|
|
90
|
+
parent: b.parent,
|
|
91
|
+
pr: b.pr ?? null,
|
|
92
|
+
description: b.description ?? null,
|
|
93
|
+
};
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (jsonMode) {
|
|
99
|
+
console.log(
|
|
100
|
+
JSON.stringify(
|
|
101
|
+
{
|
|
102
|
+
current_stack: currentStackName ?? meta.current_stack ?? null,
|
|
103
|
+
current_branch: currentBranch,
|
|
104
|
+
stacks: entries,
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Human-readable ──
|
|
114
|
+
if (entries.length === 0) {
|
|
115
|
+
console.log(currentOnly ? "Current branch is not in a stack." : "No stacks found.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log();
|
|
120
|
+
for (const stack of entries) {
|
|
121
|
+
const marker = stack.is_current ? pc.yellow(" (current)") : "";
|
|
122
|
+
console.log(`${pc.blue("📚")} ${pc.yellow(stack.name)}${marker} ${pc.dim(`→ ${stack.base}`)}`);
|
|
123
|
+
if (stack.description) {
|
|
124
|
+
console.log(` ${pc.dim(stack.description)}`);
|
|
125
|
+
}
|
|
126
|
+
for (let i = 0; i < stack.branches.length; i++) {
|
|
127
|
+
const b = stack.branches[i]!;
|
|
128
|
+
const isLast = i === stack.branches.length - 1;
|
|
129
|
+
const tree = isLast ? "┗━" : "┣━";
|
|
130
|
+
const prNum = b.pr ? pc.dim(`#${b.pr}`) : pc.dim("(no PR)");
|
|
131
|
+
const cur = currentBranch === b.branch ? pc.yellow(" 👈") : "";
|
|
132
|
+
console.log(` ${tree} ${i + 1}. ${b.branch} ${prNum}${cur}`);
|
|
133
|
+
}
|
|
134
|
+
console.log();
|
|
135
|
+
}
|
|
136
|
+
}
|
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
|
@@ -131,6 +131,11 @@ export async function readMetadata(): Promise<StackMetadata | null> {
|
|
|
131
131
|
if (snapshots.length > 0) meta.snapshots = snapshots;
|
|
132
132
|
|
|
133
133
|
backfillStackBase(meta);
|
|
134
|
+
// Catch the name-keyed JSON up to any `git branch -m` since the last write.
|
|
135
|
+
// In-memory only — the fix persists the next time a command writes metadata
|
|
136
|
+
// (which also repoints renamed branches' child config). Keeping reads
|
|
137
|
+
// side-effect-free avoids surprising writes from `status`/`--dry-run`.
|
|
138
|
+
await reconcileRenames(meta);
|
|
134
139
|
return meta;
|
|
135
140
|
}
|
|
136
141
|
|
|
@@ -170,6 +175,10 @@ function serializeStack(stack: Stack): string {
|
|
|
170
175
|
export async function writeMetadata(meta: StackMetadata): Promise<void> {
|
|
171
176
|
await ensureDirs();
|
|
172
177
|
|
|
178
|
+
// 0. stamp a stable id on any active branch that lacks one, so a later
|
|
179
|
+
// `git branch -m` can be recognized even before the branch has a PR.
|
|
180
|
+
ensureBranchIds(meta);
|
|
181
|
+
|
|
173
182
|
// 1. current hint
|
|
174
183
|
await Bun.write(await currentFile(), (meta.current_stack ?? "") + "\n");
|
|
175
184
|
|
|
@@ -237,7 +246,7 @@ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
|
|
|
237
246
|
const desired = new Map<string, BranchMembership>();
|
|
238
247
|
for (const [stackName, stack] of Object.entries(meta.stacks)) {
|
|
239
248
|
for (const [branchName, b] of Object.entries(stack.branches)) {
|
|
240
|
-
desired.set(branchName, { stack: stackName, parent: b.parent, pr: b.pr });
|
|
249
|
+
desired.set(branchName, { stack: stackName, parent: b.parent, pr: b.pr, id: b.id });
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
|
|
@@ -245,7 +254,13 @@ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
|
|
|
245
254
|
|
|
246
255
|
for (const [branch, m] of desired) {
|
|
247
256
|
const cur = existing.get(branch);
|
|
248
|
-
if (
|
|
257
|
+
if (
|
|
258
|
+
!cur ||
|
|
259
|
+
cur.stack !== m.stack ||
|
|
260
|
+
cur.parent !== m.parent ||
|
|
261
|
+
cur.pr !== m.pr ||
|
|
262
|
+
cur.id !== m.id
|
|
263
|
+
) {
|
|
249
264
|
await setBranchMembership(branch, m);
|
|
250
265
|
}
|
|
251
266
|
}
|
|
@@ -256,6 +271,100 @@ async function syncBranchConfig(meta: StackMetadata): Promise<void> {
|
|
|
256
271
|
}
|
|
257
272
|
}
|
|
258
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Stamp a stable UUID on every active branch that lacks one. Mutates `meta`;
|
|
276
|
+
* the ids persist via the normal stack-file write and get mirrored into git
|
|
277
|
+
* config by `syncBranchConfig`. A branch's id is its rename anchor.
|
|
278
|
+
*/
|
|
279
|
+
function ensureBranchIds(meta: StackMetadata): void {
|
|
280
|
+
for (const stack of Object.values(meta.stacks)) {
|
|
281
|
+
for (const b of Object.values(stack.branches)) {
|
|
282
|
+
if (!b.id) b.id = crypto.randomUUID();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reconcile the name-keyed JSON after a branch was renamed with `git branch -m`.
|
|
289
|
+
*
|
|
290
|
+
* Git moves a branch's entire `branch.<name>` config section on rename, so the
|
|
291
|
+
* membership config is rename-proof and authoritative for identity; only the
|
|
292
|
+
* JSON (keyed by branch name) drifts. For each branch the config currently knows
|
|
293
|
+
* by name N, if the JSON has no entry under N but does have one — in the same
|
|
294
|
+
* stack — matching N's stable id (or, for legacy/pre-id branches, its PR
|
|
295
|
+
* number), that entry was renamed: re-key it to N and repoint anything that
|
|
296
|
+
* referenced the old name.
|
|
297
|
+
*
|
|
298
|
+
* Mutates `meta` in place; returns true if anything changed. The change persists
|
|
299
|
+
* on the next `writeMetadata`, which also repoints the renamed branch's child
|
|
300
|
+
* `ghstack-parent` config (git rewrites section names, not string values).
|
|
301
|
+
*/
|
|
302
|
+
async function reconcileRenames(meta: StackMetadata): Promise<boolean> {
|
|
303
|
+
const config = await allBranchMemberships();
|
|
304
|
+
if (config.size === 0) return false;
|
|
305
|
+
|
|
306
|
+
let changed = false;
|
|
307
|
+
for (const [currentName, m] of config) {
|
|
308
|
+
if (!m.stack) continue;
|
|
309
|
+
const stack = meta.stacks[m.stack];
|
|
310
|
+
if (!stack) continue;
|
|
311
|
+
if (stack.branches[currentName]) continue; // JSON already keyed correctly
|
|
312
|
+
|
|
313
|
+
const oldKey = findStaleKey(stack, m);
|
|
314
|
+
if (!oldKey || oldKey === currentName) continue;
|
|
315
|
+
|
|
316
|
+
renameBranchKey(meta, m.stack, oldKey, currentName);
|
|
317
|
+
changed = true;
|
|
318
|
+
}
|
|
319
|
+
return changed;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Find a stack's branch entry by stable id (preferred) or PR number (fallback). */
|
|
323
|
+
function findStaleKey(stack: Stack, m: BranchMembership): string | null {
|
|
324
|
+
if (m.id) {
|
|
325
|
+
for (const [name, b] of Object.entries(stack.branches)) {
|
|
326
|
+
if (b.id === m.id) return name;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (m.pr != null) {
|
|
330
|
+
for (const [name, b] of Object.entries(stack.branches)) {
|
|
331
|
+
if (b.pr === m.pr) return name;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Re-key a branch from `oldKey` to `newKey` and repoint every reference to the
|
|
339
|
+
* old name: the stack's `last_branch`, and any branch's `parent` or stack's
|
|
340
|
+
* `base` (across active AND archived stacks — a split stack roots on a branch
|
|
341
|
+
* that belongs to its parent stack).
|
|
342
|
+
*/
|
|
343
|
+
function renameBranchKey(
|
|
344
|
+
meta: StackMetadata,
|
|
345
|
+
stackName: string,
|
|
346
|
+
oldKey: string,
|
|
347
|
+
newKey: string,
|
|
348
|
+
): void {
|
|
349
|
+
const stack = meta.stacks[stackName];
|
|
350
|
+
if (!stack) return;
|
|
351
|
+
const entry = stack.branches[oldKey];
|
|
352
|
+
if (!entry) return;
|
|
353
|
+
|
|
354
|
+
stack.branches[newKey] = entry;
|
|
355
|
+
delete stack.branches[oldKey];
|
|
356
|
+
if (stack.last_branch === oldKey) stack.last_branch = newKey;
|
|
357
|
+
|
|
358
|
+
for (const group of [meta.stacks, meta.archive ?? {}]) {
|
|
359
|
+
for (const s of Object.values(group)) {
|
|
360
|
+
if (s.base === oldKey) s.base = newKey;
|
|
361
|
+
for (const b of Object.values(s.branches)) {
|
|
362
|
+
if (b.parent === oldKey) b.parent = newKey;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
259
368
|
/**
|
|
260
369
|
* Initialize an empty v3 store.
|
|
261
370
|
*/
|
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
|