gh-stack 0.18.0 → 0.19.1

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/src/index.ts CHANGED
@@ -1,24 +1,28 @@
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
- // 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") ||
4
+ // Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it,
5
+ // and turn colors off for plain mode (--plain, GH_STACK_YES=1, GH_STACK_PLAIN=1)
6
+ // and --json (a machine reads the stderr logs, and error messages land inside
7
+ // the JSON itself).
8
+ //
9
+ // picocolors reads NO_COLOR once, when it first loads. So this file has NO
10
+ // static imports: those are evaluated before this body runs, and Bun evaluates
11
+ // a CommonJS dependency like picocolors even before an earlier ESM side-effect
12
+ // import. Everything that can pull in picocolors is imported dynamically below.
13
+ const earlyArgs = new Set(process.argv.slice(2));
14
+ const colorOff =
15
+ !!process.env.GH_STACK_NO_COLOR ||
16
+ earlyArgs.has("--plain") ||
17
+ earlyArgs.has("--json") ||
11
18
  process.env.GH_STACK_YES === "1" ||
12
19
  process.env.GH_STACK_PLAIN === "1";
13
- if (process.env.GH_STACK_NO_COLOR || _earlyPlain) {
14
- process.env.NO_COLOR = "1";
15
- }
16
-
17
- import { ensureGitRepo } from "./lib/safety.ts";
18
- import { parseCliArgs } from "./lib/cli.ts";
19
- import { setAutoYes } from "./lib/ui.ts";
20
- import { setPlain } from "./lib/output.ts";
20
+ if (colorOff) process.env.NO_COLOR = "1";
21
21
 
22
+ const { ensureGitRepo } = await import("./lib/safety.ts");
23
+ const { parseCliArgs } = await import("./lib/cli.ts");
24
+ const { setAutoYes } = await import("./lib/ui.ts");
25
+ const { setPlain } = await import("./lib/output.ts");
22
26
  const { version: VERSION } = await import("../package.json");
23
27
 
24
28
  const args = process.argv.slice(2);
