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 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
- In --yes mode, auto-generates PR titles from branch names.
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 locally (PR3 → PR2 → PR1),
132
- then optionally rebase onto main. Closes intermediate PRs and
133
- archives 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.
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 # Auto-generates PR titles
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.2.2",
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,34 +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 { 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 — Local squash-merge top-down
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 locally:
23
- PR3 → PR2 → PR1, then optionally rebases PR1 onto main.
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
- This keeps all commits local (avoiding orphaned squash commits).
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
- p.log.info("Single branch stack — nothing to merge down.");
47
- 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
+ }
48
64
  return;
49
65
  }
50
66
 
51
- p.intro(pc.cyan("Git Stack Merge (Top-Down)"));
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(` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → ${pc.blue(parent)}`);
79
+ console.log(
80
+ ` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}`,
81
+ );
64
82
  }
65
- 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
+ );
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
- 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?");
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 branch from top to bottom (skip the base)
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 parentBranch = stack.branches[childBranch]?.parent;
86
- if (!parentBranch || parentBranch === "main") continue;
116
+ const childPrNum = stack.branches[childBranch]!.pr!;
117
+ const parentBranch = stack.branches[childBranch]!.parent;
87
118
 
88
- const childPr = stack.branches[childBranch]?.pr;
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(`${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
+ );
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
- // Optionally rebase base onto main
118
- console.log();
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
- if (rebaseConfirmed) {
123
- p.log.info("Fetching latest main...");
124
- await git.fetchMain();
131
+ const ghArgs = ["pr", "merge", String(childPrNum), "--squash"];
132
+ if (deleteFlag) ghArgs.push("--delete-branch");
125
133
 
126
- p.log.info(`Checking out ${pc.yellow(baseBranch)}...`);
127
- 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;
128
140
 
129
- const success = await git.rebase("origin/main");
130
- if (success) {
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
- 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.");
134
147
  process.exit(2);
135
148
  }
136
149
  }
137
150
 
138
- // Close intermediate PRs
151
+ // Base PR → enable auto-merge into main
139
152
  console.log();
140
- 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
+ }
141
171
 
142
- if (closePrs) {
143
- const basePr = stack.branches[ordered[0]!]?.pr;
144
- for (let i = 1; i < ordered.length; i++) {
145
- const prNum = stack.branches[ordered[i]!]?.pr;
146
- if (prNum) {
147
- const comment = basePr
148
- ? `Merged locally into base PR #${basePr}. See the base PR for the full stack.`
149
- : "Merged locally into base PR.";
150
- const ok = await closePr(prNum, comment);
151
- if (ok) {
152
- p.log.success(`Closed PR #${prNum}`);
153
- } else {
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
  }