gh-stack 0.17.2 → 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/README.md +256 -13
- package/package.json +9 -8
- package/src/commands/add.ts +131 -3
- package/src/commands/config.ts +133 -0
- package/src/commands/init.ts +4 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +263 -5
- package/src/commands/restack.ts +954 -211
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +145 -145
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +87 -15
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +45 -9
- package/src/lib/chain.ts +20 -0
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +98 -22
- package/src/lib/github.ts +83 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +64 -14
- 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/using-gh-stack.md +140 -5
- package/src/types.ts +62 -2
package/src/commands/sync.ts
CHANGED
|
@@ -7,91 +7,132 @@ 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 restack, {
|
|
21
|
+
notePendingRun,
|
|
22
|
+
refuseRebaseInProgress,
|
|
23
|
+
resolvePush,
|
|
24
|
+
routePendingRun,
|
|
25
|
+
syncChain,
|
|
26
|
+
} from "./restack.ts";
|
|
20
27
|
|
|
21
28
|
export default async function sync(args: string[]): Promise<void> {
|
|
22
29
|
const dryRun = args.includes("--dry-run");
|
|
30
|
+
const fresh = args.includes("--fresh");
|
|
23
31
|
|
|
24
32
|
if (args.includes("--help")) {
|
|
25
33
|
console.log(`
|
|
26
34
|
gh-stack sync — Sync base with main + restack all
|
|
27
35
|
|
|
28
36
|
USAGE
|
|
29
|
-
gh-stack sync [--dry-run]
|
|
37
|
+
gh-stack sync [--dry-run] [--no-push | --push] [--fresh]
|
|
30
38
|
|
|
31
39
|
Fetches latest main, rebases the base branch onto main,
|
|
32
40
|
then restacks all children. Equivalent to running restack
|
|
33
41
|
with the base branch included.
|
|
42
|
+
|
|
43
|
+
OPTIONS
|
|
44
|
+
--dry-run Show what would happen without executing
|
|
45
|
+
--no-push Rebase locally only — never push or retarget PRs
|
|
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\`.
|
|
34
61
|
`);
|
|
35
62
|
return;
|
|
36
63
|
}
|
|
37
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
|
+
|
|
38
76
|
// Reject if a git rebase is already in progress. Letting sync run on top
|
|
39
77
|
// of a half-finished rebase produces deeply confusing state — make the
|
|
40
78
|
// user finish or abort it first.
|
|
41
79
|
if (await git.isRebaseInProgress()) {
|
|
42
|
-
|
|
43
|
-
`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")}.`,
|
|
44
|
-
);
|
|
45
|
-
process.exit(1);
|
|
80
|
+
await refuseRebaseInProgress("sync");
|
|
46
81
|
}
|
|
47
82
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
|
59
95
|
}
|
|
60
96
|
|
|
97
|
+
const push = await resolvePush(args);
|
|
98
|
+
const noPush = !push.value;
|
|
99
|
+
const { meta, stackName } = await syncPreconditions(args);
|
|
61
100
|
let stack = meta.stacks[stackName]!;
|
|
62
|
-
await ensureValidStack(meta, stackName);
|
|
63
101
|
let ordered = getOrderedBranches(stack);
|
|
64
102
|
|
|
65
|
-
if (ordered.length === 0) {
|
|
66
|
-
p.cancel("No branches in this stack");
|
|
67
|
-
process.exit(1);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
103
|
p.intro(pc.cyan("Git Stack Sync"));
|
|
71
104
|
|
|
72
105
|
// Find the root branch (whose parent is the stack's base)
|
|
73
106
|
const base = stackBase(stack);
|
|
74
|
-
const baseIsTrunk = base === "main" || base === "master";
|
|
75
|
-
|
|
76
|
-
// sync only makes sense for trunk-rooted stacks — it fetches main and
|
|
77
|
-
// rebases onto it. A split stack is rooted on a branch in another stack, so
|
|
78
|
-
// syncing it means restacking onto that base (handled by `restack`), or
|
|
79
|
-
// re-rooting onto main once the parent stack has merged.
|
|
80
|
-
if (!baseIsTrunk) {
|
|
81
|
-
p.cancel(
|
|
82
|
-
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
|
|
83
|
-
` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
|
|
84
|
-
` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
|
|
85
|
-
);
|
|
86
|
-
process.exit(1);
|
|
87
|
-
}
|
|
88
107
|
|
|
89
108
|
// Catch up to reality first: if the bottom PR(s) already landed on the trunk
|
|
90
109
|
// outside gh-stack (e.g. the GitHub web UI), the stack metadata still lists
|
|
91
110
|
// them. A plain rebase onto main would then replay their now-squashed commits
|
|
92
111
|
// and conflict — so advance past them (re-rooting the survivor onto main with
|
|
93
112
|
// `--onto`) before the normal sync runs. (#20)
|
|
94
|
-
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
|
+
);
|
|
95
136
|
if (advance.drained) {
|
|
96
137
|
p.outro(pc.green("Bottom PR(s) already merged — stack fully drained & archived."));
|
|
97
138
|
return;
|
|
@@ -149,77 +190,55 @@ with the base branch included.
|
|
|
149
190
|
// need to find when restacking children. (See resolveRestackBoundary.)
|
|
150
191
|
await takeSnapshot(meta, stackName, "sync");
|
|
151
192
|
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
console.log(pc.cyan("━".repeat(40)));
|
|
157
|
-
console.log();
|
|
158
|
-
|
|
159
|
-
// Check if already up to date
|
|
160
|
-
if (await git.isAncestor("origin/main", baseBranch)) {
|
|
161
|
-
p.log.success(`${baseBranch} is already up to date with main`);
|
|
162
|
-
} else if (dryRun) {
|
|
163
|
-
p.log.warn(`[DRY RUN] Would rebase ${baseBranch} onto origin/main`);
|
|
164
|
-
} else {
|
|
165
|
-
const confirmed = await confirmAction(`Rebase ${pc.yellow(baseBranch)} onto origin/main?`);
|
|
166
|
-
if (!confirmed) {
|
|
167
|
-
p.cancel("Cancelled");
|
|
168
|
-
process.exit(0);
|
|
169
|
-
}
|
|
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
|
+
}
|
|
170
197
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
p.log.info("Rebasing onto origin/main...");
|
|
184
|
-
const success = await git.rebase("origin/main");
|
|
185
|
-
|
|
186
|
-
if (!success) {
|
|
187
|
-
console.log();
|
|
188
|
-
p.log.error("Rebase conflict — resolve and run:");
|
|
189
|
-
console.log(` ${pc.green("git rebase --continue")}`);
|
|
190
|
-
console.log(` ${pc.green("gh-stack restack --resume")}`);
|
|
191
|
-
process.exit(2);
|
|
192
|
-
}
|
|
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();
|
|
193
209
|
|
|
194
|
-
|
|
210
|
+
const meta = await ensureMetadata();
|
|
211
|
+
const currentBranch = await git.currentBranch();
|
|
212
|
+
const stackName = findStackForBranch(meta, currentBranch);
|
|
195
213
|
|
|
196
|
-
|
|
197
|
-
|
|
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);
|
|
198
219
|
}
|
|
199
220
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
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);
|
|
206
226
|
}
|
|
207
227
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await git.checkout(children[0]!);
|
|
221
|
-
await restack(dryRun ? ["--dry-run"] : []);
|
|
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);
|
|
222
240
|
}
|
|
241
|
+
return { meta, stackName };
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
/** Result of the merged-bottom catch-up pass. */
|
|
@@ -241,19 +260,15 @@ interface AdvanceOutcome {
|
|
|
241
260
|
* Returns what happened so the caller can re-derive the root or short-circuit.
|
|
242
261
|
* In dry-run it reports and makes no changes.
|
|
243
262
|
*/
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
): Promise<
|
|
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> {
|
|
250
269
|
const stack = meta.stacks[stackName]!;
|
|
251
|
-
const ordered = getOrderedBranches(stack);
|
|
252
|
-
|
|
253
|
-
// Walk bottom-up gathering PR state; stop at the first branch that isn't a
|
|
254
|
-
// merged PR (only contiguous merged bottoms can be advanced past).
|
|
255
270
|
const reviews: BranchReview[] = [];
|
|
256
|
-
for (const branch of
|
|
271
|
+
for (const branch of getOrderedBranches(stack)) {
|
|
257
272
|
const pr = stack.branches[branch]?.pr ?? null;
|
|
258
273
|
let prState: string | null = null;
|
|
259
274
|
if (pr != null) {
|
|
@@ -263,8 +278,21 @@ async function advanceMergedBottoms(
|
|
|
263
278
|
reviews.push({ branch, pr, prState, reviewDecision: null });
|
|
264
279
|
if (prState !== "MERGED") break;
|
|
265
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);
|
|
266
295
|
|
|
267
|
-
const plan = planMerges(reviews, { approveAndMerge: false });
|
|
268
296
|
if (plan.steps.length === 0) {
|
|
269
297
|
return { detected: false, advanced: false, drained: false };
|
|
270
298
|
}
|
|
@@ -302,6 +330,8 @@ async function advanceMergedBottoms(
|
|
|
302
330
|
childPr,
|
|
303
331
|
targetRef,
|
|
304
332
|
recordedBase: trunk,
|
|
333
|
+
push: !noPush,
|
|
334
|
+
expect: verified.get(child),
|
|
305
335
|
});
|
|
306
336
|
if (!res.ok) {
|
|
307
337
|
console.log();
|
|
@@ -344,33 +374,3 @@ async function advanceMergedBottoms(
|
|
|
344
374
|
|
|
345
375
|
return { detected: true, advanced: true, drained: false };
|
|
346
376
|
}
|
|
347
|
-
|
|
348
|
-
async function promptForcePush(branch: string): Promise<void> {
|
|
349
|
-
if (branch === "main" || branch === "master") return;
|
|
350
|
-
if (!(await git.remoteBranchExists(branch))) return;
|
|
351
|
-
|
|
352
|
-
const localSha = await git.revParse(branch);
|
|
353
|
-
let remoteSha: string;
|
|
354
|
-
try {
|
|
355
|
-
remoteSha = await git.revParse(`origin/${branch}`);
|
|
356
|
-
} catch {
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
if (localSha === remoteSha) return;
|
|
361
|
-
|
|
362
|
-
console.log();
|
|
363
|
-
p.log.info(`${pc.yellow("⚠")} ${branch} is out of sync with remote`);
|
|
364
|
-
|
|
365
|
-
const confirmed = await confirmAction("Push with --force-with-lease?");
|
|
366
|
-
if (confirmed) {
|
|
367
|
-
const ok = await git.forcePushWithLease(branch);
|
|
368
|
-
if (ok) {
|
|
369
|
-
p.log.success("Pushed successfully");
|
|
370
|
-
} else {
|
|
371
|
-
p.log.error("Push failed");
|
|
372
|
-
}
|
|
373
|
-
} else {
|
|
374
|
-
p.log.warn("Skipping push");
|
|
375
|
-
}
|
|
376
|
-
}
|
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
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../lib/metadata.ts";
|
|
18
18
|
import { ensureMetadata } from "../lib/safety.ts";
|
|
19
19
|
import {
|
|
20
|
+
findOpenPrForBranch,
|
|
20
21
|
getPrInfo,
|
|
21
22
|
getPrBody,
|
|
22
23
|
updatePrBody,
|
|
@@ -102,6 +103,32 @@ function vizHash(viz: string): string {
|
|
|
102
103
|
return createHash("sha1").update(viz).digest("hex");
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Whether a live PR body already carries exactly this viz block. GitHub
|
|
108
|
+
* stores bodies edited in its web UI with CRLF line endings, so normalize
|
|
109
|
+
* before comparing — otherwise every such PR would look perpetually stale.
|
|
110
|
+
*/
|
|
111
|
+
export function bodyHasViz(body: string, viz: string): boolean {
|
|
112
|
+
return body.replace(/\r\n/g, "\n").includes(viz);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Record PR numbers for tracked branches whose PR was opened outside gh-stack
|
|
117
|
+
* (e.g. `gh pr create`), by looking up the open PR for each PR-less branch.
|
|
118
|
+
* Only an unambiguous single match is adopted. Returns the branches adopted —
|
|
119
|
+
* the caller persists metadata when non-empty. (#35)
|
|
120
|
+
*/
|
|
121
|
+
export async function discoverMissingPrs(stack: Stack, ordered: string[]): Promise<string[]> {
|
|
122
|
+
const missing = ordered.filter((b) => stack.branches[b]!.pr == null);
|
|
123
|
+
const found = await mapLimit(missing, 8, async (branch) => {
|
|
124
|
+
const pr = await findOpenPrForBranch(branch);
|
|
125
|
+
if (pr == null) return null;
|
|
126
|
+
stack.branches[branch]!.pr = pr;
|
|
127
|
+
return branch;
|
|
128
|
+
});
|
|
129
|
+
return found.filter((b): b is string => b != null);
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
/** Splice the rendered viz block into a PR body, replacing any existing one. */
|
|
106
133
|
function mergeViz(body: string, viz: string): string {
|
|
107
134
|
let base = body;
|
|
@@ -135,6 +162,7 @@ export function numberedTitle(title: string, position: number, total: number): s
|
|
|
135
162
|
|
|
136
163
|
export interface TitleResult {
|
|
137
164
|
updated: number; // PR titles we edited on GitHub
|
|
165
|
+
updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
|
|
138
166
|
dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
|
|
139
167
|
}
|
|
140
168
|
|
|
@@ -184,23 +212,27 @@ export async function reconcilePrTitles(
|
|
|
184
212
|
|
|
185
213
|
const items = ordered.map((name) => {
|
|
186
214
|
const bm = stack.branches[name]!;
|
|
187
|
-
return { pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
|
|
215
|
+
return { name, pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
|
|
188
216
|
});
|
|
189
217
|
|
|
190
|
-
const
|
|
218
|
+
const edits = planTitleEdits(items);
|
|
219
|
+
const oks = await mapLimit(edits, 8, async ({ item, desired }) => {
|
|
191
220
|
const ok = await updatePrTitle(item.pr!, desired);
|
|
192
221
|
if (ok) item.bm.prTitle = desired;
|
|
193
222
|
return ok;
|
|
194
223
|
});
|
|
195
224
|
|
|
196
|
-
const
|
|
197
|
-
|
|
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 };
|
|
198
228
|
}
|
|
199
229
|
|
|
200
230
|
export interface VizResult {
|
|
201
231
|
updated: number; // PRs whose description we PATCHed
|
|
232
|
+
updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
|
|
202
233
|
unchanged: number; // PRs skipped because their rendered block was identical
|
|
203
234
|
skipped: number; // branches with no PR
|
|
235
|
+
skippedBranches: string[]; // …and their names, so callers can say which
|
|
204
236
|
dirty: boolean; // metadata mutated (caller should persist)
|
|
205
237
|
}
|
|
206
238
|
|
|
@@ -213,12 +245,17 @@ export interface VizResult {
|
|
|
213
245
|
* body: the override is used as the base instead of the current GitHub body,
|
|
214
246
|
* the managed `📚 Stacked on` block is re-merged into it, and the PR is updated
|
|
215
247
|
* even if the viz block itself didn't change.
|
|
248
|
+
*
|
|
249
|
+
* `verifyLive` checks each PR whose cached hash matches against its actual
|
|
250
|
+
* body, so a viz block removed by an external edit (`gh pr edit`, the web UI)
|
|
251
|
+
* is restored rather than reported "unchanged". It costs one read per PR, so
|
|
252
|
+
* `update-prs` opts in while `submit` stays on the cheap hash check. (#34)
|
|
216
253
|
*/
|
|
217
254
|
export async function refreshStackViz(
|
|
218
255
|
meta: StackMetadata,
|
|
219
256
|
stack: Stack,
|
|
220
257
|
ordered: string[],
|
|
221
|
-
opts: { force?: boolean; bodyOverrides?: Record<string, string> } = {},
|
|
258
|
+
opts: { force?: boolean; verifyLive?: boolean; bodyOverrides?: Record<string, string> } = {},
|
|
222
259
|
): Promise<VizResult> {
|
|
223
260
|
const force = opts.force ?? false;
|
|
224
261
|
const bodyOverrides = opts.bodyOverrides ?? {};
|
|
@@ -239,28 +276,45 @@ export async function refreshStackViz(
|
|
|
239
276
|
const bm = stack.branches[info.branch]!;
|
|
240
277
|
// A body override forces the PATCH even when the viz block is unchanged.
|
|
241
278
|
const overridden = bodyOverrides[info.branch] != null;
|
|
242
|
-
|
|
279
|
+
const needsUpdate = force || overridden || bm.vizHash !== hash;
|
|
280
|
+
return { info, viz, hash, bm, needsUpdate, liveBody: null as string | null };
|
|
243
281
|
});
|
|
244
282
|
|
|
283
|
+
if (opts.verifyLive) {
|
|
284
|
+
await mapLimit(
|
|
285
|
+
plans.filter((plan) => !plan.needsUpdate),
|
|
286
|
+
8,
|
|
287
|
+
async (plan) => {
|
|
288
|
+
const body = await getPrBody(plan.info.prNumber!);
|
|
289
|
+
if (body !== null && !bodyHasViz(body, plan.viz)) {
|
|
290
|
+
plan.needsUpdate = true;
|
|
291
|
+
plan.liveBody = body;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
245
297
|
const toUpdate = plans.filter((plan) => plan.needsUpdate);
|
|
246
298
|
const unchanged = plans.length - toUpdate.length;
|
|
247
|
-
const
|
|
299
|
+
const skippedBranches = branchInfos.filter((b) => b.prNumber == null).map((b) => b.branch);
|
|
300
|
+
const skipped = skippedBranches.length;
|
|
248
301
|
|
|
249
302
|
const oks = await mapLimit(toUpdate, 8, async (plan) => {
|
|
250
303
|
// `submit -b` replaces the body; otherwise keep the PR's current body and
|
|
251
304
|
// just re-merge the viz block into it.
|
|
252
305
|
const override = bodyOverrides[plan.info.branch];
|
|
253
|
-
const body = override ?? (await getPrBody(plan.info.prNumber!));
|
|
306
|
+
const body = override ?? plan.liveBody ?? (await getPrBody(plan.info.prNumber!));
|
|
254
307
|
if (body === null) return false;
|
|
255
308
|
const ok = await updatePrBody(plan.info.prNumber!, mergeViz(body, plan.viz));
|
|
256
309
|
if (ok) plan.bm.vizHash = plan.hash;
|
|
257
310
|
return ok;
|
|
258
311
|
});
|
|
259
312
|
|
|
260
|
-
const
|
|
313
|
+
const updatedBranches = toUpdate.filter((_, i) => oks[i]).map((plan) => plan.info.branch);
|
|
314
|
+
const updated = updatedBranches.length;
|
|
261
315
|
if (updated > 0) dirty = true;
|
|
262
316
|
|
|
263
|
-
return { updated, unchanged, skipped, dirty };
|
|
317
|
+
return { updated, updatedBranches, unchanged, skipped, skippedBranches, dirty };
|
|
264
318
|
}
|
|
265
319
|
|
|
266
320
|
export default async function updatePrs(args: string[]): Promise<void> {
|
|
@@ -272,8 +326,13 @@ USAGE
|
|
|
272
326
|
gh-stack update-prs [--force]
|
|
273
327
|
|
|
274
328
|
Updates all PRs in the current stack with a standardized stack section
|
|
275
|
-
showing the tree structure, PR links, and per-PR stack index.
|
|
276
|
-
|
|
329
|
+
showing the tree structure, PR links, and per-PR stack index. Each PR's
|
|
330
|
+
live description is checked, so a stack section removed by an outside
|
|
331
|
+
edit (gh pr edit, the GitHub UI) is restored. PRs that already show the
|
|
332
|
+
current section are left alone; --force rewrites all.
|
|
333
|
+
|
|
334
|
+
Tracked branches with no recorded PR are looked up on GitHub first, so a
|
|
335
|
+
PR opened outside gh-stack (e.g. gh pr create) is adopted automatically.
|
|
277
336
|
`);
|
|
278
337
|
return;
|
|
279
338
|
}
|
|
@@ -298,17 +357,30 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
|
|
|
298
357
|
|
|
299
358
|
const s = p.spinner();
|
|
300
359
|
s.start("Updating PR titles & descriptions...");
|
|
360
|
+
const adopted = await discoverMissingPrs(stack, ordered);
|
|
301
361
|
const titles = await reconcilePrTitles(stack, ordered);
|
|
302
|
-
const result = await refreshStackViz(meta, stack, ordered, { force });
|
|
362
|
+
const result = await refreshStackViz(meta, stack, ordered, { force, verifyLive: true });
|
|
303
363
|
s.stop("Done");
|
|
304
364
|
|
|
305
|
-
if (titles.dirty || result.dirty) await writeMetadata(meta);
|
|
365
|
+
if (adopted.length > 0 || titles.dirty || result.dirty) await writeMetadata(meta);
|
|
366
|
+
|
|
367
|
+
for (const b of adopted) {
|
|
368
|
+
p.log.success(
|
|
369
|
+
`Adopted PR #${stack.branches[b]!.pr} for ${pc.blue(b)} (opened outside gh-stack)`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (result.skippedBranches.length > 0) {
|
|
373
|
+
p.log.warn(
|
|
374
|
+
`Skipped ${result.skippedBranches.length} branch(es) with no open PR: ${result.skippedBranches.map((b) => pc.blue(b)).join(", ")}\n` +
|
|
375
|
+
` Open them with ${pc.green("gh-stack submit")}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
306
378
|
|
|
307
379
|
console.log();
|
|
308
380
|
p.outro(
|
|
309
381
|
pc.green(
|
|
310
382
|
`Updated ${result.updated} description(s), ${titles.updated} title(s)` +
|
|
311
|
-
(result.unchanged > 0 ? `, ${result.unchanged}
|
|
383
|
+
(result.unchanged > 0 ? `, ${result.unchanged} already up to date` : "") +
|
|
312
384
|
(result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
|
|
313
385
|
),
|
|
314
386
|
);
|