gh-stack 0.4.0 → 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.
@@ -131,12 +138,16 @@ sync [--dry-run]
131
138
  children. Snapshots every branch's pre-sync tip so children can be
132
139
  correctly rebased even though their parent's history was rewritten.
133
140
 
134
- merge [--dry-run] [-d|--delete-branch]
141
+ merge [--dry-run] [-d|--delete-branch] [--collapse]
135
142
  Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
136
143
  then enables auto-merge for the base PR into main. All merges
137
144
  happen on GitHub so PRs show as "Merged", Linear tickets close
138
145
  automatically, and GitHub Actions fire normally. Skips already-
139
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.
140
151
 
141
152
  delete [<branch>]
142
153
  Remove a branch from the stack and re-parent its children.
@@ -166,14 +177,18 @@ archive [--restore <name>]
166
177
 
167
178
  ```
168
179
  --yes, -y Skip all confirmations (for agents/CI)
180
+ --plain Plain output — no spinners, colors, or banner boxes
169
181
  --help Show help for a command
170
182
  --version, -V Show version
171
183
  ```
172
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
+
173
187
  ## Environment Variables
174
188
 
175
189
  ```
176
- 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)
177
192
  GH_STACK_NO_COLOR=1 Disable colored output
178
193
  ```
179
194
 
@@ -262,6 +277,11 @@ gh-stack status
262
277
 
263
278
  # When PRs are approved, merge the stack
264
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
265
285
  ```
266
286
 
267
287
  ## Agent/CI Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.4.0",
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)));
@@ -1,6 +1,6 @@
1
1
  // gh-stack restack — Rebase children onto updated parents
2
2
  // Port of reference/gh-stack-sync.sh — the most critical command
3
- import * as p from "@clack/prompts";
3
+ import * as p from "../lib/output.ts";
4
4
  import pc from "picocolors";
5
5
  import * as git from "../lib/git.ts";
6
6
  import {
@@ -281,16 +281,21 @@ async function processChain(
281
281
  //
282
282
  // Strategy:
283
283
  // 1. Look for a recent snapshot where the parent's recorded tip is no
284
- // longer an ancestor of the parent's current tip. That recorded SHA
285
- // is the orphaned old-parent tip — exactly the rebase base we need.
284
+ // longer an ancestor of the parent's current tip AND is still an
285
+ // ancestor of the child. That recorded SHA is the orphaned
286
+ // old-parent tip — exactly the rebase base we need.
286
287
  // 2. Fall back to `merge-base(branch, parent)` if no snapshot tells us
287
288
  // the parent was rewritten (typical case: parent only had commits
288
289
  // appended, not rebased).
289
290
  //
291
+ // The child-ancestry check (issue #5) prevents stale snapshots from
292
+ // older sessions — where the child has already been rebased past the
293
+ // recorded point — from being used as a rebase base.
294
+ //
290
295
  // Re-read metadata fresh because previous iterations of this loop may
291
296
  // have updated snapshots.
292
297
  const freshMeta = (await readMetadata()) ?? meta;
293
- const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent);
298
+ const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent, branch);
294
299
  const oldBaseFromMergeBase = await git.mergeBase(branch, parent);
295
300
  const oldBase = oldBaseFromSnapshot ?? oldBaseFromMergeBase;
296
301
 
@@ -1,12 +1,18 @@
1
1
  // gh-stack submit — Push branches and create/update PRs
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";
6
- import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
7
- import { ensureMetadata } from "../lib/safety.ts";
6
+ import {
7
+ findStackForBranch,
8
+ getOrderedBranches,
9
+ readMetadata,
10
+ writeMetadata,
11
+ } from "../lib/metadata.ts";
8
12
  import { isAutoYes } from "../lib/ui.ts";
13
+ import type { StackMetadata } from "../types.ts";
9
14
  import { getPrNumber, getPrInfo, getPrBody, updatePrBody, reviewEmoji } from "../lib/github.ts";
15
+ import { resolveOrCreateStack } from "../lib/chain.ts";
10
16
  import { buildStackViz } from "./update-prs.ts";
11
17
 
12
18
  const HELP = `
@@ -19,6 +25,11 @@ Pushes all branches from trunk to the current branch, creating PRs
19
25
  for branches that don't have them and updating stack visualization
