gh-stack 0.18.0 → 0.19.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/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 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
 
@@ -148,9 +148,15 @@ export async function advanceNewBottom(opts: {
148
148
  recordedBase: string;
149
149
  /** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
150
150
  push?: boolean;
151
+ /**
152
+ * The child's origin tip as the remote guard checked and cleared it: the
153
+ * push leases on exactly that sha (see git.forcePushWithLease).
154
+ */
155
+ expect?: string;
151
156
  }): Promise<AdvanceResult> {
152
157
  const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
153
158
  const push = opts.push ?? true;
159
+ let retargeted = false;
154
160
 
155
161
  const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
156
162
  if (!forkPoint) return { ok: false, reason: "no-fork-point" };
@@ -169,13 +175,23 @@ export async function advanceNewBottom(opts: {
169
175
 
170
176
  if (push) {
171
177
  // Push the rebased head first so the PR diff is clean before its base flips.
172
- await git.pushStackedBranch(child);
173
- if (childPr != null) {
178
+ // If it didn't land, leave the base alone: retargeting onto the trunk with
179
+ // the old head on origin would show the merged commits in the PR (#29).
180
+ const pushed = await git.forcePushWithLease(child, { expect: opts.expect });
181
+ if (!pushed.ok) {
182
+ p.log.error(
183
+ `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.`,
184
+ );
185
+ for (const line of git.pushFailureHint(child, pushed)) console.log(` ${line}`);
186
+ } else if (childPr != null) {
174
187
  const moved = await setPrBase(childPr, recordedBase);
175
188
  if (moved) {
189
+ retargeted = true;
176
190
  p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
177
191
  } else {
178
- p.log.warn(`Could not retarget PR #${childPr} — set its base to ${recordedBase} manually`);
192
+ p.log.warn(
193
+ `Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
194
+ );
179
195
  }
180
196
  }
181
197
  } else if (childPr != null) {
@@ -190,5 +206,14 @@ export async function advanceNewBottom(opts: {
190
206
  // parent (the trunk) and writes. Done after the git work succeeds so a failure
191
207
  // never leaves metadata describing a state git didn't reach.
192
208
  await removeBranchFromStack(meta, stackName, mergedBranch);
209
+
210
+ // Record where the child's PR really points. Until it's retargeted it still
211
+ // targets the merged branch, and the next successful push of the child
212
+ // retargets it (restack's pushStep), so it's never left there.
213
+ const entry = meta.stacks[stackName]?.branches[child];
214
+ if (entry && childPr != null) {
215
+ entry.prBase = retargeted ? recordedBase : mergedBranch;
216
+ await writeMetadata(meta);
217
+ }
193
218
  return { ok: true };
194
219
  }