gh-stack 0.2.3 → 0.3.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 +5 -5
- package/package.json +1 -1
- package/src/commands/merge.ts +101 -111
package/README.md
CHANGED
|
@@ -129,11 +129,11 @@ 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.
|
|
137
137
|
|
|
138
138
|
delete [<branch>]
|
|
139
139
|
Remove a branch from the stack and re-parent its children.
|
package/package.json
CHANGED
package/src/commands/merge.ts
CHANGED
|
@@ -1,35 +1,37 @@
|
|
|
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 { $ } from "bun";
|
|
10
|
-
import { closePr } from "../lib/github.ts";
|
|
11
9
|
import { confirmAction } from "../lib/ui.ts";
|
|
12
10
|
|
|
13
11
|
export default async function merge(args: string[]): Promise<void> {
|
|
14
12
|
const dryRun = args.includes("--dry-run");
|
|
13
|
+
const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
|
|
15
14
|
|
|
16
15
|
if (args.includes("--help")) {
|
|
17
16
|
console.log(`
|
|
18
|
-
gh-stack merge —
|
|
17
|
+
gh-stack merge — Squash-merge stack via GitHub
|
|
19
18
|
|
|
20
19
|
USAGE
|
|
21
|
-
gh-stack merge [--dry-run]
|
|
20
|
+
gh-stack merge [--dry-run] [-d|--delete-branch]
|
|
21
|
+
|
|
22
|
+
Squash-merges the stack from top to bottom via GitHub:
|
|
23
|
+
PR3 → squash into PR2, PR2 → squash into PR1, PR1 → auto-merge into main.
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
All merges happen on GitHub, so PRs show as "Merged", Linear tickets
|
|
26
|
+
close automatically, and all GitHub Actions/webhooks fire normally.
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
OPTIONS
|
|
29
|
+
-d, --delete-branch Delete remote branches after merging
|
|
30
|
+
--dry-run Show what would happen without doing anything
|
|
27
31
|
`);
|
|
28
32
|
return;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
await ensureCleanWorkingTree();
|
|
32
|
-
|
|
33
35
|
const meta = await ensureMetadata();
|
|
34
36
|
const currentBranch = await git.currentBranch();
|
|
35
37
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
@@ -44,12 +46,25 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
44
46
|
const ordered = getOrderedBranches(stack);
|
|
45
47
|
|
|
46
48
|
if (ordered.length <= 1) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
// Single branch — just enable auto-merge
|
|
50
|
+
const basePr = stack.branches[ordered[0]!]?.pr;
|
|
51
|
+
if (basePr) {
|
|
52
|
+
p.log.info("Single branch stack — enabling auto-merge on GitHub.");
|
|
53
|
+
if (!dryRun) {
|
|
54
|
+
try {
|
|
55
|
+
await $`gh pr merge ${basePr} --squash --auto`.quiet();
|
|
56
|
+
p.log.success(`Auto-merge enabled for PR #${basePr}`);
|
|
57
|
+
} catch {
|
|
58
|
+
p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
p.log.info("Single branch stack — merge via GitHub as normal.");
|
|
63
|
+
}
|
|
49
64
|
return;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
p.intro(pc.cyan("
|
|
67
|
+
p.intro(pc.cyan("Stack Merge (via GitHub)"));
|
|
53
68
|
|
|
54
69
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
55
70
|
console.log();
|
|
@@ -61,9 +76,15 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
61
76
|
const child = reversed[i]!;
|
|
62
77
|
const parent = stack.branches[child]?.parent || "???";
|
|
63
78
|
const childPr = stack.branches[child]?.pr;
|
|
64
|
-
console.log(
|
|
79
|
+
console.log(
|
|
80
|
+
` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}`,
|
|
81
|
+
);
|
|
65
82
|
}
|
|
66
|
-
|
|
83
|
+
const baseBranch = ordered[0]!;
|
|
84
|
+
const basePr = stack.branches[baseBranch]?.pr;
|
|
85
|
+
console.log(
|
|
86
|
+
` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
|
|
87
|
+
);
|
|
67
88
|
console.log();
|
|
68
89
|
|
|
69
90
|
if (dryRun) {
|
|
@@ -71,7 +92,16 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
71
92
|
return;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
|
-
|
|
95
|
+
// Check all PRs in stack have PR numbers
|
|
96
|
+
const missingPrs = ordered.filter((b) => !stack.branches[b]?.pr);
|
|
97
|
+
if (missingPrs.length > 0) {
|
|
98
|
+
p.cancel(
|
|
99
|
+
`Missing PR numbers for: ${missingPrs.join(", ")}\n\n Run ${pc.green("gh-stack submit")} first to create PRs.`,
|
|
100
|
+
);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const confirmed = await confirmAction("Squash-merge stack top-down via GitHub?");
|
|
75
105
|
if (!confirmed) {
|
|
76
106
|
p.cancel("Cancelled");
|
|
77
107
|
process.exit(0);
|
|
@@ -80,82 +110,77 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
80
110
|
// Take snapshot
|
|
81
111
|
await takeSnapshot(meta, stackName, "merge");
|
|
82
112
|
|
|
83
|
-
// Merge top-down: for each
|
|
113
|
+
// Merge top-down via GitHub: for each PR from top, squash-merge into parent
|
|
84
114
|
for (let i = 0; i < reversed.length - 1; i++) {
|
|
85
115
|
const childBranch = reversed[i]!;
|
|
86
|
-
const
|
|
87
|
-
|
|
116
|
+
const childPrNum = stack.branches[childBranch]!.pr!;
|
|
117
|
+
const parentBranch = stack.branches[childBranch]!.parent;
|
|
88
118
|
|
|
89
|
-
|
|
90
|
-
const childTitle = stack.branches[childBranch]?.description || childBranch;
|
|
119
|
+
if (parentBranch === "main" || parentBranch === "master") continue;
|
|
91
120
|
|
|
92
121
|
console.log();
|
|
93
122
|
console.log(pc.cyan("━".repeat(40)));
|
|
94
|
-
console.log(
|
|
123
|
+
console.log(
|
|
124
|
+
`${pc.blue("Merge:")} PR #${childPrNum} ${pc.yellow(childBranch)} → ${pc.blue(parentBranch)}`,
|
|
125
|
+
);
|
|
95
126
|
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
|
-
|
|
102
|
-
// Squash merge child
|
|
103
|
-
p.log.info(`Squash-merging ${pc.yellow(childBranch)}...`);
|
|
104
|
-
const success = await git.mergeSquash(childBranch);
|
|
105
|
-
|
|
106
|
-
if (!success) {
|
|
107
|
-
p.log.error("Merge conflict — resolve and try again");
|
|
108
|
-
process.exit(2);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Commit
|
|
112
|
-
const commitMsg = childPr ? `squash: ${childTitle} (#${childPr})` : `squash: ${childTitle}`;
|
|
113
127
|
|
|
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?`);
|
|
128
|
+
const s = p.spinner();
|
|
129
|
+
s.start(`Squash-merging PR #${childPrNum} via GitHub...`);
|
|
122
130
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
await git.fetchMain();
|
|
131
|
+
const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
|
|
132
|
+
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
126
133
|
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
const proc = Bun.spawn(["gh", ...ghArgs], {
|
|
135
|
+
stdout: "pipe",
|
|
136
|
+
stderr: "pipe",
|
|
137
|
+
});
|
|
138
|
+
const stderr = await new Response(proc.stderr).text();
|
|
139
|
+
const exitCode = await proc.exited;
|
|
129
140
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
p.log.success("Rebased onto main");
|
|
141
|
+
if (exitCode === 0) {
|
|
142
|
+
s.stop(`Merged PR #${childPrNum} into ${pc.blue(parentBranch)}`);
|
|
133
143
|
} else {
|
|
134
|
-
|
|
144
|
+
s.stop(pc.red(`Failed to merge PR #${childPrNum}`));
|
|
145
|
+
p.log.error(stderr.trim());
|
|
146
|
+
p.log.info("Resolve the issue on GitHub, then re-run merge to continue.");
|
|
135
147
|
process.exit(2);
|
|
136
148
|
}
|
|
137
149
|
}
|
|
138
150
|
|
|
139
|
-
//
|
|
151
|
+
// Base PR → enable auto-merge into main
|
|
140
152
|
console.log();
|
|
141
|
-
|
|
153
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
154
|
+
console.log(
|
|
155
|
+
`${pc.blue("Auto-merge:")} PR #${basePr} ${pc.yellow(baseBranch)} → ${pc.green("main")}`,
|
|
156
|
+
);
|
|
157
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
158
|
+
|
|
159
|
+
const autoSpinner = p.spinner();
|
|
160
|
+
autoSpinner.start(`Enabling auto-merge for PR #${basePr}...`);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
|
|
164
|
+
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
165
|
+
await $`gh pr merge ${basePr} --squash --auto ${deleteFlag ? "--delete-branch" : ""}`.quiet();
|
|
166
|
+
autoSpinner.stop(`Auto-merge enabled for PR #${basePr} — will merge into main when CI passes`);
|
|
167
|
+
} catch {
|
|
168
|
+
autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
|
|
169
|
+
p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${basePr} --squash --auto`)}`);
|
|
170
|
+
}
|
|
142
171
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
p.log.warn(`Could not close PR #${prNum}`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
172
|
+
// Update local main and clean up
|
|
173
|
+
console.log();
|
|
174
|
+
const pullSpinner = p.spinner();
|
|
175
|
+
pullSpinner.start("Syncing local branches...");
|
|
176
|
+
try {
|
|
177
|
+
await $`git fetch origin`.quiet();
|
|
178
|
+
// Checkout main and pull so local is up to date
|
|
179
|
+
await git.checkout("main");
|
|
180
|
+
await $`git pull origin main`.quiet();
|
|
181
|
+
pullSpinner.stop("Local branches synced");
|
|
182
|
+
} catch {
|
|
183
|
+
pullSpinner.stop(pc.dim("Could not sync local branches — run git pull manually"));
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
// Archive the stack
|
|
@@ -168,40 +193,5 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
168
193
|
}
|
|
169
194
|
await writeMetadata(meta);
|
|
170
195
|
|
|
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
196
|
p.outro(pc.green("Stack merge complete! Stack archived."));
|
|
207
197
|
}
|