20
26
  in all PR descriptions. Idempotent — safe to run repeatedly.
21
27
 
28
+ Self-healing: if the current branch isn't tracked in a stack yet,
29
+ submit auto-detects the branch chain from trunk → current, registers
30
+ (or reconciles into) a stack, then pushes + creates PRs for the whole
31
+ chain. You never need to run \`gh-stack init\` first.
32
+
22
33
  OPTIONS
23
34
  -d, --draft Create new PRs as drafts
24
35
  -n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
@@ -110,13 +121,50 @@ export default async function submit(args: string[]): Promise<void> {
110
121
 
111
122
  const noEdit = args.includes("--no-edit") || args.includes("-n") || isAutoYes() || !!titleFlag;
112
123
 
113
- const meta = await ensureMetadata();
124
+ // Read metadata, or start with an empty in-memory store. submit self-heals,
125
+ // so a missing metadata file is fine — we'll build the stack from local
126
+ // branch ancestry and persist it (unless --dry-run).
127
+ const meta: StackMetadata = (await readMetadata()) ?? {
128
+ version: 2,
129
+ current_stack: null,
130
+ stacks: {},
131
+ };
114
132
  const branch = await git.currentBranch();
115
- const stackName = findStackForBranch(meta, branch);
133
+ let stackName = findStackForBranch(meta, branch);
116
134
 
135
+ // ── Self-heal: branch isn't in any stack ──
136
+ // Reconstruct the stack from local branch ancestry so the end state is
137
+ // always stack registered + branches pushed + PRs created — no need to
138
+ // remember to run `gh-stack init` first.
139
+ let heal: { trunk: string; chain: string[]; created: boolean; added: string[] } | null = null;
117
140
  if (!stackName) {
118
- p.cancel(`Branch ${pc.blue(branch)} not found in any stack`);
119
- process.exit(1);
141
+ const trunk = await git.trunkBranch();
142
+
143
+ if (branch === trunk || branch === "main" || branch === "master") {
144
+ p.cancel(
145
+ `You're on ${pc.blue(branch)} — nothing to submit.\n\n Checkout a feature branch first.`,
146
+ );
147
+ process.exit(1);
148
+ }
149
+
150
+ let resolution;
151
+ try {
152
+ resolution = await resolveOrCreateStack(meta, branch, trunk);
153
+ } catch (err) {
154
+ p.cancel((err as Error).message);
155
+ process.exit(1);
156
+ }
157
+
158
+ stackName = resolution.stackName;
159
+ heal = {
160
+ trunk,
161
+ chain: resolution.chain,
162
+ created: resolution.created,
163
+ added: resolution.addedBranches,
164
+ };
165
+
166
+ // Persist the reconstructed/updated stack before pushing (skip on dry-run)
167
+ if (!dryRun) await writeMetadata(meta);
120
168
  }
121
169
 
122
170
  const stack = meta.stacks[stackName]!;
@@ -127,6 +175,34 @@ export default async function submit(args: string[]): Promise<void> {
127
175
  const scope = currentIndex >= 0 ? ordered.slice(0, currentIndex + 1) : ordered;
128
176
 
129
177
  p.intro(pc.cyan("Submit Stack"));
178
+
179
+ // Report self-heal results (stack creation / reconciliation + detected chain)
180
+ if (heal) {
181
+ if (heal.created) {
182
+ p.log.success(
183
+ `No stack found — created ${pc.yellow(stackName)} from ${heal.chain.length} local branch(es)`,
184
+ );
185
+ } else if (heal.added.length > 0) {
186
+ p.log.success(
187
+ `Reconciled ${heal.added.length} untracked branch(es) into ${pc.yellow(stackName)}`,
188
+ );
189
+ }
190
+
191
+ console.log();
192
+ console.log(` ${pc.dim(heal.trunk)}`);
193
+ for (let i = 0; i < heal.chain.length; i++) {
194
+ const b = heal.chain[i]!;
195
+ const isLast = i === heal.chain.length - 1;
196
+ const tree = isLast ? " ┗━" : " ┣━";
197
+ const isNew = heal.added.includes(b);
198
+ const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
199
+ const tag = isNew ? pc.green(" +") : "";
200
+ console.log(`${tree} ${label}${tag}`);
201
+ if (!isLast) console.log(" ┃");
202
+ }
203
+ console.log();
204
+ }
205
+
130
206
  p.log.info(`Stack: ${pc.yellow(stackName)}`);
131
207
  p.log.info(
132
208
  `Scope: ${scope.length} of ${ordered.length} branch(es) (downstack from ${pc.blue(branch)})`,
@@ -1,5 +1,5 @@
1
1
  // gh-stack sync — Fetch main, rebase base branch onto main, then restack all
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, saveRestackState } from "../lib/metadata.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack top — Jump to the tip 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 {
@@ -1,5 +1,5 @@
1
1
  // gh-stack undo — Restore from last snapshot
2
- import * as p from "@clack/prompts";
2
+ import * as p from "../lib/output.ts";
3
3
  import * as git from "../lib/git.ts";
4
4
  import pc from "picocolors";
5
5
  import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
@@ -1,5 +1,5 @@
1
1
  // gh-stack up — Move to child branch (upstack)
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, getChildren, writeMetadata } from "../lib/metadata.ts";
@@ -1,6 +1,6 @@
1
1
  // gh-stack update-prs — Update PR descriptions with stack visualization
