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/README.md +247 -18
- package/package.json +9 -8
- package/src/commands/config.ts +133 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +352 -12
- package/src/commands/restack.ts +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +144 -156
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +11 -6
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +67 -17
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +84 -22
- package/src/lib/github.ts +63 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +29 -8
- package/src/lib/paths.ts +1 -1
- package/src/lib/remote-guard.ts +281 -0
- package/src/lib/run-log.ts +117 -0
- package/src/lib/safety.ts +12 -4
- package/src/lib/using-gh-stack.md +131 -5
- package/src/types.ts +62 -4
package/src/commands/submit.ts
CHANGED
|
@@ -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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
process.exit(1);
|
|
284
|
+
throw new SubmitAbort(`Could not read body file: ${bodyFileFlag}`);
|
|
126
285
|
}
|
|
127
286
|
}
|
|
128
287
|
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
210
|
-
if (!isLast)
|
|
371
|
+
p.print(`${tree} ${label}${tag}`);
|
|
372
|
+
if (!isLast) p.print(" ┃");
|
|
211
373
|
}
|
|
212
|
-
|
|
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
|
-
|
|
381
|
+
p.print();
|
|
220
382
|
|
|
221
383
|
if (dryRun) {
|
|
222
384
|
p.log.info(pc.dim("Dry run — no changes will be made"));
|
|
223
|
-
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
p.print();
|
|
425
629
|
const { default: restack } = await import("./restack.ts");
|
|
426
|
-
|
|
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
|
}
|