gh-stack 0.3.0 → 0.3.1

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
@@ -133,7 +133,8 @@ merge [--dry-run] [-d|--delete-branch]
133
133
  Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
134
134
  then enables auto-merge for the base PR into main. All merges
135
135
  happen on GitHub so PRs show as "Merged", Linear tickets close
136
- automatically, and GitHub Actions fire normally.
136
+ automatically, and GitHub Actions fire normally. Skips already-
137
+ merged PRs (safe to re-run). Waits for GitHub between merges.
137
138
 
138
139
  delete [<branch>]
139
140
  Remove a branch from the stack and re-parent its children.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -6,8 +6,38 @@ import * as git from "../lib/git.ts";
6
6
  import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
7
7
  import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
8
8
  import { takeSnapshot } from "../lib/snapshot.ts";
9
+ import { getPrMergeState } from "../lib/github.ts";
9
10
  import { confirmAction } from "../lib/ui.ts";
10
11
 
12
+ const MAX_RETRIES = 12;
13
+ const RETRY_DELAY_MS = 5000;
14
+
15
+ /**
16
+ * Wait for a PR to become mergeable after a previous merge lands.
17
+ * GitHub needs time to process the new commit on the target branch.
18
+ */
19
+ async function waitForMergeable(
20
+ prNumber: number,
21
+ spinner: ReturnType<typeof p.spinner>,
22
+ ): Promise<boolean> {
23
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
24
+ const state = await getPrMergeState(prNumber);
25
+ if (!state) return false;
26
+
27
+ if (state.state === "MERGED") return true; // Already merged
28
+ if (state.state === "CLOSED") return false;
29
+
30
+ // "CLEAN", "HAS_HOOKS", "UNSTABLE" are all mergeable states
31
+ if (state.mergeable === "MERGEABLE" && state.mergeStateStatus !== "BLOCKED") {
32
+ return true;
33
+ }
34
+
35
+ spinner.message(`Waiting for PR #${prNumber} to be ready... (${attempt}/${MAX_RETRIES})`);
36
+ await Bun.sleep(RETRY_DELAY_MS);
37
+ }
38
+ return false;
39
+ }
40
+
11
41
  export default async function merge(args: string[]): Promise<void> {
12
42
  const dryRun = args.includes("--dry-run");
13
43
  const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
@@ -25,6 +55,9 @@ Squash-merges the stack from top to bottom via GitHub:
25
55
  All merges happen on GitHub, so PRs show as "Merged", Linear tickets
26
56
  close automatically, and all GitHub Actions/webhooks fire normally.
27
57
 
58
+ Skips PRs that are already merged (safe to re-run after partial failure).
59
+ Waits for GitHub to process between merges if needed.
60
+
28
61
  OPTIONS
29
62
  -d, --delete-branch Delete remote branches after merging
30
63
  --dry-run Show what would happen without doing anything
@@ -65,23 +98,29 @@ OPTIONS
65
98
  }
66
99
 
67
100
  p.intro(pc.cyan("Stack Merge (via GitHub)"));
68
-
69
101
  p.log.info(`Stack: ${pc.yellow(stackName)}`);
70
102
  console.log();
71
103
 
72
- // Show the merge plan: from top to bottom
104
+ // Check PR states and build the merge plan
73
105
  const reversed = ordered.toReversed();
106
+ const baseBranch = ordered[0]!;
107
+ const basePr = stack.branches[baseBranch]?.pr;
108
+
74
109
  console.log(` ${pc.bold("Merge plan:")}`);
75
110
  for (let i = 0; i < reversed.length - 1; i++) {
76
111
  const child = reversed[i]!;
77
112
  const parent = stack.branches[child]?.parent || "???";
78
113
  const childPr = stack.branches[child]?.pr;
114
+
115
+ // Check if already merged
116
+ const prState = childPr ? await getPrMergeState(childPr) : null;
117
+ const isMerged = prState?.state === "MERGED";
118
+ const mergedLabel = isMerged ? pc.dim(" (already merged)") : "";
119
+
79
120
  console.log(
80
- ` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}`,
121
+ ` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}${mergedLabel}`,
81
122
  );
82
123
  }