2
2
  // Port of reference/gh-stack-update-pr.sh
3
- import * as p from "@clack/prompts";
3
+ import * as p from "../lib/output.ts";
4
4
  import pc from "picocolors";
5
5
  import * as git from "../lib/git.ts";
6
6
  import { findStackForBranch, getOrderedBranches } from "../lib/metadata.ts";
package/src/index.ts CHANGED
@@ -1,14 +1,23 @@
1
1
  #!/usr/bin/env bun
2
2
  // gh-stack — Unified stacked PR manager for squash-merge workflows
3
3
 
4
- // Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it
5
- if (process.env.GH_STACK_NO_COLOR) {
4
+ // Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it.
5
+ // Also pre-detect plain mode (--plain flag, GH_STACK_YES=1, or GH_STACK_PLAIN=1)
6
+ // and set NO_COLOR *before* any import below — picocolors snapshots env at
7
+ // load time, so this must run before the import block.
8
+ const _earlyArgs = process.argv.slice(2);
9
+ const _earlyPlain =
10
+ _earlyArgs.includes("--plain") ||
11
+ process.env.GH_STACK_YES === "1" ||
12
+ process.env.GH_STACK_PLAIN === "1";
13
+ if (process.env.GH_STACK_NO_COLOR || _earlyPlain) {
6
14
  process.env.NO_COLOR = "1";
7
15
  }
8
16
 
9
17
  import { ensureGitRepo } from "./lib/safety.ts";
10
18
  import { parseCliArgs } from "./lib/cli.ts";
11
19
  import { setAutoYes } from "./lib/ui.ts";
20
+ import { setPlain } from "./lib/output.ts";
12
21
 
13
22
  const { version: VERSION } = await import("../package.json");
14
23
 
@@ -32,6 +41,11 @@ if (parsed.autoYes) {
32
41
  setAutoYes(true);
33
42
  }
34
43
 
44
+ // Global --plain (or GH_STACK_YES=1): drop intro/outro/spinner/color chrome.
45
+ if (parsed.plain) {
46
+ setPlain(true);
47
+ }
48
+
35
49
  // Ensure we're in a git repo for all commands
36
50
  await ensureGitRepo();
37
51
 
@@ -160,11 +174,13 @@ ${bold("INFO & MAINTENANCE")}
160
174
 
161
175
  ${bold("GLOBAL OPTIONS")}
162
176
  --yes, -y Skip confirmations ${dim("(for agents/CI)")}
177
+ --plain Plain output: no spinners, colors, or box drawing
163
178
  --help Show help
164
179
  --version, -V Show version
165
180
 
166
181
  ${bold("ENVIRONMENT")}
167
- GH_STACK_YES=1 Same as --yes
182
+ GH_STACK_YES=1 Same as --yes (also enables --plain)
183
+ GH_STACK_PLAIN=1 Same as --plain
168
184
  GH_STACK_NO_COLOR Disable colored output
169
185
  `);
170
186
  }
@@ -0,0 +1,140 @@
1
+ // Branch-chain detection and stack reconciliation.
2
+ //
3
+ // Shared by `init` (interactive stack creation) and `submit` (self-healing:
4
+ // auto-create/register a stack from a bare set of local branches so the
5
+ // end state is always stack registered + branches pushed + PRs created).
6
+ import * as git from "./git.ts";
7
+ import { findStackForBranch } from "./metadata.ts";
8
+ import type { StackMetadata, Branch } from "../types.ts";
9
+
10
+ /**
11
+ * Detect a chain of branches from trunk to the current branch.
12
+ *
13
+ * Walks down from the current branch by finding local branches whose tips
14
+ * are strict ancestors of the current branch. Sorts by commit distance
15
+ * (closest = direct parent) to reconstruct the chain. Branches already
16
+ * merged into trunk are skipped (stale/old branches).
17
+ *
18
+ * Returns branches in bottom-up order: [closest-to-trunk, ..., current].
19
+ * For a branch based directly off trunk, returns just [current].
20
+ */
21
+ export async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
22
+ const allBranches = await git.allLocalBranches();
23
+
24
+ // Branches to exclude: current, trunk, and always main/master
25
+ const excluded = new Set([currentBranch, trunk, "main", "master"]);
26
+
27
+ // Find branches that are strict ancestors of current (and not trunk)
28
+ const ancestors: { name: string; distance: number }[] = [];
29
+
30
+ for (const branch of allBranches) {
31
+ if (excluded.has(branch)) continue;
32
+
33
+ // Is this branch's tip an ancestor of current?
34
+ if (await git.isAncestor(branch, currentBranch)) {
35
+ // Skip branches already merged into trunk (stale/old branches)
36
+ if (await git.isAncestor(branch, trunk)) continue;
37
+
38
+ // How many commits between this branch and current?
39
+ const distance = await git.commitCount(branch, currentBranch);
40
+ if (distance > 0) {
41
+ ancestors.push({ name: branch, distance });
42
+ }
43
+ }
44
+ }
45
+
46
+ // Sort by distance descending (furthest from current = closest to trunk)
47
+ ancestors.sort((a, b) => b.distance - a.distance);
48
+
49
+ // Build chain: ancestors (sorted trunk→current) + current branch
50
+ const chain = ancestors.map((a) => a.name);
51
+ chain.push(currentBranch);
52
+
53
+ return chain;
54
+ }
55
+
56
+ export interface StackResolution {
57
+ stackName: string;
58
+ chain: string[]; // trunk→current order
59
+ created: boolean; // true if a brand-new stack was created
60
+ addedBranches: string[]; // branches newly registered into the stack
61
+ }
62
+
63
+ /**
64
+ * Ensure a stack exists for `branch`, reconstructing it from local branch
65
+ * ancestry if needed. Mutates `meta` in place but does NOT persist — the
66
+ * caller decides when to write (so `--dry-run` can preview without side
67
+ * effects).
68
+ *
69
+ * Behavior:
70
+ * - Detects the chain trunk→current.
71
+ * - If any branch in the chain already belongs to a stack, reconciles
72
+ * into that existing stack (adds only the missing branches).
73
+ * - Otherwise creates a new stack named after the current branch
74
+ * (auto-suffixed if that name is taken).
75
+ * - Registers missing branches with correct parent links (chain[0]'s
76
+ * parent is trunk; each subsequent branch's parent is the prior one).
77
+ *
78
+ * Throws if the chain spans more than one existing stack (ambiguous).
79
+ */
80
+ export async function resolveOrCreateStack(
81
+ meta: StackMetadata,
82
+ branch: string,
83
+ trunk: string,
84
+ ): Promise<StackResolution> {
85
+ const chain = await detectBranchChain(branch, trunk);
86
+
87
+ // Which existing stacks (if any) already own branches in this chain?
88
+ const owningStacks = new Set<string>();
89
+ for (const b of chain) {
90
+ const owner = findStackForBranch(meta, b);
91
+ if (owner) owningStacks.add(owner);
92
+ }
93
+
94
+ if (owningStacks.size > 1) {
95
+ throw new Error(
96
+ `Branches in this chain span multiple stacks (${[...owningStacks].join(
97
+ ", ",
98
+ )}). Resolve manually with gh-stack init / delete.`,
99
+ );
100
+ }
101
+
102
+ let stackName: string;
103
+ let created = false;
104
+
105
+ if (owningStacks.size === 1) {
106
+ // Reconcile into the existing stack
107
+ stackName = [...owningStacks][0]!;
108
+ } else {
109
+ // Create a new stack named after the current branch (auto-suffix on clash)
110
+ stackName = branch;
111
+ let n = 2;
112
+ while (meta.stacks[stackName]) {
113
+ stackName = `${branch}-${n++}`;
114
+ }
115
+ meta.stacks[stackName] = {
116
+ description: "",
117
+ last_branch: null,
118
+ branches: {},
119
+ };
120
+ meta.current_stack = stackName;
121
+ created = true;
122
+ }
123
+
124
+ const stack = meta.stacks[stackName]!;
125
+ const addedBranches: string[] = [];
126
+
127
+ // Register missing branches with correct parent links.
128
+ for (let i = 0; i < chain.length; i++) {
129
+ const name = chain[i]!;
130
+ if (stack.branches[name]) continue; // already tracked — leave as-is
131
+
132
+ const parent = i === 0 ? trunk : chain[i - 1]!;
133
+ const branchData: Branch = { parent };
134
+ stack.branches[name] = branchData;
135
+ stack.last_branch = name;
136
+ addedBranches.push(name);
137
+ }
138
+
139
+ return { stackName, chain, created, addedBranches };
140
+ }
package/src/lib/cli.ts CHANGED
@@ -2,11 +2,12 @@ export interface ParsedCliArgs {
2
2
  command: string;
3
3
  commandArgs: string[];
4
4
  autoYes: boolean;
5
+ plain: boolean;
5
6
  showVersion: boolean;
6
7
  showGlobalHelp: boolean;
7
8
  }
8
9
 
9
- const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--help", "--version", "-V"]);
10
+ const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--plain", "--help", "--version", "-V"]);
10
11
 
11
12
  export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedCliArgs {
12
13
  let commandIndex = 0;
@@ -20,12 +21,18 @@ export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedC
20
21
  const command = rawArgs[commandIndex] || "log";
21
22
  const commandArgs = rawArgs
22
23
  .slice(commandIndex + 1)
23
- .filter((arg) => arg !== "--yes" && arg !== "-y");
24
+ .filter((arg) => arg !== "--yes" && arg !== "-y" && arg !== "--plain");
25
+
26
+ const autoYes = rawArgs.includes("--yes") || rawArgs.includes("-y") || env.GH_STACK_YES === "1";
27
+ // Plain mode auto-enables under GH_STACK_YES=1 (agents skip the chrome anyway)
28
+ // or explicit --plain. Both compose with each other.
29
+ const plain = autoYes || rawArgs.includes("--plain") || env.GH_STACK_PLAIN === "1";
24
30
 
25
31
  return {
26
32
  command,
27
33
  commandArgs,
28
- autoYes: rawArgs.includes("--yes") || rawArgs.includes("-y") || env.GH_STACK_YES === "1",
34
+ autoYes,
35
+ plain,
29
36
  showVersion: leadingFlags.has("--version") || leadingFlags.has("-V"),
30
37
  showGlobalHelp: !rawArgs[commandIndex] && leadingFlags.has("--help"),
31
38
  };
package/src/lib/git.ts CHANGED
@@ -254,6 +254,16 @@ export async function allLocalBranches(): Promise<string[]> {
254
254
  return result.trim().split("\n").filter(Boolean);
255
255
  }
256
256
 
257
+ /**
258
+ * Determine the trunk branch name. Prefers "main", falls back to "master"
259
+ * if only that exists locally. Defaults to "main" when neither is present.
260
+ */
261
+ export async function trunkBranch(): Promise<string> {
262
+ if (await localBranchExists("main")) return "main";
263
+ if (await localBranchExists("master")) return "master";
264
+ return "main";
265
+ }
266
+
257
267
  /**
258
268
  * Build a deterministic temp tag name for a branch.
259
269
  * Includes a readable slug plus a stable hash to avoid collisions.
@@ -0,0 +1,90 @@
1
+ // Plain-mode-aware wrapper over @clack/prompts.
2
+ //
3
+ // In plain mode (--plain flag or GH_STACK_YES=1), we strip the decorative
4
+ // chrome that makes gh-stack output painful for agents/log consumers:
5
+ // - no intro/outro boxes
6
+ // - no spinners (start/stop lines collapse to plain prints)
7
+ // - no ANSI colors (picocolors auto-disables via NO_COLOR; we also strip
8
+ // any residual escapes defensively)
9
+ //
10
+ // Interactive prompts (text, confirm, select) are re-exported unchanged —
11
+ // plain mode is about output, not interactivity. Agents already pair --plain
12
+ // with --yes/GH_STACK_YES so the interactive paths aren't reached.
13
+ import * as clack from "@clack/prompts";
14
+
15
+ let _plain = false;
16
+
17
+ export function setPlain(enabled: boolean): void {
18
+ _plain = enabled;
19
+ }
20
+
21
+ export function isPlain(): boolean {
22
+ return _plain;
23
+ }
24
+
25
+ const ANSI_RE = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
26
+ function strip(s: string | undefined): string {
27
+ return (s ?? "").replace(ANSI_RE, "");
28
+ }
29
+
30
+ // ── Interactive prompts (unchanged) ──
31
+ export const isCancel = clack.isCancel;
32
+ export const text = clack.text;
33
+ export const confirm = clack.confirm;
34
+ export const select = clack.select;
35
+
36
+ // ── Banner / error ──
37
+ export function intro(message?: string): void {
38
+ if (_plain) {
39
+ if (message) console.log(strip(message));
40
+ } else {
41
+ clack.intro(message);
42
+ }
43
+ }
44
+
45
+ export function outro(message?: string): void {
46
+ if (_plain) {
47
+ if (message) console.log(strip(message));
48
+ } else {
49
+ clack.outro(message);
50
+ }
51
+ }
52
+
53
+ export function cancel(message?: string): void {
54
+ if (_plain) {
55
+ if (message) console.error(strip(message));
56
+ } else {
57
+ clack.cancel(message);
58
+ }
59
+ }
60
+
61
+ // ── log.* ──
62
+ export const log = {
63
+ info: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.info(msg)),
64
+ success: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.success(msg)),
65
+ warn: (msg: string) => (_plain ? console.warn(strip(msg)) : clack.log.warn(msg)),
66
+ error: (msg: string) => (_plain ? console.error(strip(msg)) : clack.log.error(msg)),
67
+ step: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.step(msg)),
68
+ };
69
+
70
+ // ── Spinner ──
71
+ export interface Spinner {
72
+ start: (msg?: string) => void;
73
+ stop: (msg?: string, code?: number) => void;
74
+ message: (msg?: string) => void;
75
+ }
76
+
77
+ export function spinner(): Spinner {
78
+ if (!_plain) return clack.spinner() as Spinner;
79
+ return {
80
+ start: (msg) => {
81
+ if (msg) console.log(strip(msg));
82
+ },
83
+ stop: (msg) => {
84
+ if (msg) console.log(strip(msg));
85
+ },
86
+ message: (msg) => {
87
+ if (msg) console.log(strip(msg));
88
+ },
89
+ };
90
+ }
package/src/lib/safety.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import * as git from "./git.ts";
3
3
  import { metadataExists, readMetadata, findStackForBranch } from "./metadata.ts";
4
4
  import type { StackMetadata, Stack } from "../types.ts";
5
- import * as p from "@clack/prompts";
5
+ import * as p from "./output.ts";
6
6
  import pc from "picocolors";
7
7
 
8
8
  /**
@@ -4,47 +4,73 @@ import * as git from "./git.ts";
4
4
  import { writeMetadata, getOrderedBranches } from "./metadata.ts";
5
5
 
6
6
  /**
7
- * Find the pre-rewrite SHA for a branch by walking snapshots newest-first.
7
+ * Find the pre-rewrite SHA for a parent branch by walking snapshots newest-first.
8
8
  *
9
9
  * Returns the SHA from the most recent snapshot whose recorded tip for
10
- * `branchName` is NOT an ancestor of the branch's current tip — meaning
11
- * that snapshot was taken before the branch's history got rewritten
10
+ * `parentBranch` is NOT an ancestor of the parent's current tip — meaning
11
+ * that snapshot was taken before the parent's history got rewritten
12
12
  * (typically by `sync` rebasing it onto a new main).
13
13
  *
14
14
  * That pre-rewrite SHA is the correct rebase base when restacking a child
15
- * onto this branch — `git rebase --onto <new-tip> <pre-rewrite-tip> <child>`
15
+ * onto this parent — `git rebase --onto <new-tip> <pre-rewrite-tip> <child>`
16
16
  * replays only the child's unique commits, never the parent's old history.
17
17
  *
18
- * Returns null if no snapshots exist or every snapshot's recorded tip is
19
- * still an ancestor of the current tip (i.e., the branch has only had
20
- * commits appended, never been rebased — `merge-base` is still correct).
18
+ * **Child validation (issue #5)**: When `childBranch` is provided, any candidate
19
+ * SHA must also still be an ancestor of the child. If the child has already
20
+ * been rebased past this point (e.g., a previous restack already moved it),
21
+ * the recorded SHA is stale for this child and would cause `rebase --onto`
22
+ * to replay a wildly wrong range. We skip those candidates and keep walking.
23
+ *
24
+ * Returns null if no snapshots exist, no recorded tip qualifies, or every
25
+ * snapshot's recorded tip is still an ancestor of the parent's current tip
26
+ * (i.e., the parent has only had commits appended, never been rebased —
27
+ * `merge-base` is still correct).
21
28
  */
22
29
  export async function findPreRewriteSha(
23
30
  meta: StackMetadata,
24
- branchName: string,
31
+ parentBranch: string,
32
+ childBranch?: string,
25
33
  ): Promise<string | null> {
26
34
  if (!meta.snapshots || meta.snapshots.length === 0) return null;
27
35
 
28
- let currentTip: string;
36
+ let currentParentTip: string;
29
37
  try {
30
- currentTip = await git.revParse(branchName);
38
+ currentParentTip = await git.revParse(parentBranch);
31
39
  } catch {
32
40
  return null;
33
41
  }
34
42
 
43
+ let currentChildTip: string | null = null;
44
+ if (childBranch) {
45
+ try {
46
+ currentChildTip = await git.revParse(childBranch);
47
+ } catch {
48
+ // Child doesn't exist locally — fall back to parent-only validation
49
+ currentChildTip = null;
50
+ }
51
+ }
52
+
35
53
  // Walk newest → oldest
36
54
  for (let i = meta.snapshots.length - 1; i >= 0; i--) {
37
- const recorded = meta.snapshots[i]!.branches[branchName];
55
+ const recorded = meta.snapshots[i]!.branches[parentBranch];
38
56
  if (!recorded) continue;
39
- if (recorded === currentTip) continue;
40
-
41
- // If the recorded SHA is no longer an ancestor of the current tip,
42
- // the branch's history was rewritten between then and now.
43
- // That recorded SHA is the orphaned tip we need.
44
- const stillAncestor = await git.isAncestor(recorded, currentTip);
45
- if (!stillAncestor) {
46
- return recorded;
57
+ if (recorded === currentParentTip) continue;
58
+
59
+ // The recorded SHA must no longer be an ancestor of the parent's current
60
+ // tip — that's how we detect the parent's history was rewritten.
61
+ const stillAncestorOfParent = await git.isAncestor(recorded, currentParentTip);
62
+ if (stillAncestorOfParent) continue;
63
+
64
+ // Issue #5 guard: if we know the child, the recorded SHA must still be
65
+ // an ancestor of the child. Otherwise the child has already been rebased
66
+ // past this point (or never branched off it) and using it as a base
67
+ // would replay unrelated history.
68
+ if (currentChildTip) {
69
+ const stillAncestorOfChild = await git.isAncestor(recorded, currentChildTip);
70
+ if (!stillAncestorOfChild) continue;
47
71
  }
72
+
73
+ return recorded;
48
74
  }
49
75
 
50
76
  return null;
package/src/lib/ui.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // TUI helpers: tree rendering, formatting, prompts
2
- import * as p from "@clack/prompts";
2
+ import * as p from "./output.ts";
3
3
  import pc from "picocolors";
4
4
  import type { Stack, StackMetadata } from "../types.ts";
5
5
  import { getOrderedBranches } from "./metadata.ts";