gh-stack 0.2.2 → 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 +12 -8
- package/package.json +1 -1
- package/src/commands/merge.ts +101 -80
package/README.md
CHANGED
|
@@ -78,12 +78,14 @@ create <branch-name> [--description <desc>]
|
|
|
78
78
|
Create a new git branch off the current branch and add it to
|
|
79
79
|
the stack. The current branch must already be tracked.
|
|
80
80
|
|
|
81
|
-
submit [-d|--draft] [-n|--no-edit] [--dry-run]
|
|
81
|
+
submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
|
|
82
82
|
Push all downstack branches to GitHub, create PRs for branches
|
|
83
83
|
that don't have them, and update all PR descriptions with stack
|
|
84
84
|
visualization. Idempotent — safe to run repeatedly.
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
--title/-t and --body/-b provide PR details directly (skips prompts).
|
|
87
|
+
--body-file reads body from a file. In --yes mode without --title,
|
|
88
|
+
auto-generates PR titles from branch names.
|
|
87
89
|
```
|
|
88
90
|
|
|
89
91
|
### Stack Navigation
|
|
@@ -127,10 +129,11 @@ sync [--dry-run]
|
|
|
127
129
|
Fetch main, rebase the base branch onto main, then restack all
|
|
128
130
|
children. Creates tags for ALL branches before any rebasing starts.
|
|
129
131
|
|
|
130
|
-
merge [--dry-run]
|
|
131
|
-
Squash-merge the stack top-down
|
|
132
|
-
then
|
|
133
|
-
|
|
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.
|
|
134
137
|
|
|
135
138
|
delete [<branch>]
|
|
136
139
|
Remove a branch from the stack and re-parent its children.
|
|
@@ -251,7 +254,7 @@ gh-stack top # jump to tip
|
|
|
251
254
|
gh-stack status
|
|
252
255
|
|
|
253
256
|
# When PRs are approved, merge the stack
|
|
254
|
-
gh-stack merge
|
|
257
|
+
gh-stack merge # squash-merges down, pushes, enables auto-merge
|
|
255
258
|
```
|
|
256
259
|
|
|
257
260
|
## Agent/CI Usage
|
|
@@ -263,7 +266,8 @@ gh-stack is designed to be used by AI agents and CI pipelines:
|
|
|
263
266
|
export GH_STACK_YES=1
|
|
264
267
|
|
|
265
268
|
gh-stack init # No confirmations
|
|
266
|
-
gh-stack submit
|
|
269
|
+
gh-stack submit -t "Title [WEB-1234]" -b "Description" # Explicit PR details
|
|
270
|
+
gh-stack submit -n # Or auto-generate titles
|
|
267
271
|
gh-stack sync
|
|
268
272
|
gh-stack restack
|
|
269
273
|
|
package/package.json
CHANGED
package/src/commands/merge.ts
CHANGED
|
@@ -1,34 +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 { closePr } from "../lib/github.ts";
|
|
10
9
|
import { confirmAction } from "../lib/ui.ts";
|
|
11
10
|
|
|
12
11
|
export default async function merge(args: string[]): Promise<void> {
|
|
13
12
|
const dryRun = args.includes("--dry-run");
|
|
13
|
+
const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
|
|
14
14
|
|
|
15
15
|
if (args.includes("--help")) {
|
|
16
16
|
console.log(`
|
|
17
|
-
gh-stack merge —
|
|
17
|
+
gh-stack merge — Squash-merge stack via GitHub
|
|
18
18
|
|
|
19
19
|
USAGE
|
|
20
|
-
gh-stack merge [--dry-run]
|
|
20
|
+
gh-stack merge [--dry-run] [-d|--delete-branch]
|
|
21
21
|
|
|
22
|
-
Squash-merges the stack from top to bottom
|
|
23
|
-
PR3 → PR2 → PR1,
|
|
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.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
All merges happen on GitHub, so PRs show as "Merged", Linear tickets
|
|
26
|
+
close automatically, and all GitHub Actions/webhooks fire normally.
|
|
27
|
+
|
|
28
|
+
OPTIONS
|
|
29
|
+
-d, --delete-branch Delete remote branches after merging
|
|
30
|
+
--dry-run Show what would happen without doing anything
|
|
26
31
|
`);
|
|
27
32
|
return;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
await ensureCleanWorkingTree();
|
|
31
|
-
|
|
32
35
|
const meta = await ensureMetadata();
|
|
33
36
|
const currentBranch = await git.currentBranch();
|
|
34
37
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
@@ -43,12 +46,25 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
43
46
|
const ordered = getOrderedBranches(stack);
|
|
44
47
|
|
|
45
48
|
if (ordered.length <= 1) {
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
64
|
return;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
p.intro(pc.cyan("
|
|
67
|
+
p.intro(pc.cyan("Stack Merge (via GitHub)"));
|
|
52
68
|
|
|
53
69
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
54
70
|
console.log();
|
|
@@ -60,9 +76,15 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
60
76
|
const child = reversed[i]!;
|
|
61
77
|
const parent = stack.branches[child]?.parent || "???";
|
|
62
78
|
const childPr = stack.branches[child]?.pr;
|
|
63
|
-
console.log(
|
|
79
|
+
console.log(
|
|
80
|
+
` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}`,
|
|
81
|
+
);
|
|
64
82
|
}
|
|
65
|
-
|
|
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
|
+
);
|
|
66
88
|
console.log();
|
|
67
89
|
|
|
68
90
|
if (dryRun) {
|
|
@@ -70,7 +92,16 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
70
92
|
return;
|
|
71
93
|
}
|
|
72
94
|
|
|
73
|
-
|
|
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?");
|
|
74
105
|
if (!confirmed) {
|
|
75
106
|
p.cancel("Cancelled");
|
|
76
107
|
process.exit(0);
|
|
@@ -79,82 +110,77 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
79
110
|
// Take snapshot
|
|
80
111
|
await takeSnapshot(meta, stackName, "merge");
|
|
81
112
|
|
|
82
|
-
// Merge top-down: for each
|
|
113
|
+
// Merge top-down via GitHub: for each PR from top, squash-merge into parent
|
|
83
114
|
for (let i = 0; i < reversed.length - 1; i++) {
|
|
84
115
|
const childBranch = reversed[i]!;
|
|
85
|
-
const
|
|
86
|
-
|
|
116
|
+
const childPrNum = stack.branches[childBranch]!.pr!;
|
|
117
|
+
const parentBranch = stack.branches[childBranch]!.parent;
|
|
87
118
|
|
|
88
|
-
|
|
89
|
-
const childTitle = stack.branches[childBranch]?.description || childBranch;
|
|
119
|
+
if (parentBranch === "main" || parentBranch === "master") continue;
|
|
90
120
|
|
|
91
121
|
console.log();
|
|
92
122
|
console.log(pc.cyan("━".repeat(40)));
|
|
93
|
-
console.log(
|
|
123
|
+
console.log(
|
|
124
|
+
`${pc.blue("Merge:")} PR #${childPrNum} ${pc.yellow(childBranch)} → ${pc.blue(parentBranch)}`,
|
|
125
|
+
);
|
|
94
126
|
console.log(pc.cyan("━".repeat(40)));
|
|
95
|
-
console.log();
|
|
96
|
-
|
|
97
|
-
// Checkout parent
|
|
98
|
-
p.log.info(`Checking out ${pc.yellow(parentBranch)}...`);
|
|
99
|
-
await git.checkout(parentBranch);
|
|
100
|
-
|
|
101
|
-
// Squash merge child
|
|
102
|
-
p.log.info(`Squash-merging ${pc.yellow(childBranch)}...`);
|
|
103
|
-
const success = await git.mergeSquash(childBranch);
|
|
104
|
-
|
|
105
|
-
if (!success) {
|
|
106
|
-
p.log.error("Merge conflict — resolve and try again");
|
|
107
|
-
process.exit(2);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Commit
|
|
111
|
-
const commitMsg = childPr ? `squash: ${childTitle} (#${childPr})` : `squash: ${childTitle}`;
|
|
112
|
-
|
|
113
|
-
await git.commit(commitMsg);
|
|
114
|
-
p.log.success(`Merged ${childBranch} into ${parentBranch}`);
|
|
115
|
-
}
|
|
116
127
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const baseBranch = ordered[0]!;
|
|
120
|
-
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...`);
|
|
121
130
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await git.fetchMain();
|
|
131
|
+
const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
|
|
132
|
+
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
125
133
|
|
|
126
|
-
|
|
127
|
-
|
|
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;
|
|
128
140
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
p.log.success("Rebased onto main");
|
|
141
|
+
if (exitCode === 0) {
|
|
142
|
+
s.stop(`Merged PR #${childPrNum} into ${pc.blue(parentBranch)}`);
|
|
132
143
|
} else {
|
|
133
|
-
|
|
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.");
|
|
134
147
|
process.exit(2);
|
|
135
148
|
}
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
//
|
|
151
|
+
// Base PR → enable auto-merge into main
|
|
139
152
|
console.log();
|
|
140
|
-
|
|
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
|
+
}
|
|
141
171
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
p.log.warn(`Could not close PR #${prNum}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
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"));
|
|
158
184
|
}
|
|
159
185
|
|
|
160
186
|
// Archive the stack
|
|
@@ -168,9 +194,4 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
168
194
|
await writeMetadata(meta);
|
|
169
195
|
|
|
170
196
|
p.outro(pc.green("Stack merge complete! Stack archived."));
|
|
171
|
-
|
|
172
|
-
console.log();
|
|
173
|
-
console.log(" Next steps:");
|
|
174
|
-
console.log(` 1. Push ${pc.yellow(baseBranch)} and let CI run`);
|
|
175
|
-
console.log(` 2. Squash-merge ${pc.yellow(baseBranch)} into main via GitHub`);
|
|
176
197
|
}
|