gh-stack 0.3.1 → 0.7.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
@@ -83,6 +83,13 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
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
+ Self-healing: if the current branch isn't tracked in a stack yet,
87
+ submit auto-detects the chain from trunk → current, registers (or
88
+ reconciles into) a stack, then pushes + creates PRs for the whole
89
+ chain. You never need to run `gh-stack init` first — running submit
90
+ from the top of a bare chain of local branches converges to the
91
+ expected end state (stack registered, branches pushed, PRs created).
92
+
86
93
  --title/-t and --body/-b provide PR details directly (skips prompts).
87
94
  --body-file reads body from a file. In --yes mode without --title,
88
95
  auto-generates PR titles from branch names.
@@ -117,7 +124,8 @@ ls
117
124
  ```
118
125
  restack [--resume] [--dry-run] [--verbose]
119
126
  Rebase the current branch and all descendants onto their parents.
120
- Uses tag-based references for stable rebasing across the chain.
127
+ Uses metadata snapshots to recover the correct rebase base even
128
+ after a parent's history has been rewritten.
121
129
 
122
130
  On conflict:
123
131
  git rebase --continue
@@ -127,14 +135,19 @@ restack [--resume] [--dry-run] [--verbose]
127
135
 
128
136
  sync [--dry-run]
129
137
  Fetch main, rebase the base branch onto main, then restack all
130
- children. Creates tags for ALL branches before any rebasing starts.
138
+ children. Snapshots every branch's pre-sync tip so children can be
139
+ correctly rebased even though their parent's history was rewritten.
131
140
 
132
- merge [--dry-run] [-d|--delete-branch]
141
+ merge [--dry-run] [-d|--delete-branch] [--collapse]
133
142
  Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
134
143
  then enables auto-merge for the base PR into main. All merges
135
144
  happen on GitHub so PRs show as "Merged", Linear tickets close
136
145
  automatically, and GitHub Actions fire normally. Skips already-
137
146
  merged PRs (safe to re-run). Waits for GitHub between merges.
147
+ --collapse Stop after collapsing the stack into the base PR;
148
+ leaves the base PR open against main so you can
149
+ review the cumulative diff on GitHub. Re-run
150
+ `gh-stack merge` (without --collapse) to finish.
138
151
 
139
152
  delete [<branch>]
140
153
  Remove a branch from the stack and re-parent its children.
@@ -164,14 +177,18 @@ archive [--restore <name>]
164
177
 
165
178
  ```
166
179
  --yes, -y Skip all confirmations (for agents/CI)
180
+ --plain Plain output — no spinners, colors, or banner boxes
167
181
  --help Show help for a command
168
182
  --version, -V Show version
169
183
  ```
170
184
 
185
+ `--plain` is auto-enabled when `--yes` / `GH_STACK_YES=1` is set, so agents get clean, easily-filtered output by default. Use `--plain` alone if you want plain output but still interactive prompts.
186
+
171
187
  ## Environment Variables
172
188
 
173
189
  ```
174
- GH_STACK_YES=1 Skip all confirmations (same as --yes)
190
+ GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
191
+ GH_STACK_PLAIN=1 Plain output (same as --plain)
175
192
  GH_STACK_NO_COLOR=1 Disable colored output
