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/sync.ts
CHANGED
|
@@ -7,28 +7,34 @@ import {
|
|
|
7
7
|
findStackForBranch,
|
|
8
8
|
getOrderedBranches,
|
|
9
9
|
removeBranchFromStack,
|
|
10
|
-
saveRestackState,
|
|
11
10
|
stackBase,
|
|
12
11
|
writeMetadata,
|
|
13
12
|
} from "../lib/metadata.ts";
|
|
14
13
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
14
|
+
import { resolveFlag } from "../lib/config.ts";
|
|
15
15
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
17
17
|
import { getPrInfo } from "../lib/github.ts";
|
|
18
|
-
import { planMerges, advanceNewBottom, type BranchReview } from "../lib/advance.ts";
|
|
18
|
+
import { planMerges, advanceNewBottom, type BranchReview, type MergePlan } from "../lib/advance.ts";
|
|
19
19
|
import type { StackMetadata } from "../types.ts";
|
|
20
|
-
import {
|
|
20
|
+
import restack, {
|
|
21
|
+
notePendingRun,
|
|
22
|
+
refuseRebaseInProgress,
|
|
23
|
+
resolvePush,
|
|
24
|
+
routePendingRun,
|
|
25
|
+
syncChain,
|
|
26
|
+
} from "./restack.ts";
|
|
21
27
|
|
|
22
28
|
export default async function sync(args: string[]): Promise<void> {
|
|
23
29
|
const dryRun = args.includes("--dry-run");
|
|
24
|
-
const
|
|
30
|
+
const fresh = args.includes("--fresh");
|
|
25
31
|
|
|
26
32
|
if (args.includes("--help")) {
|
|
27
33
|
console.log(`
|
|
28
34
|
gh-stack sync — Sync base with main + restack all
|
|
29
35
|
|
|
30
36
|
USAGE
|
|
31
|
-
gh-stack sync [--dry-run] [--no-push]
|
|
37
|
+
gh-stack sync [--dry-run] [--no-push | --push] [--fresh]
|
|
32
38
|
|
|
33
39
|
Fetches latest main, rebases the base branch onto main,
|
|
34
40
|
then restacks all children. Equivalent to running restack
|
|
@@ -37,68 +43,96 @@ with the base branch included.
|
|
|
37
43
|
OPTIONS
|
|
38
44
|
--dry-run Show what would happen without executing
|
|
39
45
|
--no-push Rebase locally only — never push or retarget PRs
|
|
40
|
-
(also GH_STACK_NO_PUSH=1
|
|
46
|
+
(also GH_STACK_NO_PUSH=1, or \`[restack] push = false\` in
|
|
47
|
+
config; see gh-stack config). Wins over --yes for pushes
|
|
48
|
+
--push Push even when a config default or env var says not to
|
|
49
|
+
--resume Same as \`gh-stack restack --resume\`
|
|
50
|
+
--fresh Discard an interrupted restack/sync instead of resuming it
|
|
51
|
+
--overwrite-remote
|
|
52
|
+
Rewrite branches even though origin has commits they lack.
|
|
53
|
+
Those commits are dropped from origin
|
|
54
|
+
|
|
55
|
+
If an interrupted restack or sync is pending, sync asks whether to resume it
|
|
56
|
+
(with --yes it resumes) rather than starting over on top of it.
|
|
57
|
+
|
|
58
|
+
Before rewriting anything, sync stops if a branch's copy on origin has
|
|
59
|
+
commits the local branch never had (e.g. the squash commit \`merge --collapse\`
|
|
60
|
+
adds on GitHub). Pull or cherry-pick them first; see \`gh-stack restack --help\`.
|
|
41
61
|
`);
|
|
42
62
|
return;
|
|
43
63
|
}
|
|
44
64
|
|
|
65
|
+
// Only explicit push flags (and --overwrite-remote) carry over to a resume:
|
|
66
|
+
// it otherwise keeps the push decision of the run it resumes.
|
|
67
|
+
const pushFlags = args.filter(
|
|
68
|
+
(a) => a === "--push" || a === "--no-push" || a === "--overwrite-remote",
|
|
69
|
+
);
|
|
70
|
+
const resumeRun = () => restack(["--resume", ...pushFlags]);
|
|
71
|
+
if (args.includes("--resume")) {
|
|
72
|
+
await resumeRun();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
// Reject if a git rebase is already in progress. Letting sync run on top
|
|
46
77
|
// of a half-finished rebase produces deeply confusing state — make the
|
|
47
78
|
// user finish or abort it first.
|
|
48
79
|
if (await git.isRebaseInProgress()) {
|
|
49
|
-
|
|
50
|
-
`Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}\n\n Then re-run ${pc.green("gh-stack sync")}.`,
|
|
51
|
-
);
|
|
52
|
-
process.exit(1);
|
|
80
|
+
await refuseRebaseInProgress("sync");
|
|
53
81
|
}
|
|
54
82
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
// An interrupted restack/sync is pending: resume it rather than starting a
|
|
84
|
+
// new sync on top of it (#29).
|
|
85
|
+
if (dryRun) {
|
|
86
|
+
await notePendingRun();
|
|
87
|
+
} else if (
|
|
88
|
+
(await routePendingRun("sync", {
|
|
89
|
+
fresh,
|
|
90
|
+
preflight: async () => void (await syncPreconditions(args)),
|
|
91
|
+
})) === "resume"
|
|
92
|
+
) {
|
|
93
|
+
await resumeRun();
|
|
94
|
+
return;
|
|
66
95
|
}
|
|
67
96
|
|
|
97
|
+
const push = await resolvePush(args);
|
|
98
|
+
const noPush = !push.value;
|
|
99
|
+
const { meta, stackName } = await syncPreconditions(args);
|
|
68
100
|
let stack = meta.stacks[stackName]!;
|
|
69
|
-
await ensureValidStack(meta, stackName);
|
|
70
101
|
let ordered = getOrderedBranches(stack);
|
|
71
102
|
|
|
72
|
-
if (ordered.length === 0) {
|
|
73
|
-
p.cancel("No branches in this stack");
|
|
74
|
-
process.exit(1);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
103
|
p.intro(pc.cyan("Git Stack Sync"));
|
|
78
104
|
|
|
79
105
|
// Find the root branch (whose parent is the stack's base)
|
|
80
106
|
const base = stackBase(stack);
|
|
81
|
-
const baseIsTrunk = base === "main" || base === "master";
|
|
82
|
-
|
|
83
|
-
// sync only makes sense for trunk-rooted stacks — it fetches main and
|
|
84
|
-
// rebases onto it. A split stack is rooted on a branch in another stack, so
|
|
85
|
-
// syncing it means restacking onto that base (handled by `restack`), or
|
|
86
|
-
// re-rooting onto main once the parent stack has merged.
|
|
87
|
-
if (!baseIsTrunk) {
|
|
88
|
-
p.cancel(
|
|
89
|
-
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
|
|
90
|
-
` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
|
|
91
|
-
` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
|
|
92
|
-
);
|
|
93
|
-
process.exit(1);
|
|
94
|
-
}
|
|
95
107
|
|
|
96
108
|
// Catch up to reality first: if the bottom PR(s) already landed on the trunk
|
|
97
109
|
// outside gh-stack (e.g. the GitHub web UI), the stack metadata still lists
|
|
98
110
|
// them. A plain rebase onto main would then replay their now-squashed commits
|
|
99
111
|
// and conflict — so advance past them (re-rooting the survivor onto main with
|
|
100
112
|
// `--onto`) before the normal sync runs. (#20)
|
|
101
|
-
const
|
|
113
|
+
const merged = await findMergedBottoms(meta, stackName);
|
|
114
|
+
|
|
115
|
+
// Nothing is rewritten yet: stop here if origin holds commits the local
|
|
116
|
+
// branches lack, e.g. a collapse's squash commit on the base branch (#28).
|
|
117
|
+
// Bottoms whose PRs already merged are skipped: the advance drops them
|
|
118
|
+
// rather than rewriting them, and after `merge --approved` they're exactly
|
|
119
|
+
// where the collapse's squash commits live.
|
|
120
|
+
const mergedBranches = new Set(merged.steps.map((step) => step.branch));
|
|
121
|
+
const verified = await guardRemoteContent(
|
|
122
|
+
meta,
|
|
123
|
+
ordered.filter((b) => !mergedBranches.has(b)),
|
|
124
|
+
{ command: "sync", overwrite: args.includes("--overwrite-remote"), dryRun },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const advance = await advanceMergedBottoms(
|
|
128
|
+
meta,
|
|
129
|
+
stackName,
|
|
130
|
+
base,
|
|
131
|
+
dryRun,
|
|
132
|
+
noPush,
|
|
133
|
+
merged,
|
|
134
|
+
verified,
|
|
135
|
+
);
|
|
102
136
|
if (advance.drained) {
|
|
103
137
|
p.outro(pc.green("Bottom PR(s) already merged — stack fully drained & archived."));
|
|
104
138
|
return;
|
|
@@ -156,82 +190,55 @@ OPTIONS
|
|
|
156
190
|
// need to find when restacking children. (See resolveRestackBoundary.)
|
|
157
191
|
await takeSnapshot(meta, stackName, "sync");
|
|
158
192
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
console.log(pc.cyan("━".repeat(40)));
|
|
164
|
-
console.log();
|
|
165
|
-
|
|
166
|
-
// Check if already up to date
|
|
167
|
-
if (await git.isAncestor("origin/main", baseBranch)) {
|
|
168
|
-
p.log.success(`${baseBranch} is already up to date with main`);
|
|
169
|
-
} else if (dryRun) {
|
|
170
|
-
p.log.warn(`[DRY RUN] Would rebase ${baseBranch} onto origin/main`);
|
|
171
|
-
} else {
|
|
172
|
-
const confirmed = await confirmAction(`Rebase ${pc.yellow(baseBranch)} onto origin/main?`);
|
|
173
|
-
if (!confirmed) {
|
|
174
|
-
p.cancel("Cancelled");
|
|
175
|
-
process.exit(0);
|
|
176
|
-
}
|
|
193
|
+
// Rebase the root onto main, push it, then restack every branch above it —
|
|
194
|
+
// one run, saved after every step, so `restack --resume` can finish it.
|
|
195
|
+
await syncChain(meta, stackName, ordered, { trunkRef: "origin/main", push, verified });
|
|
196
|
+
}
|
|
177
197
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
p.log.info("Rebasing onto origin/main...");
|
|
192
|
-
const success = await git.rebase("origin/main");
|
|
193
|
-
|
|
194
|
-
if (!success) {
|
|
195
|
-
console.log();
|
|
196
|
-
p.log.error("Rebase conflict — resolve and run:");
|
|
197
|
-
console.log(` ${pc.green("git rebase --continue")}`);
|
|
198
|
-
console.log(` ${pc.green("gh-stack restack --resume")}`);
|
|
199
|
-
process.exit(2);
|
|
200
|
-
}
|
|
198
|
+
/**
|
|
199
|
+
* What a new sync needs before it changes anything: a clean tree, a valid push
|
|
200
|
+
* setting, and the current branch in a trunk-rooted stack. Also the preflight
|
|
201
|
+
* for discarding an interrupted run, so a sync that would refuse leaves that
|
|
202
|
+
* run intact.
|
|
203
|
+
*/
|
|
204
|
+
async function syncPreconditions(
|
|
205
|
+
args: string[],
|
|
206
|
+
): Promise<{ meta: StackMetadata; stackName: string }> {
|
|
207
|
+
await resolveFlag("restack.push", args); // a bad setting stops here
|
|
208
|
+
await ensureCleanWorkingTree();
|
|
201
209
|
|
|
202
|
-
|
|
210
|
+
const meta = await ensureMetadata();
|
|
211
|
+
const currentBranch = await git.currentBranch();
|
|
212
|
+
const stackName = findStackForBranch(meta, currentBranch);
|
|
203
213
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
214
|
+
if (!stackName) {
|
|
215
|
+
p.cancel(
|
|
216
|
+
`Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Track it first (no push/PR):\n ${pc.green("gh-stack add")}\n\n Or open its PR with:\n ${pc.green("gh-stack submit")}`,
|
|
217
|
+
);
|
|
218
|
+
process.exit(1);
|
|
210
219
|
}
|
|
211
220
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
return;
|
|
221
|
+
const stack = meta.stacks[stackName]!;
|
|
222
|
+
await ensureValidStack(meta, stackName);
|
|
223
|
+
if (getOrderedBranches(stack).length === 0) {
|
|
224
|
+
p.cancel("No branches in this stack");
|
|
225
|
+
process.exit(1);
|
|
218
226
|
}
|
|
219
227
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
await git.checkout(children[0]!);
|
|
233
|
-
await restack([...(dryRun ? ["--dry-run"] : []), ...(noPush ? ["--no-push"] : [])]);
|
|
228
|
+
// sync only makes sense for trunk-rooted stacks — it fetches main and
|
|
229
|
+
// rebases onto it. A split stack is rooted on a branch in another stack, so
|
|
230
|
+
// syncing it means restacking onto that base (handled by `restack`), or
|
|
231
|
+
// re-rooting onto main once the parent stack has merged.
|
|
232
|
+
const base = stackBase(stack);
|
|
233
|
+
if (base !== "main" && base !== "master") {
|
|
234
|
+
p.cancel(
|
|
235
|
+
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
|
|
236
|
+
` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
|
|
237
|
+
` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
|
|
238
|
+
);
|
|
239
|
+
process.exit(1);
|
|
234
240
|
}
|
|
241
|
+
return { meta, stackName };
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
/** Result of the merged-bottom catch-up pass. */
|
|
@@ -253,20 +260,15 @@ interface AdvanceOutcome {
|
|
|
253
260
|
* Returns what happened so the caller can re-derive the root or short-circuit.
|
|
254
261
|
* In dry-run it reports and makes no changes.
|
|
255
262
|
*/
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
): Promise<AdvanceOutcome> {
|
|
263
|
+
/**
|
|
264
|
+
* The contiguous bottom branches whose PRs already merged (outside gh-stack,
|
|
265
|
+
* or the base of a `merge --approved`), walking bottom-up and stopping at the
|
|
266
|
+
* first that isn't.
|
|
267
|
+
*/
|
|
268
|
+
async function findMergedBottoms(meta: StackMetadata, stackName: string): Promise<MergePlan> {
|
|
263
269
|
const stack = meta.stacks[stackName]!;
|
|
264
|
-
const ordered = getOrderedBranches(stack);
|
|
265
|
-
|
|
266
|
-
// Walk bottom-up gathering PR state; stop at the first branch that isn't a
|
|
267
|
-
// merged PR (only contiguous merged bottoms can be advanced past).
|
|
268
270
|
const reviews: BranchReview[] = [];
|
|
269
|
-
for (const branch of
|
|
271
|
+
for (const branch of getOrderedBranches(stack)) {
|
|
270
272
|
const pr = stack.branches[branch]?.pr ?? null;
|
|
271
273
|
let prState: string | null = null;
|
|
272
274
|
if (pr != null) {
|
|
@@ -276,8 +278,21 @@ async function advanceMergedBottoms(
|
|
|
276
278
|
reviews.push({ branch, pr, prState, reviewDecision: null });
|
|
277
279
|
if (prState !== "MERGED") break;
|
|
278
280
|
}
|
|
281
|
+
return planMerges(reviews, { approveAndMerge: false });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function advanceMergedBottoms(
|
|
285
|
+
meta: StackMetadata,
|
|
286
|
+
stackName: string,
|
|
287
|
+
trunk: string,
|
|
288
|
+
dryRun: boolean,
|
|
289
|
+
noPush: boolean,
|
|
290
|
+
plan: MergePlan,
|
|
291
|
+
verified: Map<string, string>,
|
|
292
|
+
): Promise<AdvanceOutcome> {
|
|
293
|
+
const stack = meta.stacks[stackName]!;
|
|
294
|
+
const ordered = getOrderedBranches(stack);
|
|
279
295
|
|
|
280
|
-
const plan = planMerges(reviews, { approveAndMerge: false });
|
|
281
296
|
if (plan.steps.length === 0) {
|
|
282
297
|
return { detected: false, advanced: false, drained: false };
|
|
283
298
|
}
|
|
@@ -316,14 +331,17 @@ async function advanceMergedBottoms(
|
|
|
316
331
|
targetRef,
|
|
317
332
|
recordedBase: trunk,
|
|
318
333
|
push: !noPush,
|
|
334
|
+
expect: verified.get(child),
|
|
319
335
|
});
|
|
320
336
|
if (!res.ok) {
|
|
321
337
|
console.log();
|
|
322
338
|
if (res.reason === "conflict") {
|
|
323
339
|
p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
|
|
324
|
-
console.log(` Resolve it, then finish the
|
|
340
|
+
console.log(` Resolve it, then finish the sync:`);
|
|
325
341
|
console.log(` ${pc.green("git rebase --continue")}`);
|
|
326
|
-
console.log(
|
|
342
|
+
console.log(
|
|
343
|
+
` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${child}; not restack, which would put it back on ${mergedBranch})`)}`,
|
|
344
|
+
);
|
|
327
345
|
} else {
|
|
328
346
|
p.log.error(
|
|
329
347
|
`Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
|
|
@@ -358,33 +376,3 @@ async function advanceMergedBottoms(
|
|
|
358
376
|
|
|
359
377
|
return { detected: true, advanced: true, drained: false };
|
|
360
378
|
}
|
|
361
|
-
|
|
362
|
-
async function promptForcePush(branch: string): Promise<void> {
|
|
363
|
-
if (branch === "main" || branch === "master") return;
|
|
364
|
-
if (!(await git.remoteBranchExists(branch))) return;
|
|
365
|
-
|
|
366
|
-
const localSha = await git.revParse(branch);
|
|
367
|
-
let remoteSha: string;
|
|
368
|
-
try {
|
|
369
|
-
remoteSha = await git.revParse(`origin/${branch}`);
|
|
370
|
-
} catch {
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (localSha === remoteSha) return;
|
|
375
|
-
|
|
376
|
-
console.log();
|
|
377
|
-
p.log.info(`${pc.yellow("⚠")} ${branch} is out of sync with remote`);
|
|
378
|
-
|
|
379
|
-
const confirmed = await confirmAction("Push with --force-with-lease?");
|
|
380
|
-
if (confirmed) {
|
|
381
|
-
const ok = await git.forcePushWithLease(branch);
|
|
382
|
-
if (ok) {
|
|
383
|
-
p.log.success("Pushed successfully");
|
|
384
|
-
} else {
|
|
385
|
-
p.log.error("Push failed");
|
|
386
|
-
}
|
|
387
|
-
} else {
|
|
388
|
-
p.log.warn("Skipping push");
|
|
389
|
-
}
|
|
390
|
-
}
|
package/src/commands/undo.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as git from "../lib/git.ts";
|
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
6
6
|
import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
|
|
7
|
+
import { clearRestackState, loadRestackState } from "../lib/metadata.ts";
|
|
7
8
|
import { confirmAction } from "../lib/ui.ts";
|
|
8
9
|
import { $ } from "bun";
|
|
9
10
|
|
|
@@ -72,5 +73,15 @@ a destructive operation (restack, merge, sync, remove).
|
|
|
72
73
|
// Pop the snapshot
|
|
73
74
|
await popSnapshot(meta);
|
|
74
75
|
|
|
76
|
+
// An unfinished run on this stack was rewriting the branches just restored;
|
|
77
|
+
// resuming it would push the restored tips as if they were its results.
|
|
78
|
+
const run = await loadRestackState().catch(() => null); // an unusable state is left for --fresh
|
|
79
|
+
if (run && !run.finished_at && run.stack_name === snapshot.stack) {
|
|
80
|
+
await clearRestackState();
|
|
81
|
+
p.log.info(
|
|
82
|
+
`Discarded the interrupted ${run.command}'s saved state (its branches were restored).`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
p.outro(pc.green("Undo complete!"));
|
|
76
87
|
}
|
|
@@ -162,6 +162,7 @@ export function numberedTitle(title: string, position: number, total: number): s
|
|
|
162
162
|
|
|
163
163
|
export interface TitleResult {
|
|
164
164
|
updated: number; // PR titles we edited on GitHub
|
|
165
|
+
updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
|
|
165
166
|
dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -211,21 +212,24 @@ export async function reconcilePrTitles(
|
|
|
211
212
|
|
|
212
213
|
const items = ordered.map((name) => {
|
|
213
214
|
const bm = stack.branches[name]!;
|
|
214
|
-
return { pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
|
|
215
|
+
return { name, pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
|
|
215
216
|
});
|
|
216
217
|
|
|
217
|
-
const
|
|
218
|
+
const edits = planTitleEdits(items);
|
|
219
|
+
const oks = await mapLimit(edits, 8, async ({ item, desired }) => {
|
|
218
220
|
const ok = await updatePrTitle(item.pr!, desired);
|
|
219
221
|
if (ok) item.bm.prTitle = desired;
|
|
220
222
|
return ok;
|
|
221
223
|
});
|
|
222
224
|
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
+
const updatedBranches = edits.filter((_, i) => oks[i]).map(({ item }) => item.name);
|
|
226
|
+
const updated = updatedBranches.length;
|
|
227
|
+
return { updated, updatedBranches, dirty: dirtyBackfill || updated > 0 };
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
export interface VizResult {
|
|
228
231
|
updated: number; // PRs whose description we PATCHed
|
|
232
|
+
updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
|
|
229
233
|
unchanged: number; // PRs skipped because their rendered block was identical
|
|
230
234
|
skipped: number; // branches with no PR
|
|
231
235
|
skippedBranches: string[]; // …and their names, so callers can say which
|
|
@@ -306,10 +310,11 @@ export async function refreshStackViz(
|
|
|
306
310
|
return ok;
|
|
307
311
|
});
|
|
308
312
|
|
|
309
|
-
const
|
|
313
|
+
const updatedBranches = toUpdate.filter((_, i) => oks[i]).map((plan) => plan.info.branch);
|
|
314
|
+
const updated = updatedBranches.length;
|
|
310
315
|
if (updated > 0) dirty = true;
|
|
311
316
|
|
|
312
|
-
return { updated, unchanged, skipped, skippedBranches, dirty };
|
|
317
|
+
return { updated, updatedBranches, unchanged, skipped, skippedBranches, dirty };
|
|
313
318
|
}
|
|
314
319
|
|
|
315
320
|
export default async function updatePrs(args: string[]): Promise<void> {
|