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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -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 if needed.
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 ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
234
- if (deleteFlag) ghArgs.push("--delete-branch");
364
+ const result = await squashMergeWithRetry(childPrNum, childBranch, deleteFlag, s);
235
365
 
236
- const proc = Bun.spawn(["gh", ...ghArgs], {
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.trim());
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
- try {
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
- const proc = Bun.spawn(["gh", ...ghArgs], {
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
- } catch {
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")}
@@ -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
- prNumber: number,
157
- ): Promise<{ state: string; mergeable: string; mergeStateStatus: string } | null> {
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 = await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus`.text();
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
  */
@@ -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 (!cur || cur.stack !== m.stack || cur.parent !== m.parent || cur.pr !== m.pr) {
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