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 +2 -1
- package/package.json +1 -1
- package/src/commands/merge.ts +77 -10
- package/src/lib/github.ts +15 -0
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
package/src/commands/merge.ts
CHANGED
|
@@ -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
|
-
//
|
|
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(`
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|