176
193
  ```
177
194
 
@@ -181,9 +198,13 @@ GH_STACK_NO_COLOR=1 Disable colored output
181
198
 
182
199
  `gh-stack init` auto-detects branch chains by walking git ancestry. From the top branch, it finds all local branches whose tips are strict ancestors (but not already merged into trunk) and reconstructs the chain with correct parent relationships.
183
200
 
184
- ### Tag-Based Rebasing
201
+ ### Snapshot-Based Rebasing
202
+
203
+ The critical insight: after a parent branch's history is rewritten (e.g., rebased onto a new main), `git merge-base(child, parent)` falls all the way back to the original main — and using that as a rebase base would replay the parent's old commits onto the child, producing ghost-conflicts on the parent's own work.
185
204
 
186
- The critical insight: after rebasing a parent branch, `git merge-base` returns wrong results for its children. gh-stack solves this by creating temporary `stack-sync-*` tags marking each branch's divergence point **before** any rebasing starts, then using those stable references for `git rebase --onto`.
205
+ gh-stack solves this by snapshotting every branch's tip **before** any destructive operation. When restacking a child whose parent has been rewritten, gh-stack walks the snapshots newest-first and finds the most recent recorded tip that's no longer an ancestor of the parent's current tip. That orphaned SHA is the correct rebase base — `git rebase --onto <new-parent-tip> <orphaned-old-tip> <child>` replays only the child's unique commits.
206
+
207
+ Snapshots also power `gh-stack undo`, so the same data structure does double duty.
187
208
 
188
209
  ### Stack Visualization
189
210
 
@@ -256,6 +277,11 @@ gh-stack status
256
277
 
257
278
  # When PRs are approved, merge the stack
258
279
  gh-stack merge # squash-merges down, pushes, enables auto-merge
280
+
281
+ # Or: collapse first to review the cumulative diff before shipping
282
+ gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
283
+ # ...review base PR on GitHub...
284
+ gh-stack merge # finishes base PR → main + archives the stack
259
285
  ```
260
286
 
261
287
  ## Agent/CI Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.3.1",
