gh-stack 0.7.1 → 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.
@@ -3,15 +3,148 @@ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import { $ } from "bun";
5
5
  import * as git from "../lib/git.ts";
6
- import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
6
+ import {
7
+ findStackForBranch,
8
+ getOrderedBranches,
9
+ stackBase,
10
+ writeMetadata,
11
+ } from "../lib/metadata.ts";
7
12
  import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
8
13
  import { takeSnapshot } from "../lib/snapshot.ts";
9
- import { getPrMergeState } from "../lib/github.ts";
14
+ import { getBranchRefSha, getPrMergeState, resyncPrHead } from "../lib/github.ts";
10
15
  import { confirmAction } from "../lib/ui.ts";
11
16
 
12
17
  const MAX_RETRIES = 12;
13
18
  const RETRY_DELAY_MS = 5000;
14
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
+
15
148
  /**
16
149
  * Wait for a PR to become mergeable after a previous merge lands.
17
150
  * GitHub needs time to process the new commit on the target branch.
@@ -57,7 +190,10 @@ All merges happen on GitHub, so PRs show as "Merged", Linear tickets
57
190
  close automatically, and all GitHub Actions/webhooks fire normally.
58
191
 
59
192
  Skips PRs that are already merged (safe to re-run after partial failure).
60
- 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.
61
197
 
62
198
  OPTIONS
63
199
  -d, --delete-branch Delete remote branches after merging
@@ -84,6 +220,19 @@ OPTIONS
84
220
  await ensureValidStack(meta, stackName);
85
221
  const ordered = getOrderedBranches(stack);
86
222
 
223
+ // A split stack is rooted on a branch in another stack, so its base PR
224
+ // doesn't target main — merging it now would land it against the wrong base.
225
+ // Re-root onto main first (after the parent stack merges).
226
+ const base = stackBase(stack);
227
+ if (base !== "main" && base !== "master") {
228
+ p.cancel(
229
+ `${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — can't merge yet.\n\n` +
230
+ ` Merge (or finish) the parent stack first, then re-root onto main:\n` +
231
+ ` ${pc.green("gh-stack restack --onto main")}`,
232
+ );
233
+ process.exit(1);
234
+ }
235
+
87
236
  if (ordered.length <= 1) {
88
237
  // Single branch — just enable auto-merge
89
238
  const basePr = stack.branches[ordered[0]!]?.pr;
@@ -212,21 +361,22 @@ OPTIONS
212
361
 
213
362
  s.message(`Squash-merging PR #${childPrNum} via GitHub...`);
214
363
 
215
- const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
216
- if (deleteFlag) ghArgs.push("--delete-branch");
364
+ const result = await squashMergeWithRetry(childPrNum, childBranch, deleteFlag, s);
217
365
 
218
- const proc = Bun.spawn(["gh", ...ghArgs], {
219
- stdout: "pipe",
220
- stderr: "pipe",
221
- });
222
- const stderr = await new Response(proc.stderr).text();
223
- const exitCode = await proc.exited;
224
-
225
- if (exitCode === 0) {
366
+ if (result.ok) {
226
367
  s.stop(`Merged PR #${childPrNum} into ${pc.blue(parentBranch)}`);
227
368
  } else {
228
369
  s.stop(pc.red(`Failed to merge PR #${childPrNum}`));
229
- 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
+ }
230
380
  p.log.info("Re-run merge to continue from where you left off.");
231
381
  process.exit(2);
232
382
  }
@@ -310,25 +460,38 @@ OPTIONS
310
460
 
311
461
  const autoSpinner = p.spinner();
312
462
 
313
- // 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.
314
467
  autoSpinner.start(`Waiting for PR #${basePr} to be ready...`);
468
+ await ensureFreshHead(basePr!, baseBranch, autoSpinner);
315
469
  await waitForMergeable(basePr!, autoSpinner);
316
470
 
317
471
  autoSpinner.message(`Enabling auto-merge for PR #${basePr}...`);
318
472
 
319
- try {
473
+ const enableAutoMerge = async (): Promise<{ ok: boolean; stderr: string }> => {
320
474
  const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
321
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
+ }
322
489
 
323
- const proc = Bun.spawn(["gh", ...ghArgs], {
324
- stdout: "pipe",
325
- stderr: "pipe",
326
- });
327
- await proc.exited;
328
-
490
+ if (auto.ok) {
329
491
  autoSpinner.stop(`Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`);
330
- } catch {
492
+ } else {
331
493
  autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
494
+ if (auto.stderr) p.log.error(auto.stderr);
332
495
  p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
333
496
  }
334
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
+ }