gh-stack 0.2.3 → 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 +6 -5
- package/package.json +1 -1
- package/src/commands/merge.ts +166 -109
- package/src/lib/github.ts +15 -0
package/README.md
CHANGED
|
@@ -129,11 +129,12 @@ sync [--dry-run]
|
|
|
129
129
|
Fetch main, rebase the base branch onto main, then restack all
|
|
130
130
|
children. Creates tags for ALL branches before any rebasing starts.
|
|
131
131
|
|
|
132
|
-
merge [--dry-run]
|
|
133
|
-
Squash-merge the stack top-down
|
|
134
|
-
then
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
merge [--dry-run] [-d|--delete-branch]
|
|
133
|
+
Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
|
|
134
|
+
then enables auto-merge for the base PR into main. All merges
|
|
135
|
+
happen on GitHub so PRs show as "Merged", Linear tickets close
|
|
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
|
@@ -1,35 +1,70 @@
|
|
|
1
|
-
// gh-stack merge —
|
|
2
|
-
// From reference/gh-stack-merge-design.md
|
|
1
|
+
// gh-stack merge — Squash-merge stack top-down via GitHub
|
|
3
2
|
import * as p from "@clack/prompts";
|
|
4
3
|
import pc from "picocolors";
|
|
4
|
+
import { $ } from "bun";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
|
7
|
-
import { ensureMetadata,
|
|
7
|
+
import { ensureMetadata, ensureValidStack } from "../lib/safety.ts";
|
|
8
8
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
9
|
-
import {
|
|
10
|
-
import { closePr } from "../lib/github.ts";
|
|
9
|
+
import { getPrMergeState } from "../lib/github.ts";
|
|
11
10
|
import { confirmAction } from "../lib/ui.ts";
|
|
12
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
|
+
|
|
13
41
|
export default async function merge(args: string[]): Promise<void> {
|
|
14
42
|
const dryRun = args.includes("--dry-run");
|
|
43
|
+
const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
|
|
15
44
|
|
|
16
45
|
if (args.includes("--help")) {
|
|
17
46
|
console.log(`
|
|
18
|
-
gh-stack merge —
|
|
47
|
+
gh-stack merge — Squash-merge stack via GitHub
|
|
19
48
|
|
|
20
49
|
USAGE
|
|
21
|
-
gh-stack merge [--dry-run]
|
|
50
|
+
gh-stack merge [--dry-run] [-d|--delete-branch]
|
|
22
51
|
|
|
23
|
-
Squash-merges the stack from top to bottom
|
|
24
|
-
PR3 → PR2 → PR1,
|
|
52
|
+
Squash-merges the stack from top to bottom via GitHub:
|
|
53
|
+
PR3 → squash into PR2, PR2 → squash into PR1, PR1 → auto-merge into main.
|
|
25
54
|
|
|
26
|
-
|
|
55
|
+
All merges happen on GitHub, so PRs show as "Merged", Linear tickets
|
|
56
|
+
close automatically, and all GitHub Actions/webhooks fire normally.
|
|
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
|
+
|
|
61
|
+
OPTIONS
|
|
62
|
+
-d, --delete-branch Delete remote branches after merging
|
|
63
|
+
--dry-run Show what would happen without doing anything
|
|
27
64
|
`);
|
|
28
65
|
return;
|
|
29
66
|
}
|
|
30
67
|
|
|
31
|
-
await ensureCleanWorkingTree();
|
|
32
|
-
|
|
33
68
|
const meta = await ensureMetadata();
|
|
34
69
|
const currentBranch = await git.currentBranch();
|
|
35
70
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
@@ -44,26 +79,51 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
44
79
|
const ordered = getOrderedBranches(stack);
|
|
45
80
|
|
|
46
81
|
if (ordered.length <= 1) {
|
|
47
|
-
|
|
48
|
-
|
|
82
|
+
// Single branch — just enable auto-merge
|
|
83
|
+
const basePr = stack.branches[ordered[0]!]?.pr;
|
|
84
|
+
if (basePr) {
|
|
85
|
+
p.log.info("Single branch stack — enabling auto-merge on GitHub.");
|
|
86
|
+
if (!dryRun) {
|
|
87
|
+
try {
|
|
88
|
+
await $`gh pr merge ${basePr} --squash --auto`.quiet();
|
|
89
|
+
p.log.success(`Auto-merge enabled for PR #${basePr}`);
|
|
90
|
+
} catch {
|
|
91
|
+
p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
p.log.info("Single branch stack — merge via GitHub as normal.");
|
|
96
|
+
}
|
|
49
97
|
return;
|
|
50
98
|
}
|
|
51
99
|
|
|
52
|
-
p.intro(pc.cyan("
|
|
53
|
-
|
|
100
|
+
p.intro(pc.cyan("Stack Merge (via GitHub)"));
|
|
54
101
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
55
102
|
console.log();
|
|
56
103
|
|
|
57
|
-
//
|
|
104
|
+
// Check PR states and build the merge plan
|
|
58
105
|
const reversed = ordered.toReversed();
|
|
106
|
+
const baseBranch = ordered[0]!;
|
|
107
|
+
const basePr = stack.branches[baseBranch]?.pr;
|
|
108
|
+
|
|
59
109
|
console.log(` ${pc.bold("Merge plan:")}`);
|
|
60
110
|
for (let i = 0; i < reversed.length - 1; i++) {
|
|
61
111
|
const child = reversed[i]!;
|
|
62
112
|
const parent = stack.branches[child]?.parent || "???";
|
|
63
113
|
const childPr = stack.branches[child]?.pr;
|
|
64
|
-
|
|
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
|
+
|
|
120
|
+
console.log(
|
|
121
|
+
` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}${mergedLabel}`,
|
|
122
|
+
);
|
|
65
123
|
}
|
|
66
|
-
console.log(
|
|
124
|
+
console.log(
|
|
125
|
+
` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
|
|
126
|
+
);
|
|
67
127
|
console.log();
|
|
68
128
|
|
|
69
129
|
if (dryRun) {
|
|
@@ -71,7 +131,16 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
71
131
|
return;
|
|
72
132
|
}
|
|
73
133
|
|
|
74
|
-
|
|
134
|
+
// Check all PRs in stack have PR numbers
|
|
135
|
+
const missingPrs = ordered.filter((b) => !stack.branches[b]?.pr);
|
|
136
|
+
if (missingPrs.length > 0) {
|
|
137
|
+
p.cancel(
|
|
138
|
+
`Missing PR numbers for: ${missingPrs.join(", ")}\n\n Run ${pc.green("gh-stack submit")} first to create PRs.`,
|
|
139
|
+
);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const confirmed = await confirmAction("Squash-merge stack top-down via GitHub?");
|
|
75
144
|
if (!confirmed) {
|
|
76
145
|
p.cancel("Cancelled");
|
|
77
146
|
process.exit(0);
|
|
@@ -80,82 +149,105 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
80
149
|
// Take snapshot
|
|
81
150
|
await takeSnapshot(meta, stackName, "merge");
|
|
82
151
|
|
|
83
|
-
// Merge top-down: for each
|
|
152
|
+
// Merge top-down via GitHub: for each PR from top, squash-merge into parent
|
|
84
153
|
for (let i = 0; i < reversed.length - 1; i++) {
|
|
85
154
|
const childBranch = reversed[i]!;
|
|
86
|
-
const
|
|
87
|
-
|
|
155
|
+
const childPrNum = stack.branches[childBranch]!.pr!;
|
|
156
|
+
const parentBranch = stack.branches[childBranch]!.parent;
|
|
88
157
|
|
|
89
|
-
|
|
90
|
-
|
|
158
|
+
if (parentBranch === "main" || parentBranch === "master") continue;
|
|
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
|
+
}
|
|
91
166
|
|
|
92
167
|
console.log();
|
|
93
168
|
console.log(pc.cyan("━".repeat(40)));
|
|
94
|
-
console.log(
|
|
169
|
+
console.log(
|
|
170
|
+
`${pc.blue("Merge:")} PR #${childPrNum} ${pc.yellow(childBranch)} → ${pc.blue(parentBranch)}`,
|
|
171
|
+
);
|
|
95
172
|
console.log(pc.cyan("━".repeat(40)));
|
|
96
|
-
console.log();
|
|
97
|
-
|
|
98
|
-
// Checkout parent
|
|
99
|
-
p.log.info(`Checking out ${pc.yellow(parentBranch)}...`);
|
|
100
|
-
await git.checkout(parentBranch);
|
|
101
173
|
|
|
102
|
-
//
|
|
103
|
-
p.
|
|
104
|
-
|
|
174
|
+
// Wait for PR to be mergeable (GitHub may need time after previous merge)
|
|
175
|
+
const s = p.spinner();
|
|
176
|
+
s.start(`Checking PR #${childPrNum} is ready to merge...`);
|
|
177
|
+
const ready = await waitForMergeable(childPrNum, s);
|
|
105
178
|
|
|
106
|
-
if (!
|
|
107
|
-
|
|
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.");
|
|
108
183
|
process.exit(2);
|
|
109
184
|
}
|
|
110
185
|
|
|
111
|
-
|
|
112
|
-
const commitMsg = childPr ? `squash: ${childTitle} (#${childPr})` : `squash: ${childTitle}`;
|
|
186
|
+
s.message(`Squash-merging PR #${childPrNum} via GitHub...`);
|
|
113
187
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Optionally rebase base onto main
|
|
119
|
-
console.log();
|
|
120
|
-
const baseBranch = ordered[0]!;
|
|
121
|
-
const rebaseConfirmed = await confirmAction(`Rebase ${pc.yellow(baseBranch)} onto latest main?`);
|
|
188
|
+
const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
|
|
189
|
+
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
122
190
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
191
|
+
const proc = Bun.spawn(["gh", ...ghArgs], {
|
|
192
|
+
stdout: "pipe",
|
|
193
|
+
stderr: "pipe",
|
|
194
|
+
});
|
|
195
|
+
const stderr = await new Response(proc.stderr).text();
|
|
196
|
+
const exitCode = await proc.exited;
|
|
126
197
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const success = await git.rebase("origin/main");
|
|
131
|
-
if (success) {
|
|
132
|
-
p.log.success("Rebased onto main");
|
|
198
|
+
if (exitCode === 0) {
|
|
199
|
+
s.stop(`Merged PR #${childPrNum} into ${pc.blue(parentBranch)}`);
|
|
133
200
|
} else {
|
|
134
|
-
|
|
201
|
+
s.stop(pc.red(`Failed to merge PR #${childPrNum}`));
|
|
202
|
+
p.log.error(stderr.trim());
|
|
203
|
+
p.log.info("Re-run merge to continue from where you left off.");
|
|
135
204
|
process.exit(2);
|
|
136
205
|
}
|
|
137
206
|
}
|
|
138
207
|
|
|
139
|
-
//
|
|
208
|
+
// Base PR → enable auto-merge into main
|
|
140
209
|
console.log();
|
|
141
|
-
|
|
210
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
211
|
+
console.log(
|
|
212
|
+
`${pc.blue("Auto-merge:")} PR #${basePr} ${pc.yellow(baseBranch)} → ${pc.green("main")}`,
|
|
213
|
+
);
|
|
214
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
142
215
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
216
|
+
const autoSpinner = p.spinner();
|
|
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}...`);
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
|
|
226
|
+
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
227
|
+
|
|
228
|
+
const proc = Bun.spawn(["gh", ...ghArgs], {
|
|
229
|
+
stdout: "pipe",
|
|
230
|
+
stderr: "pipe",
|
|
231
|
+
});
|
|
232
|
+
await proc.exited;
|
|
233
|
+
|
|
234
|
+
autoSpinner.stop(`Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`);
|
|
235
|
+
} catch {
|
|
236
|
+
autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
|
|
237
|
+
p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Update local main and clean up
|
|
241
|
+
console.log();
|
|
242
|
+
const pullSpinner = p.spinner();
|
|
243
|
+
pullSpinner.start("Syncing local branches...");
|
|
244
|
+
try {
|
|
245
|
+
await $`git fetch origin`.quiet();
|
|
246
|
+
await git.checkout("main");
|
|
247
|
+
await $`git pull origin main`.quiet();
|
|
248
|
+
pullSpinner.stop("Local branches synced");
|
|
249
|
+
} catch {
|
|
250
|
+
pullSpinner.stop(pc.dim("Could not sync local branches — run git pull manually"));
|
|
159
251
|
}
|
|
160
252
|
|
|
161
253
|
// Archive the stack
|
|
@@ -168,40 +260,5 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
168
260
|
}
|
|
169
261
|
await writeMetadata(meta);
|
|
170
262
|
|
|
171
|
-
// Push base branch and enable auto-merge
|
|
172
|
-
console.log();
|
|
173
|
-
const pushAndMerge = await confirmAction(
|
|
174
|
-
`Push ${pc.yellow(baseBranch)} and enable auto-merge (squash)?`,
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
if (pushAndMerge) {
|
|
178
|
-
const pushSpinner = p.spinner();
|
|
179
|
-
pushSpinner.start(`Pushing ${pc.blue(baseBranch)}...`);
|
|
180
|
-
const pushOk = await git.forcePushWithLease(baseBranch);
|
|
181
|
-
if (pushOk) {
|
|
182
|
-
pushSpinner.stop(`Pushed ${pc.blue(baseBranch)}`);
|
|
183
|
-
} else {
|
|
184
|
-
pushSpinner.stop(pc.red(`Failed to push ${baseBranch}`));
|
|
185
|
-
p.outro(
|
|
186
|
-
pc.yellow("Stack merged locally but push failed. Push manually and merge via GitHub."),
|
|
187
|
-
);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Enable auto-merge via gh CLI
|
|
192
|
-
const basePrNum = stack.branches[baseBranch]?.pr;
|
|
193
|
-
if (basePrNum) {
|
|
194
|
-
const mergeSpinner = p.spinner();
|
|
195
|
-
mergeSpinner.start("Enabling auto-merge...");
|
|
196
|
-
try {
|
|
197
|
-
await $`gh pr merge ${basePrNum} --squash --auto`.quiet();
|
|
198
|
-
mergeSpinner.stop(`Auto-merge enabled for PR #${basePrNum} — will merge when CI passes`);
|
|
199
|
-
} catch {
|
|
200
|
-
mergeSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePrNum}`));
|
|
201
|
-
p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePrNum} --squash --auto`)}`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
263
|
p.outro(pc.green("Stack merge complete! Stack archived."));
|
|
207
264
|
}
|
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
|
*/
|