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.
@@ -11,8 +11,9 @@ import {
11
11
  writeMetadata,
12
12
  } from "../lib/metadata.ts";
13
13
  import { isAutoYes } from "../lib/ui.ts";
14
+ import { ConfigError, describeSource, resolveFlag } from "../lib/config.ts";
14
15
  import type { Stack, StackMetadata } from "../types.ts";
15
- import { getPrNumber } from "../lib/github.ts";
16
+ import { getPrNumber, getRepoNwo, prUrlFor } from "../lib/github.ts";
16
17
  import { resolveOrCreateStack } from "../lib/chain.ts";
17
18
  import { reconcilePrTitles, refreshStackViz, branchToTitle } from "./update-prs.ts";
18
19
 
@@ -32,13 +33,19 @@ submit auto-detects the branch chain from trunk → current, registers
32
33
  chain. You never need to run \`gh-stack init\` first.
33
34
 
34
35
  OPTIONS
35
- -d, --draft Create new PRs as drafts
36
+ -d, --draft Create new PRs as drafts (default: [submit] draft in
37
+ config, or GH_STACK_DRAFT; see gh-stack config)
38
+ --no-draft Create new PRs ready for review, overriding a draft default
36
39
  -n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
37
40
  -t, --title <title> Set the PR title (current branch) — creates or updates
38
41
  -b, --body <body> Set the PR body (current branch) — creates or updates
39
42
  --body-file <f> Read PR body from a file
40
43
  --restack After submitting, restack the branches above the current
41
44
  one onto it (propagate a downstack fix up to children)
45
+ --json Print the result as JSON on stdout: each branch's PR
46
+ number, URL, and action (created/updated/unchanged/failed).
47
+ Logs go to stderr, uncolored. Implies --no-edit.
48
+ Not combinable with --restack or --dry-run
42
49
  --dry-run Show what would happen without doing anything
43
50
 
44
51
  Updating an existing PR: -t / -b also update the PR for the branch you're on
@@ -46,6 +53,23 @@ if it already exists — the title keeps its (N/M) stack position, and the body
46
53
  is replaced with the "Stacked on" block re-merged in. Use this instead of
47
54
  gh pr edit so the stack visualization is never clobbered.
48
55
 
56
+ JSON output (--json): stdout is one JSON document on every exit, success or
57
+ not, so a script or agent can capture the PRs it just created without a
58
+ follow-up \`gh pr view\`:
59
+ { "stack": "...", "current_branch": "...",
60
+ "branches": [{ "branch", "parent", "pr", "url", "title",
61
+ "action", "pushed", "draft", "error"? }],
62
+ "error": null | { "message": "..." } }
63
+ "branches" is the submit scope (trunk → current), in stack order. "action" is
64
+ created (new PR), updated (pushed, or base/title/description edited),
65
+ unchanged, or failed (PR creation or push failed; see the entry's "error").
66
+ "draft" is true/false for PRs created this run and null otherwise. The
67
+ top-level "error" is set when the run stopped early (bad flags, nothing to
68
+ submit, a push failure); "branches" then holds what happened before it, and
69
+ "stack"/"current_branch" may be null. Exits 1 if "error" is set or any branch
70
+ failed. Not available with --dry-run or --restack (run \`gh-stack restack\`
71
+ separately).
72
+
49
73
  Restack after submit (--restack): submit covers DOWNSTACK (trunk → current);
50
74
  restack covers UPSTACK (the children above current). Together they sync the
51
75
  whole stack from wherever you're standing. The common loop: drop downstack,
@@ -58,14 +82,113 @@ EXAMPLES
58
82
  gh-stack submit -t "My PR" -b "Description" # Explicit title and body
59
83
  gh-stack submit --restack # Submit, then restack children onto it
60
84
  gh-stack submit --draft # Create new PRs as drafts
85
+ gh-stack submit --no-draft # Ready for review despite a draft default
86
+ gh-stack submit --json -t "My PR" | jq '.branches[-1].url' # URL of the PR
61
87
  gh-stack submit --dry-run # Preview what would happen
62
88
  `;
