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 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 locally (PR3 → PR2 → PR1),
134
- then rebases onto main, closes intermediate PRs, pushes the
135
- base branch, and enables GitHub auto-merge (squash). Archives
136
- the stack on completion.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -1,35 +1,37 @@
1
- // gh-stack merge — Local squash-merge top-down
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, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
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 — Local squash-merge top-down
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
- Squash-merges the stack from top to bottom locally:
24
- PR3 → PR2 → PR1, then optionally rebases PR1 onto main.
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
- This keeps all commits local (avoiding orphaned squash commits).
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
- p.log.info("Single branch stack — nothing to merge down.");
48
- p.log.info("Just merge via GitHub as normal.");
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("Git Stack Merge (Top-Down)"));
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(` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → ${pc.blue(parent)}`);
79
+ console.log(
80
+ ` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}`,
81
+ );
65
82
  }
66
- console.log(` ${pc.blue(ordered[0]!)} → ${pc.green("main")} (via GitHub)`);
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
- const confirmed = await confirmAction("Merge stack top-down?");
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 branch from top to bottom (skip the base)
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 parentBranch = stack.branches[childBranch]?.parent;
87
- if (!parentBranch || parentBranch === "main") continue;
116
+ const childPrNum = stack.branches[childBranch]!.pr!;
117
+ const parentBranch = stack.branches[childBranch]!.parent;
88
118
 
89
- const childPr = stack.branches[childBranch]?.pr;
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(`${pc.blue("Merge:")} ${pc.yellow(childBranch)} → ${pc.blue(parentBranch)}`);
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
- await git.commit(commitMsg);
115
- p.log.success(`Merged ${childBranch} into ${parentBranch}`);
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
- if (rebaseConfirmed) {
124
- p.log.info("Fetching latest main...");
125
- await git.fetchMain();
131
+ const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
132
+ if (deleteFlag) ghArgs.push("--delete-branch");
126
133
 
127
- p.log.info(`Checking out ${pc.yellow(baseBranch)}...`);
128
- await git.checkout(baseBranch);
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
- const success = await git.rebase("origin/main");
131
- if (success) {
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
- p.log.error("Rebase conflict — resolve manually");
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
- // Close intermediate PRs
151
+ // Base PR → enable auto-merge into main
140
152
  console.log();
141
- const closePrs = await confirmAction("Close intermediate PRs on GitHub?");
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
- if (closePrs) {
144
- const basePr = stack.branches[ordered[0]!]?.pr;
145
- for (let i = 1; i < ordered.length; i++) {
146
- const prNum = stack.branches[ordered[i]!]?.pr;
147
- if (prNum) {
148
- const comment = basePr
149
- ? `Merged locally into base PR #${basePr}. See the base PR for the full stack.`
150
- : "Merged locally into base PR.";
151
- const ok = await closePr(prNum, comment);
152
- if (ok) {
153
- p.log.success(`Closed PR #${prNum}`);
154
- } else {
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
  }