@@ -48,122 +52,140 @@ if (parsed.plain) {
48
52
 
49
53
  // Ensure we're in a git repo for all commands — except `learn`, which just
50
54
  // emits/installs the agent skill and is useful outside any repo (e.g. an agent
51
- // onboarding before cloning, or `learn --skill --global`).
52
- if (command !== "learn") {
55
+ // onboarding before cloning, or `learn --skill --global`), and `config`, which
56
+ // shows the user and env layers anywhere (and the repo layer inside a repo).
57
+ if (command !== "learn" && command !== "config") {
53
58
  await ensureGitRepo();
54
59
  }
55
60
 
56
61
  // Route to subcommand — strip global flags from command args
57
62
  const commandArgs = parsed.commandArgs;
58
63
 
59
- switch (command) {
60
- case "log":
61
- await (await import("./commands/log.ts")).default(commandArgs);
62
- break;
63
-
64
- case "init":
65
- await (await import("./commands/init.ts")).default(commandArgs);
66
- break;
67
-
68
- case "create":
69
- await (await import("./commands/create.ts")).default(commandArgs);
70
- break;
71
-
72
- case "add":
73
- await (await import("./commands/add.ts")).default(commandArgs);
74
- break;
75
-
76
- case "delete":
77
- await (await import("./commands/delete.ts")).default(commandArgs);
78
- break;
79
-
80
- case "split":
81
- await (await import("./commands/split.ts")).default(commandArgs);
82
- break;
83
-
84
- case "rename":
85
- await (await import("./commands/rename.ts")).default(commandArgs);
86
- break;
87
-
88
- case "checkout":
89
- case "co":
90
- await (await import("./commands/checkout.ts")).default(commandArgs);
91
- break;
92
-
93
- case "ls":
94
- await (await import("./commands/list.ts")).default(commandArgs);
95
- break;
96
-
97
- case "restack":
98
- case "rebase":
99
- await (await import("./commands/restack.ts")).default(commandArgs);
100
- break;
101
-
102
- case "sync":
103
- await (await import("./commands/sync.ts")).default(commandArgs);
104
- break;
105
-
106
- case "submit":
107
- await (await import("./commands/submit.ts")).default(commandArgs);
108
- break;
109
-
110
- case "status":
111
- await (await import("./commands/status.ts")).default(commandArgs);
112
- break;
113
-
114
- case "merge":
115
- await (await import("./commands/merge.ts")).default(commandArgs);
116
- break;
117
-
118
- case "undo":
119
- await (await import("./commands/undo.ts")).default(commandArgs);
120
- break;
121
-
122
- case "doctor":
123
- await (await import("./commands/doctor.ts")).default(commandArgs);
124
- break;
125
-
126
- case "update-prs":
127
- await (await import("./commands/update-prs.ts")).default(commandArgs);
128
- break;
129
-
130
- case "stacks":
131
- await (await import("./commands/stacks.ts")).default(commandArgs);
132
- break;
133
-
134
- case "archive":
135
- await (await import("./commands/archive.ts")).default(commandArgs);
136
- break;
137
-
138
- case "up":
139
- await (await import("./commands/up.ts")).default(commandArgs);
140
- break;
141
-
142
- case "down":
143
- await (await import("./commands/down.ts")).default(commandArgs);
144
- break;
145
-
146
- case "top":
147
- await (await import("./commands/top.ts")).default(commandArgs);
148
- break;
149
-
150
- case "bottom":
151
- await (await import("./commands/bottom.ts")).default(commandArgs);
152
- break;
153
-
154
- case "learn":
155
- await (await import("./commands/learn.ts")).default(commandArgs);
156
- break;
157
-
158
- case "--help":
159
- case "help":
160
- printHelp();
161
- break;
162
-
163
- default:
164
- console.error(`Unknown command: ${command}\n`);
165
- printHelp();
166
- process.exit(1);
64
+ // A config problem (malformed TOML, a non-boolean env value) is reported as
65
+ // one clean line, whichever command hit it.
66
+ const { ConfigError } = await import("./lib/config.ts");
67
+ try {
68
+ switch (command) {
69
+ case "log":
70
+ await (await import("./commands/log.ts")).default(commandArgs);
71
+ break;
72
+
73
+ case "init":
74
+ await (await import("./commands/init.ts")).default(commandArgs);
75
+ break;
76
+
77
+ case "create":
78
+ await (await import("./commands/create.ts")).default(commandArgs);
79
+ break;
80
+
81
+ case "add":
82
+ await (await import("./commands/add.ts")).default(commandArgs);
83
+ break;
84
+
85
+ case "join":
86
+ await (await import("./commands/join.ts")).default(commandArgs);
87
+ break;
88
+
89
+ case "delete":
90
+ await (await import("./commands/delete.ts")).default(commandArgs);
91
+ break;
92
+
93
+ case "split":
94
+ await (await import("./commands/split.ts")).default(commandArgs);
95
+ break;
96
+
97
+ case "rename":
98
+ await (await import("./commands/rename.ts")).default(commandArgs);
99
+ break;
100
+
101
+ case "checkout":
102
+ case "co":
103
+ await (await import("./commands/checkout.ts")).default(commandArgs);
104
+ break;
105
+
106
+ case "ls":
107
+ await (await import("./commands/list.ts")).default(commandArgs);
108
+ break;
109
+
110
+ case "restack":
111
+ case "rebase":
112
+ await (await import("./commands/restack.ts")).default(commandArgs);
113
+ break;
114
+
115
+ case "sync":
116
+ await (await import("./commands/sync.ts")).default(commandArgs);
117
+ break;
118
+
119
+ case "submit":
120
+ await (await import("./commands/submit.ts")).default(commandArgs);
121
+ break;
122
+
123
+ case "status":
124
+ await (await import("./commands/status.ts")).default(commandArgs);
125
+ break;
126
+
127
+ case "merge":
128
+ await (await import("./commands/merge.ts")).default(commandArgs);
129
+ break;
130
+
131
+ case "undo":
132
+ await (await import("./commands/undo.ts")).default(commandArgs);
133
+ break;
134
+
135
+ case "doctor":
136
+ await (await import("./commands/doctor.ts")).default(commandArgs);
137
+ break;
138
+
139
+ case "update-prs":
140
+ await (await import("./commands/update-prs.ts")).default(commandArgs);
141
+ break;
142
+
143
+ case "stacks":
144
+ await (await import("./commands/stacks.ts")).default(commandArgs);
145
+ break;
146
+
147
+ case "archive":
148
+ await (await import("./commands/archive.ts")).default(commandArgs);
149
+ break;
150
+
151
+ case "up":
152
+ await (await import("./commands/up.ts")).default(commandArgs);
153
+ break;
154
+
155
+ case "down":
156
+ await (await import("./commands/down.ts")).default(commandArgs);
157
+ break;
158
+
159
+ case "top":
160
+ await (await import("./commands/top.ts")).default(commandArgs);
161
+ break;
162
+
163
+ case "bottom":
164
+ await (await import("./commands/bottom.ts")).default(commandArgs);
165
+ break;
166
+
167
+ case "learn":
168
+ await (await import("./commands/learn.ts")).default(commandArgs);
169
+ break;
170
+
171
+ case "config":
172
+ await (await import("./commands/config.ts")).default(commandArgs);
173
+ break;
174
+
175
+ case "--help":
176
+ case "help":
177
+ printHelp();
178
+ break;
179
+
180
+ default:
181
+ console.error(`Unknown command: ${command}\n`);
182
+ printHelp();
183
+ process.exit(1);
184
+ }
185
+ } catch (err) {
186
+ if (!(err instanceof ConfigError)) throw err;
187
+ console.error(`gh-stack: ${err.message}`);
188
+ process.exit(1);
167
189
  }
168
190
 
169
191
  function printHelp() {
@@ -200,6 +222,7 @@ ${bold("STACK MANAGEMENT")}
200
222
  ${green("sync")} Sync base with main + restack all
201
223
  ${green("merge")} Squash-merge via GitHub (top-down, or --approved bottom-up)
202
224
  ${green("split")} Cut a stack into two at a branch
225
+ ${green("join")} Splice an existing branch/PR into a stack
203
226
  ${green("rename")} Rename a tracked branch (keeps metadata in sync)
204
227
  ${green("delete")} Remove a branch from the stack
205
228
 
@@ -209,6 +232,7 @@ ${bold("INFO & MAINTENANCE")}
209
232
  ${green("archive")} Manage archived stacks
210
233
  ${green("update-prs")} Refresh stack visualization in PR descriptions
211
234
  ${green("doctor")} Migrate & repair stack metadata
235
+ ${green("config")} Show flag defaults and where each comes from
212
236
  ${green("learn")} Print the agent skill (--skill installs it)
213
237
 
214
238
  ${bold("GLOBAL OPTIONS")}
@@ -221,6 +245,12 @@ ${bold("ENVIRONMENT")}
221
245
  GH_STACK_YES=1 Same as --yes (also enables --plain)
222
246
  GH_STACK_PLAIN=1 Same as --plain
223
247
  GH_STACK_NO_COLOR Disable colored output
248
+ GH_STACK_DRAFT=1 submit creates new PRs as drafts (0 = ready)
249
+ GH_STACK_NO_PUSH=1 restack/sync/merge --base never push (0 = push)
250
+
251
+ ${bold("CONFIG")}
252
+ .gh-stack.toml (repo root) and ~/.config/gh-stack/config.toml set flag
253
+ defaults, e.g. [submit] draft = true. Run ${green("gh-stack config")} to see them.
224
254
  `);
225
255
  }
226
256
 
@@ -20,7 +20,7 @@ import * as p from "./output.ts";
20
20
  import pc from "picocolors";
21
21
  import * as git from "./git.ts";
22
22
  import type { StackMetadata } from "../types.ts";
23
- import { removeBranchFromStack } from "./metadata.ts";
23
+ import { removeBranchFromStack, writeMetadata } from "./metadata.ts";
24
24
  import { resolveRestackBoundary } from "./snapshot.ts";
25
25
  import { setPrBase } from "./github.ts";
26
26
 
@@ -120,6 +120,17 @@ export async function resolveForkPoint(
120
120
  mergedBranch: string,
121
121
  child: string,
122
122
  ): Promise<string | null> {
123
+ // `gh pr merge --delete-branch` deletes the merged branch locally too, which
124
+ // leaves the shared resolver no ref to read snapshots against. Its pre-merge
125
+ // tip is in the snapshot the caller took: use the newest recorded tip the
126
+ // child still builds on.
127
+ if (!(await git.localBranchExists(mergedBranch))) {
128
+ for (const snap of (meta.snapshots ?? []).toReversed()) {
129
+ const tip = snap.branches[mergedBranch];
130
+ if (tip && (await git.isAncestor(tip, child))) return tip;
131
+ }
132
+ return null;
133
+ }
123
134
  const boundary = await resolveRestackBoundary(meta, mergedBranch, child);
124
135
  return boundary?.sha ?? null;
125
136
  }
@@ -137,6 +148,9 @@ export type AdvanceResult =
137
148
  *
138
149
  * The caller MUST have taken a snapshot first — on a rebase conflict this leaves
139
150
  * the in-progress `git rebase` for the user to resolve and returns `conflict`.
151
+ * Metadata still names the merged branch as the child's parent then, so the
152
+ * fix is to re-run the caller: this skips the finished rebase and does the rest.
153
+ * (`restack` would rebase the child back onto the merged branch.)
140
154
  */
141
155
  export async function advanceNewBottom(opts: {
142
156
  meta: StackMetadata;
@@ -148,34 +162,61 @@ export async function advanceNewBottom(opts: {
148
162
  recordedBase: string;
149
163
  /** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
150
164
  push?: boolean;
165
+ /**
166
+ * The child's origin tip as the remote guard checked and cleared it: the
167
+ * push leases on exactly that sha (see git.forcePushWithLease).
168
+ */
169
+ expect?: string;
151
170
  }): Promise<AdvanceResult> {
152
171
  const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
153
172
  const push = opts.push ?? true;
173
+ let retargeted = false;
174
+
175
+ // Already on the target: an earlier run stopped on a conflict the user then
176
+ // resolved, or the branch was re-rooted by hand (#51). Its fork point is
177
+ // behind it now, so skip the rebase and finish the rest (push, PR base,
178
+ // metadata). Before a re-root the child can't contain the target, which
179
+ // holds the merged PR's new squash commit.
180
+ if (await git.isAncestor(targetRef, child)) {
181
+ p.log.info(
182
+ `${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
183
+ );
184
+ } else {
185
+ const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
186
+ if (!forkPoint) return { ok: false, reason: "no-fork-point" };
187
+
188
+ // Guard: the fork-point must be an ancestor of the child, else `--onto` would
189
+ // replay an unrelated range. Bail loudly rather than mangle history.
190
+ if (!(await git.isAncestor(forkPoint, child))) {
191
+ return { ok: false, reason: "bad-fork-point", forkPoint };
192
+ }
154
193
 
155
- const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
156
- if (!forkPoint) return { ok: false, reason: "no-fork-point" };
157
-
158
- // Guard: the fork-point must be an ancestor of the child, else `--onto` would
159
- // replay an unrelated range. Bail loudly rather than mangle history.
160
- if (!(await git.isAncestor(forkPoint, child))) {
161
- return { ok: false, reason: "bad-fork-point", forkPoint };
194
+ p.log.info(
195
+ `Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
196
+ );
197
+ const ok = await git.rebaseOnto(targetRef, forkPoint, child);
198
+ if (!ok) return { ok: false, reason: "conflict", forkPoint };
162
199
  }
163
200
 
164
- p.log.info(
165
- `Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
166
- );
167
- const ok = await git.rebaseOnto(targetRef, forkPoint, child);
168
- if (!ok) return { ok: false, reason: "conflict", forkPoint };
169
-
170
201
  if (push) {
171
202
  // Push the rebased head first so the PR diff is clean before its base flips.
172
- await git.pushStackedBranch(child);
173
- if (childPr != null) {
203
+ // If it didn't land, leave the base alone: retargeting onto the trunk with
204
+ // the old head on origin would show the merged commits in the PR (#29).
205
+ const pushed = await git.forcePushWithLease(child, { expect: opts.expect });
206
+ if (!pushed.ok) {
207
+ p.log.error(
208
+ `Push of ${pc.yellow(child)} failed — PR${childPr != null ? ` #${childPr}` : ""} left on ${mergedBranch} for now. The next push of ${child} that lands (gh-stack restack --resume, sync) retargets it.`,
209
+ );
210
+ for (const line of git.pushFailureHint(child, pushed)) console.log(` ${line}`);
211
+ } else if (childPr != null) {
174
212
  const moved = await setPrBase(childPr, recordedBase);
175
213
  if (moved) {
214
+ retargeted = true;
176
215
  p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
177
216
  } else {
178
- p.log.warn(`Could not retarget PR #${childPr} — set its base to ${recordedBase} manually`);
217
+ p.log.warn(
218
+ `Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
219
+ );
179
220
  }
180
221
  }
181
222
  } else if (childPr != null) {
@@ -190,5 +231,14 @@ export async function advanceNewBottom(opts: {
190
231
  // parent (the trunk) and writes. Done after the git work succeeds so a failure
191
232
  // never leaves metadata describing a state git didn't reach.
192
233
  await removeBranchFromStack(meta, stackName, mergedBranch);
234
+
235
+ // Record where the child's PR really points. Until it's retargeted it still
236
+ // targets the merged branch, and the next successful push of the child
237
+ // retargets it (restack's pushStep), so it's never left there.
238
+ const entry = meta.stacks[stackName]?.branches[child];
239
+ if (entry && childPr != null) {
240
+ entry.prBase = retargeted ? recordedBase : mergedBranch;
241
+ await writeMetadata(meta);
242
+ }
193
243
  return { ok: true };
194
244
  }