63
89
 
90
+ // ── --json result ──
91
+
92
+ export type SubmitAction = "created" | "updated" | "unchanged" | "failed";
93
+
94
+ /** What the push/create phase did to one branch in scope. */
95
+ export interface BranchOutcome {
96
+ branch: string;
97
+ parent: string;
98
+ pr: number | null;
99
+ /** URL printed by `gh pr create`. Existing PRs get theirs from the repo nwo. */
100
+ url: string | null;
101
+ pushed: boolean;
102
+ created: boolean;
103
+ baseChanged: boolean;
104
+ /** Whether a PR created this run was opened as a draft (null: not created). */
105
+ draft: boolean | null;
106
+ error: string | null;
107
+ }
108
+
109
+ export interface SubmitBranchJson {
110
+ branch: string;
111
+ parent: string;
112
+ pr: number | null;
113
+ url: string | null;
114
+ title: string | null;
115
+ action: SubmitAction;
116
+ pushed: boolean;
117
+ draft: boolean | null;
118
+ error?: string;
119
+ }
120
+
121
+ export interface SubmitJson {
122
+ stack: string | null;
123
+ current_branch: string | null;
124
+ branches: SubmitBranchJson[];
125
+ /** Set when the run stopped early: bad flags, nothing to submit, a push failure. */
126
+ error: { message: string } | null;
127
+ }
128
+
64
129
  /**
65
- * Generate a PR title from a branch name.
66
- * Strips common prefixes like "kiliman/", replaces hyphens with spaces,
67
- * and capitalizes the first letter.
130
+ * Classify one branch's outcome. A PR is "updated" when it already existed and
131
+ * this run changed it on GitHub: new commits pushed, base retargeted, or its
132
+ * title/description edited (`edited`, from the reconcile + viz phases).
68
133
  */
134
+ export function submitAction(outcome: BranchOutcome, edited: ReadonlySet<string>): SubmitAction {
135
+ if (outcome.error) return "failed";
136
+ if (outcome.created) return "created";
137
+ if (outcome.pushed || outcome.baseChanged || edited.has(outcome.branch)) return "updated";
138
+ return "unchanged";
139
+ }
140
+
141
+ /**
142
+ * What a run has established so far. Filled in as submit goes, so an early
143
+ * exit can still report the stack, branch, and branches handled before it.
144
+ */
145
+ export interface RunState {
146
+ stackName: string | null;
147
+ currentBranch: string | null;
148
+ stack: Stack | null;
149
+ outcomes: BranchOutcome[];
150
+ /** Branches whose PR title or description was edited (reconcile + viz). */
151
+ edited: ReadonlySet<string>;
152
+ }
153
+
154
+ /** Build the `submit --json` document. Pure, so the shape is unit-testable. */
155
+ export function buildSubmitJson(
156
+ run: RunState & { error: string | null; nwo: string | null },
157
+ ): SubmitJson {
158
+ return {
159
+ stack: run.stackName,
160
+ current_branch: run.currentBranch,
161
+ branches: run.outcomes.map((o) => {
162
+ const entry: SubmitBranchJson = {
163
+ branch: o.branch,
164
+ parent: o.parent,
165
+ pr: o.pr,
166
+ url: o.url ?? (o.pr != null ? prUrlFor(run.nwo, o.pr) : null),
167
+ title: run.stack?.branches[o.branch]?.prTitle ?? null,
168
+ action: submitAction(o, run.edited),
169
+ pushed: o.pushed,
170
+ draft: o.draft,
171
+ };
172
+ if (o.error) entry.error = o.error;
173
+ return entry;
174
+ }),
175
+ error: run.error ? { message: run.error } : null,
176
+ };
177
+ }
178
+
179
+ /** Write the --json document to stdout, awaited so it's flushed before any exit. */
180
+ async function emitJson(run: RunState, error: string | null): Promise<SubmitJson> {
181
+ // Only an existing PR (no URL from `gh pr create`) needs the repo's nwo, so
182
+ // an early refusal makes no gh call at all.
183
+ const needsNwo = run.outcomes.some((o) => o.pr != null && !o.url);
184
+ const result = buildSubmitJson({ ...run, error, nwo: needsNwo ? await getRepoNwo() : null });
185
+ await Bun.write(Bun.stdout, JSON.stringify(result, null, 2) + "\n");
186
+ return result;
187
+ }
188
+
189
+ /** Stops a run with a message for the user. In --json mode it becomes `error`. */
190
+ class SubmitAbort extends Error {}
191
+
69
192
  /**
70
193
  * Check if a local branch is up-to-date with its remote counterpart.
71
194
  */