83
- const baseBranch = ordered[0]!;
84
- const basePr = stack.branches[baseBranch]?.pr;
85
124
  console.log(
86
125
  ` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
87
126
  );
@@ -118,6 +157,13 @@ OPTIONS
118
157
 
119
158
  if (parentBranch === "main" || parentBranch === "master") continue;
120
159
 
160
+ // Check if already merged (skip)
161
+ const prState = await getPrMergeState(childPrNum);
162
+ if (prState?.state === "MERGED") {
163
+ p.log.info(`${pc.dim("✓")} PR #${childPrNum} already merged — skipping`);
164
+ continue;
165
+ }
166
+
121
167
  console.log();
122
168
  console.log(pc.cyan("━".repeat(40)));
123
169
  console.log(
@@ -125,8 +171,19 @@ OPTIONS
125
171
  );
126
172
  console.log(pc.cyan("━".repeat(40)));
127
173
 
174
+ // Wait for PR to be mergeable (GitHub may need time after previous merge)
128
175
  const s = p.spinner();
129
- s.start(`Squash-merging PR #${childPrNum} via GitHub...`);
176
+ s.start(`Checking PR #${childPrNum} is ready to merge...`);
177
+ const ready = await waitForMergeable(childPrNum, s);
178
+
179
+ if (!ready) {
180
+ s.stop(pc.red(`PR #${childPrNum} is not mergeable`));
181
+ p.log.info("Check the PR on GitHub for merge conflicts or required checks.");
182
+ p.log.info("Re-run merge to continue from where you left off.");
183
+ process.exit(2);
184
+ }
185
+
186
+ s.message(`Squash-merging PR #${childPrNum} via GitHub...`);
130
187
 
131
188
  const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
132
189
  if (deleteFlag) ghArgs.push("--delete-branch");
@@ -143,7 +200,7 @@ OPTIONS
143
200
  } else {
144
201
  s.stop(pc.red(`Failed to merge PR #${childPrNum}`));
145
202
  p.log.error(stderr.trim());
146
- p.log.info("Resolve the issue on GitHub, then re-run merge to continue.");
203
+ p.log.info("Re-run merge to continue from where you left off.");
147
204
  process.exit(2);
148
205
  }
149
206
  }
@@ -157,12 +214,23 @@ OPTIONS
157
214
  console.log(pc.cyan("━".repeat(40)));
158
215
 
159
216
  const autoSpinner = p.spinner();
160
- autoSpinner.start(`Enabling auto-merge for PR #${basePr}...`);
217
+
218
+ // Wait for base PR to be ready (may need time after last intermediate merge)
219
+ autoSpinner.start(`Waiting for PR #${basePr} to be ready...`);
220
+ await waitForMergeable(basePr!, autoSpinner);
221
+
222
+ autoSpinner.message(`Enabling auto-merge for PR #${basePr}...`);
161
223
 
162
224
  try {
163
225
  const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
164
226
  if (deleteFlag) ghArgs.push("--delete-branch");
165
- await $`gh pr merge ${basePr} --squash --auto ${deleteFlag ? "--delete-branch" : ""}`.quiet();
227
+
228
+ const proc = Bun.spawn(["gh", ...ghArgs], {
229
+ stdout: "pipe",
230
+ stderr: "pipe",
231
+ });
232
+ await proc.exited;
233
+
166
234
  autoSpinner.stop(`Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`);
167
235
  } catch {
168
236
  autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
@@ -175,7 +243,6 @@ OPTIONS
175
243
  pullSpinner.start("Syncing local branches...");
176
244
  try {
177
245
  await $`git fetch origin`.quiet();
178
- // Checkout main and pull so local is up to date
179
246
  await git.checkout("main");
180
247
  await $`git pull origin main`.quiet();
181
248
  pullSpinner.stop("Local branches synced");
package/src/lib/github.ts CHANGED
@@ -124,6 +124,21 @@ export async function getPrBody(prNumber: number): Promise<string | null> {
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Get the merge state of a PR.
129
+ * Returns { state, mergeable, mergeStateStatus }.
130
+ */
131
+ export async function getPrMergeState(
132
+ prNumber: number,
133
+ ): Promise<{ state: string; mergeable: string; mergeStateStatus: string } | null> {
134
+ try {
135
+ const result = await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus`.text();
136
+ return JSON.parse(result.trim());
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
127
142
  /**
128
143
  * Close a PR with a comment.
129
144
  */