3
+ "version": "0.7.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  // gh-stack archive — Manage archived stacks
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import { writeMetadata } from "../lib/metadata.ts";
5
5
  import { ensureMetadata } from "../lib/safety.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack bottom — Jump to the base of the stack
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack checkout — Switch between branches or stacks
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import { writeMetadata, getOrderedBranches, findStackForBranch } from "../lib/metadata.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack create — Create a new branch and add to the stack
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import { findStackForBranch, addBranchToStack } from "../lib/metadata.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack delete — Delete a branch from the stack (re-link parent chain)
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
5
5
  import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack down — Move to parent branch (downstack)
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import { findStackForBranch, writeMetadata } from "../lib/metadata.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack init — Create a new stack, auto-detecting branch chains
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import {
@@ -12,6 +12,7 @@ import {
12
12
  } from "../lib/metadata.ts";
13
13
  import { getPrNumber } from "../lib/github.ts";
14
14
  import { isAutoYes } from "../lib/ui.ts";
15
+ import { detectBranchChain } from "../lib/chain.ts";
15
16
  import type { StackMetadata, Branch } from "../types.ts";
16
17
 
17
18
  const HELP = `
@@ -37,50 +38,6 @@ EXAMPLES
37
38
  gh-stack init --parent develop # Different trunk branch
38
39
  `;
39
40
 
40
- /**
41
- * Detect a chain of branches from trunk to the current branch.
42
- *
43
- * Walks down from the current branch by finding local branches whose
44
- * tips are strict ancestors of the current branch. Sorts by commit
45
- * distance (closest = direct parent) to reconstruct the chain.
46
- *
47
- * Returns branches in bottom-up order: [closest-to-trunk, ..., current]
48
- */
49
- async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
50
- const allBranches = await git.allLocalBranches();
51
-
52
- // Branches to exclude: current, trunk, and always main/master
53
- const excluded = new Set([currentBranch, trunk, "main", "master"]);
54
-
55
- // Find branches that are strict ancestors of current (and not trunk)
56
- const ancestors: { name: string; distance: number }[] = [];
57
-
58
- for (const branch of allBranches) {
59
- if (excluded.has(branch)) continue;
60
-
61
- // Is this branch's tip an ancestor of current?
62
- if (await git.isAncestor(branch, currentBranch)) {
63
- // Skip branches already merged into trunk (stale/old branches)
64
- if (await git.isAncestor(branch, trunk)) continue;
65
-
66
- // How many commits between this branch and current?
67
- const distance = await git.commitCount(branch, currentBranch);
68
- if (distance > 0) {
69
- ancestors.push({ name: branch, distance });
70
- }
71
- }
72
- }
73
-
74
- // Sort by distance descending (furthest from current = closest to trunk)
75
- ancestors.sort((a, b) => b.distance - a.distance);
76
-
77
- // Build chain: ancestors (sorted trunk→current) + current branch
78
- const chain = ancestors.map((a) => a.name);
79
- chain.push(currentBranch);
80
-
81
- return chain;
82
- }
83
-
84
41
  export default async function init(args: string[]): Promise<void> {
85
42
  // Parse flags
86
43
  let nameFlag: string | undefined;
@@ -1,5 +1,5 @@
1
1
  // gh-stack log — Display current stack tree (default command)
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import {
@@ -1,5 +1,5 @@
1
1
  // gh-stack merge — Squash-merge stack top-down via GitHub
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import { $ } from "bun";
5
5
  import * as git from "../lib/git.ts";
@@ -41,13 +41,14 @@ async function waitForMergeable(
41
41
  export default async function merge(args: string[]): Promise<void> {
42
42
  const dryRun = args.includes("--dry-run");
43
43
  const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
44
+ const collapse = args.includes("--collapse") || args.includes("--stop-at-base");
44
45
 
45
46
  if (args.includes("--help")) {
46
47
  console.log(`
47
48
  gh-stack merge — Squash-merge stack via GitHub
48
49
 
49
50
  USAGE
50
- gh-stack merge [--dry-run] [-d|--delete-branch]
51
+ gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
51
52
 
52
53
  Squash-merges the stack from top to bottom via GitHub:
53
54
  PR3 → squash into PR2, PR2 → squash into PR1, PR1 → auto-merge into main.
@@ -61,6 +62,11 @@ Waits for GitHub to process between merges if needed.
61
62
  OPTIONS
62
63
  -d, --delete-branch Delete remote branches after merging
63
64
  --dry-run Show what would happen without doing anything
65
+ --collapse Stop after collapsing the stack into the base PR;
66
+ do NOT merge base PR into main. Lets you review the
67
+ cumulative diff on GitHub first. Re-run ${pc.green("gh-stack merge")}
68
+ (without --collapse) to finish the job.
69
+ --stop-at-base Alias for --collapse
64
70
  `);
65
71
  return;
66
72
  }
@@ -81,6 +87,12 @@ OPTIONS
81
87
  if (ordered.length <= 1) {
82
88
  // Single branch — just enable auto-merge
83
89
  const basePr = stack.branches[ordered[0]!]?.pr;
90
+ if (collapse) {
91
+ p.log.info(
92
+ `Single-branch stack — nothing to collapse. The base PR${basePr ? ` (#${basePr})` : ""} already targets main.`,
93
+ );
94
+ return;
95
+ }
84
96
  if (basePr) {
85
97
  p.log.info("Single branch stack — enabling auto-merge on GitHub.");
86
98
  if (!dryRun) {
@@ -97,8 +109,13 @@ OPTIONS
97
109
  return;
98
110
  }
99
111
 
100
- p.intro(pc.cyan("Stack Merge (via GitHub)"));
112
+ p.intro(pc.cyan(collapse ? "Stack Collapse (via GitHub)" : "Stack Merge (via GitHub)"));
101
113
  p.log.info(`Stack: ${pc.yellow(stackName)}`);
114
+ if (collapse) {
115
+ p.log.info(
116
+ pc.dim("--collapse: will stop after collapsing into base PR; base will NOT merge to main."),
117
+ );
118
+ }
102
119
  console.log();
103
120
 
104
121
  // Check PR states and build the merge plan
@@ -121,9 +138,15 @@ OPTIONS
121
138
  ` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}${mergedLabel}`,
122
139
  );
123
140
  }
124
- console.log(
125
- ` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
126
- );
141
+ if (collapse) {
142
+ console.log(
143
+ ` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.dim("(stop — review on GitHub, then re-run merge to finish)")}`,
144
+ );
145
+ } else {
146
+ console.log(
147
+ ` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
148
+ );
149
+ }
127
150
  console.log();
128
151
 
129
152
  if (dryRun) {
@@ -140,14 +163,18 @@ OPTIONS
140
163
  process.exit(1);
141
164
  }
142
165
 
143
- const confirmed = await confirmAction("Squash-merge stack top-down via GitHub?");
166
+ const confirmed = await confirmAction(
167
+ collapse
168
+ ? "Collapse stack top-down via GitHub (stop at base PR)?"
169
+ : "Squash-merge stack top-down via GitHub?",
170
+ );
144
171
  if (!confirmed) {
145
172
  p.cancel("Cancelled");
146
173
  process.exit(0);
147
174
  }
148
175
 
149
176
  // Take snapshot
150
- await takeSnapshot(meta, stackName, "merge");
177
+ await takeSnapshot(meta, stackName, collapse ? "collapse" : "merge");
151
178
 
152
179
  // Merge top-down via GitHub: for each PR from top, squash-merge into parent
153
180
  for (let i = 0; i < reversed.length - 1; i++) {
@@ -205,6 +232,74 @@ OPTIONS
205
232
  }
206
233
  }
207
234
 
235
+ // ─── --collapse: stop here. Base PR holds the cumulative diff; let the
236
+ // user review on GitHub and re-run `gh-stack merge` to finish.
237
+ if (collapse) {
238
+ console.log();
239
+
240
+ // Park the user on the base branch — mirrors how normal merge lands you
241
+ // on `main`. Fetch origin so they can see how far behind their local
242
+ // base branch is (the squashed commits live only on origin/<base> now).
243
+ const checkoutSpinner = p.spinner();
244
+ checkoutSpinner.start(`Checking out base branch ${pc.yellow(baseBranch)}...`);
245
+ let localBehind = false;
246
+ try {
247
+ await $`git fetch origin`.quiet();
248
+ await git.checkout(baseBranch);
249
+
250
+ // Compare local vs origin to flag the user if their local base is stale
251
+ try {
252
+ const localSha = await git.revParse(baseBranch);
253
+ const remoteSha = await git.revParse(`origin/${baseBranch}`);
254
+ localBehind = localSha !== remoteSha;
255
+ } catch {}
256
+
257
+ checkoutSpinner.stop(`On base branch ${pc.yellow(baseBranch)}`);
258
+ } catch {
259
+ checkoutSpinner.stop(pc.dim(`Could not checkout ${baseBranch} — switch manually if needed`));
260
+ }
261
+
262
+ console.log();
263
+ console.log(pc.cyan("━".repeat(40)));
264
+ console.log(pc.green(" Stack collapsed into base PR"));
265
+ console.log(pc.cyan("━".repeat(40)));
266
+ console.log();
267
+
268
+ // Fetch the base PR URL for a clickable summary line
269
+ let baseUrl: string | null = null;
270
+ try {
271
+ const proc = Bun.spawn(["gh", "pr", "view", String(basePr), "--json", "url", "-q", ".url"], {
272
+ stdout: "pipe",
273
+ stderr: "pipe",
274
+ });
275
+ const out = (await new Response(proc.stdout).text()).trim();
276
+ if ((await proc.exited) === 0 && out) baseUrl = out;
277
+ } catch {}
278
+
279
+ console.log(
280
+ ` ${pc.blue("Base PR:")} #${basePr} ${pc.yellow(baseBranch)} → ${pc.green("main")}`,
281
+ );
282
+ if (baseUrl) console.log(` ${pc.dim(baseUrl)}`);
283
+ if (localBehind) {
284
+ console.log();
285
+ console.log(
286
+ ` ${pc.yellow("⚠")} Local ${pc.yellow(baseBranch)} is behind ${pc.dim(`origin/${baseBranch}`)}`,
287
+ );
288
+ console.log(
289
+ ` ${pc.dim("(squashed commits from upper PRs live on origin only — review the diff on GitHub)")}`,
290
+ );
291
+ }
292
+ console.log();
293
+ console.log(` ${pc.dim("Review the cumulative diff on GitHub. When ready to ship:")}`);
294
+ console.log(
295
+ ` ${pc.green("gh-stack merge")} ${pc.dim("# finishes base PR → main + archives")}`,
296
+ );
297
+ console.log();
298
+
299
+ p.outro(pc.green("Collapse complete — stack left intact for review."));
300
+ return;
301
+ }
302
+
208
303
  // Base PR → enable auto-merge into main
209
304
  console.log();
210
305
  console.log(pc.cyan("━".repeat(40)));