@@ -80,28 +203,65 @@ async function isBranchUpToDate(branch: string): Promise<boolean> {
80
203
  }
81
204
  }
82
205
 
83
- /**
84
- * Push a branch to origin with upstream tracking and force-with-lease.
85
- */
86
- async function pushBranch(branch: string): Promise<boolean> {
87
- try {
88
- await $`git push -u --force-with-lease origin ${branch}`.quiet();
89
- return true;
90
- } catch {
91
- return false;
92
- }
93
- }
94
-
95
206
  export default async function submit(args: string[]): Promise<void> {
96
207
  if (args.includes("--help")) {
97
208
  console.log(HELP);
98
209
  return;
99
210
  }
100
211
 
101
- const draftFlag = args.includes("--draft") || args.includes("-d");
212
+ // --json: logs and errors go to stderr (plain, and uncolored because index.ts
213
+ // sets NO_COLOR before picocolors loads), so stdout carries only the JSON.
214
+ const jsonMode = args.includes("--json");
215
+ if (jsonMode) p.setStderrOnly();
216
+
217
+ const run: RunState = {
218
+ stackName: null,
219
+ currentBranch: null,
220
+ stack: null,
221
+ outcomes: [],
222
+ edited: new Set(),
223
+ };
224
+ let error: string | null = null;
225
+ try {
226
+ await runSubmit(args, run, jsonMode);
227
+ } catch (err) {
228
+ // A ConfigError (bad config file or env value) is an expected stop too: in
229
+ // --json mode it becomes the document's error, like any refusal.
230
+ const expected = err instanceof SubmitAbort || err instanceof ConfigError;
231
+ if (!jsonMode && !expected) throw err;
232
+ if (!expected) console.error(err);
233
+ error = (err as Error).message;
234
+ p.cancel(error);
235
+ if (!jsonMode) process.exit(1);
236
+ }
237
+
238
+ if (jsonMode) {
239
+ const result = await emitJson(run, error);
240
+ if (result.error || result.branches.some((b) => b.action === "failed")) process.exitCode = 1;
241
+ }
242
+ }
243
+
244
+ async function runSubmit(args: string[], run: RunState, jsonMode: boolean): Promise<void> {
245
+ // Draft is create-time only: existing PRs keep whatever state they have.
246
+ const draft = await resolveFlag("submit.draft", args);
247
+ const draftFlag = draft.value;
248
+ let draftNoted = false;
102
249
  const dryRun = args.includes("--dry-run");
103
250
  const restackFlag = args.includes("--restack");
104
251
 
252
+ if (jsonMode && dryRun) {
253
+ throw new SubmitAbort(
254
+ "--json can't be combined with --dry-run: --json reports what submit did.",
255
+ );
256
+ }
257
+ // restack runs git commands that write to stdout directly, so its output
258
+ // can't be kept off the JSON channel.
259
+ if (jsonMode && restackFlag) {
260
+ throw new SubmitAbort(
261
+ "--json can't be combined with --restack. Run `gh-stack restack` separately after submit.",
262
+ );
263
+ }
264
+
105
265
  // Parse value flags
106
266
  let titleFlag: string | undefined;
107
267
  let bodyFlag: string | undefined;
@@ -121,12 +281,13 @@ export default async function submit(args: string[]): Promise<void> {
121
281
  try {
122
282
  bodyFlag = await Bun.file(bodyFileFlag).text();
123
283
  } catch {
124
- p.cancel(`Could not read body file: ${bodyFileFlag}`);
125
- process.exit(1);
284
+ throw new SubmitAbort(`Could not read body file: ${bodyFileFlag}`);
126
285
  }
127
286
  }
128
287
 
129
- const noEdit = args.includes("--no-edit") || args.includes("-n") || isAutoYes() || !!titleFlag;
288
+ // --json callers are scripts and agents: nothing can answer a title prompt.
289
+ const noEdit =
290
+ args.includes("--no-edit") || args.includes("-n") || isAutoYes() || !!titleFlag || jsonMode;
130
291
 
131
292
  // Read metadata, or start with an empty in-memory store. submit self-heals,
132
293
  // so a missing metadata file is fine — we'll build the stack from local
@@ -137,6 +298,7 @@ export default async function submit(args: string[]): Promise<void> {
137
298
  stacks: {},
138
299
  };
139
300
  const branch = await git.currentBranch();
301
+ run.currentBranch = branch;
140
302
  let stackName = findStackForBranch(meta, branch);
141
303
 
142
304
  // ── Self-heal: branch isn't in any stack ──
@@ -148,18 +310,16 @@ export default async function submit(args: string[]): Promise<void> {
148
310
  const trunk = await git.trunkBranch();
149
311
 
150
312
  if (branch === trunk || branch === "main" || branch === "master") {
151
- p.cancel(
313
+ throw new SubmitAbort(
152
314
  `You're on ${pc.blue(branch)} — nothing to submit.\n\n Checkout a feature branch first.`,
153
315
  );
154
- process.exit(1);
155
316
  }
156
317
 
157
318
  let resolution;
158
319
  try {
159
320
  resolution = await resolveOrCreateStack(meta, branch, trunk);
160
321
  } catch (err) {
161
- p.cancel((err as Error).message);
162
- process.exit(1);
322
+ throw new SubmitAbort((err as Error).message);
163
323
  }
164
324
 
165
325
  stackName = resolution.stackName;
@@ -176,6 +336,8 @@ export default async function submit(args: string[]): Promise<void> {
176
336
 
177
337
  const stack = meta.stacks[stackName]!;
178
338
  const ordered = getOrderedBranches(stack);
339
+ run.stackName = stackName;
340
+ run.stack = stack;
179
341
 
180
342
  // Determine downstack scope: branches from trunk up to and including current
181
343
  const currentIndex = ordered.indexOf(branch);
@@ -197,8 +359,8 @@ export default async function submit(args: string[]): Promise<void> {
197
359
  );
198
360
  }
199
361
 
200
- console.log();
201
- console.log(` ${pc.dim(heal.base)}`);
362
+ p.print();
363
+ p.print(` ${pc.dim(heal.base)}`);
202
364
  for (let i = 0; i < heal.chain.length; i++) {
203
365
  const b = heal.chain[i]!;
204
366
  const isLast = i === heal.chain.length - 1;
@@ -206,21 +368,21 @@ export default async function submit(args: string[]): Promise<void> {
206
368
  const isNew = heal.added.includes(b);
207
369
  const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
208
370
  const tag = isNew ? pc.green(" +") : "";
209
- console.log(`${tree} ${label}${tag}`);
210
- if (!isLast) console.log(" ┃");
371
+ p.print(`${tree} ${label}${tag}`);
372
+ if (!isLast) p.print(" ┃");
211
373
  }
212
- console.log();
374
+ p.print();
213
375
  }
214
376
 
215
377
  p.log.info(`Stack: ${pc.yellow(stackName)}`);
216
378
  p.log.info(
217
379
  `Scope: ${scope.length} of ${ordered.length} branch(es) (downstack from ${pc.blue(branch)})`,
218
380
  );
219
- console.log();
381
+ p.print();
220
382
 
221
383
  if (dryRun) {
222
384
  p.log.info(pc.dim("Dry run — no changes will be made"));
223
- console.log();
385
+ p.print();
224
386
  }
225
387
 
226
388
  // ── Phase 1: Push branches and create/update PRs ──
@@ -228,10 +390,37 @@ export default async function submit(args: string[]): Promise<void> {
228
390
  let created = 0;
229
391
  let existing = 0;
230
392
  let metadataChanged = false;
393
+ const outcomes = run.outcomes;
394
+
395
+ // The branch's PR: recorded in metadata, else looked up on GitHub. A PR
396
+ // found that way (opened outside gh-stack) is recorded for next time.
397
+ const findPr = async (name: string): Promise<number | null> => {
398
+ const bm = stack.branches[name]!;
399
+ if (bm.pr) return bm.pr;
400
+ if (dryRun) return null;
401
+ const found = await getPrNumber(name);
402
+ if (found) {
403
+ bm.pr = found;
404
+ metadataChanged = true;
405
+ }
406
+ return found;
407
+ };
231
408
 
232
409
  for (const branchName of scope) {
233
410
  const branchMeta = stack.branches[branchName]!;
234
411
  const parentBranch = branchMeta.parent;
412
+ const outcome: BranchOutcome = {
413
+ branch: branchName,
414
+ parent: parentBranch,
415
+ pr: null,
416
+ url: null,
417
+ pushed: false,
418
+ created: false,
419
+ baseChanged: false,
420
+ draft: null,
421
+ error: null,
422
+ };
423
+ outcomes.push(outcome);
235
424
 
236
425
  // ── Push (skip if already up-to-date) ──
237
426
  if (dryRun) {
@@ -241,28 +430,26 @@ export default async function submit(args: string[]): Promise<void> {
241
430
  } else {
242
431
  const s = p.spinner();
243
432
  s.start(`Pushing ${pc.blue(branchName)}...`);
244
- const ok = await pushBranch(branchName);
245
- if (ok) {
433
+ const result = await git.forcePushWithLease(branchName);
434
+ if (result.ok) {
246
435
  s.stop(`Pushed ${pc.blue(branchName)}`);
247
436
  pushed++;
437
+ outcome.pushed = true;
248
438
  } else {
249
439
  s.stop(pc.red(`Failed to push ${branchName}`));
250
- p.cancel(`Push failed for ${pc.blue(branchName)}. Aborting.`);
251
- process.exit(1);
440
+ for (const line of git.pushFailureHint(branchName, result)) p.log.warn(line);
441
+ outcome.error = `push to origin/${branchName} failed`;
442
+ outcome.pr = await findPr(branchName);
443
+ // Keep what this run learned (PRs created or discovered below this
444
+ // branch, and this branch's own PR) before aborting.
445
+ if (metadataChanged) await writeMetadata(meta);
446
+ throw new SubmitAbort(`Push failed for ${pc.blue(branchName)}. Aborting.`);
252
447
  }
253
448
  }
254
449
 
255
450
  // ── Check for existing PR ──
256
- let prNumber = branchMeta.pr ?? null;
257
-
258
- if (!prNumber && !dryRun) {
259
- prNumber = await getPrNumber(branchName);
260
- if (prNumber) {
261
- // Found an existing PR not tracked in metadata — save it
262
- branchMeta.pr = prNumber;
263
- metadataChanged = true;
264
- }
265
- }
451
+ const prNumber = await findPr(branchName);
452
+ outcome.pr = prNumber;
266
453
 
267
454
  if (prNumber) {
268
455
  // ── Existing PR: update base only if the parent actually changed ──
@@ -277,6 +464,7 @@ export default async function submit(args: string[]): Promise<void> {
277
464
  await $`gh pr edit ${prNumber} --base ${parentBranch}`.quiet();
278
465
  branchMeta.prBase = parentBranch;
279
466
  metadataChanged = true;
467
+ outcome.baseChanged = true;
280
468
  } catch {
281
469
  // Best effort — base might already be correct
282
470
  }
@@ -309,6 +497,15 @@ export default async function submit(args: string[]): Promise<void> {
309
497
  );
310
498
  created++;
311
499
  } else {
500
+ // A default (not a flag) decided draft vs ready: say which, once.
501
+ if (!draftNoted && draft.source !== "flag" && draft.source !== "default") {
502
+ draftNoted = true;
503
+ p.log.info(
504
+ pc.dim(
505
+ `New PRs open ${draftFlag ? "as drafts" : "ready for review"}: ${describeSource(draft)}.`,
506
+ ),
507
+ );
508
+ }
312
509
  const s = p.spinner();
313
510
  s.start(`Creating PR for ${pc.blue(branchName)}...`);
314
511
 
@@ -338,6 +535,10 @@ export default async function submit(args: string[]): Promise<void> {
338
535
  const prUrl = output.trim();
339
536
  const prNumMatch = prUrl.match(/\/pull\/(\d+)/);
340
537
  const newPrNumber = prNumMatch ? parseInt(prNumMatch[1]!, 10) : null;
538
+ outcome.created = true;
539
+ outcome.draft = draftFlag;
540
+ outcome.pr = newPrNumber;
541
+ outcome.url = prUrl || null;
341
542
 
342
543
  if (newPrNumber) {
343
544
  branchMeta.pr = newPrNumber;
@@ -353,12 +554,13 @@ export default async function submit(args: string[]): Promise<void> {
353
554
  const stderr = await new Response(proc.stderr).text();
354
555
  s.stop(pc.red(`Failed to create PR for ${branchName}`));
355
556
  p.log.error(stderr.trim());
557
+ outcome.error = stderr.trim() || "gh pr create failed";
356
558
  }
357
559
  }
358
560
  }
359
561
  }
360
562
 
361
- console.log();
563
+ p.print();
362
564
 
363
565
  if (dryRun) {
364
566
  p.outro(
@@ -386,6 +588,8 @@ export default async function submit(args: string[]): Promise<void> {
386
588
  bodyOverrides: bodyFlag ? { [branch]: bodyFlag } : undefined,
387
589
  });
388
590
 
591
+ run.edited = new Set([...titles.updatedBranches, ...viz.updatedBranches]);
592
+
389
593
  // One persist for everything we cached this run (discovered PRs, base edits,
390
594
  // backfilled/renumbered titles, viz hashes).
391
595
  if (metadataChanged || titles.dirty || viz.dirty) {
@@ -393,7 +597,7 @@ export default async function submit(args: string[]): Promise<void> {
393
597
  }
394
598
 
395
599
  // ── Summary ──
396
- console.log();
600
+ p.print();
397
601
  p.outro(
398
602
  pc.green(
399
603
  `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s), ${titles.updated} title(s)` +
@@ -416,12 +620,14 @@ async function maybeRestack(stack: Stack, branch: string, dryRun: boolean): Prom
416
620
  // buildRebaseChain returns the current branch + all its descendants; a length
417
621
  // of 1 means it's the top of its line, so there's nothing above to restack.
418
622
  if (buildRebaseChain(stack, branch).length <= 1) {
419
- console.log();
623
+ p.print();
420
624
  p.log.info(pc.dim(`--restack: nothing sits above ${pc.yellow(branch)} — nothing to restack.`));
421
625
  return;
422
626
  }
423
627
 
424
- console.log();
628
+ p.print();
425
629
  const { default: restack } = await import("./restack.ts");
426
- await restack(dryRun ? ["--dry-run"] : []);
630
+ // Always push: submit publishes by nature, and the explicit flag means no
631
+ // config is read after submit's own pushes already happened.
632
+ await restack([...(dryRun ? ["--dry-run"] : []), "--push"]);
427
633
  }