gh-stack 0.7.1 → 0.9.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 +91 -24
- package/package.json +1 -1
- package/src/commands/doctor.ts +268 -0
- package/src/commands/down.ts +14 -6
- package/src/commands/init.ts +4 -1
- package/src/commands/log.ts +2 -0
- package/src/commands/merge.ts +19 -1
- package/src/commands/restack.ts +270 -44
- package/src/commands/split.ts +159 -0
- package/src/commands/status.ts +7 -0
- package/src/commands/submit.ts +30 -88
- package/src/commands/sync.ts +24 -4
- package/src/commands/update-prs.ts +201 -100
- package/src/index.ts +15 -0
- package/src/lib/branch-config.ts +126 -0
- package/src/lib/chain.ts +83 -50
- package/src/lib/git.ts +11 -0
- package/src/lib/github.ts +24 -0
- package/src/lib/metadata.ts +246 -60
- package/src/lib/paths.ts +69 -0
- package/src/lib/safety.ts +33 -7
- package/src/lib/snapshot.ts +83 -14
- package/src/lib/ui.ts +5 -2
- package/src/types.ts +21 -2
package/src/commands/submit.ts
CHANGED
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
} from "../lib/metadata.ts";
|
|
12
12
|
import { isAutoYes } from "../lib/ui.ts";
|
|
13
13
|
import type { StackMetadata } from "../types.ts";
|
|
14
|
-
import { getPrNumber
|
|
14
|
+
import { getPrNumber } from "../lib/github.ts";
|
|
15
15
|
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { refreshStackViz, branchToTitle } from "./update-prs.ts";
|
|
17
17
|
|
|
18
18
|
const HELP = `
|
|
19
19
|
gh-stack submit — Push branches and create/update PRs
|
|
@@ -51,15 +51,6 @@ EXAMPLES
|
|
|
51
51
|
* Strips common prefixes like "kiliman/", replaces hyphens with spaces,
|
|
52
52
|
* and capitalizes the first letter.
|
|
53
53
|
*/
|
|
54
|
-
function branchToTitle(branch: string): string {
|
|
55
|
-
// Strip common prefixes like "kiliman/", "user/"
|
|
56
|
-
let name = branch.includes("/") ? branch.split("/").slice(1).join("/") : branch;
|
|
57
|
-
// Replace hyphens with spaces
|
|
58
|
-
name = name.replace(/-/g, " ");
|
|
59
|
-
// Capitalize first letter
|
|
60
|
-
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
54
|
/**
|
|
64
55
|
* Check if a local branch is up-to-date with its remote counterpart.
|
|
65
56
|
*/
|
|
@@ -136,7 +127,7 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
136
127
|
// Reconstruct the stack from local branch ancestry so the end state is
|
|
137
128
|
// always stack registered + branches pushed + PRs created — no need to
|
|
138
129
|
// remember to run `gh-stack init` first.
|
|
139
|
-
let heal: {
|
|
130
|
+
let heal: { base: string; chain: string[]; created: boolean; added: string[] } | null = null;
|
|
140
131
|
if (!stackName) {
|
|
141
132
|
const trunk = await git.trunkBranch();
|
|
142
133
|
|
|
@@ -157,7 +148,7 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
157
148
|
|
|
158
149
|
stackName = resolution.stackName;
|
|
159
150
|
heal = {
|
|
160
|
-
|
|
151
|
+
base: resolution.base,
|
|
161
152
|
chain: resolution.chain,
|
|
162
153
|
created: resolution.created,
|
|
163
154
|
added: resolution.addedBranches,
|
|
@@ -183,13 +174,15 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
183
174
|
`No stack found — created ${pc.yellow(stackName)} from ${heal.chain.length} local branch(es)`,
|
|
184
175
|
);
|
|
185
176
|
} else if (heal.added.length > 0) {
|
|
177
|
+
const baseNote =
|
|
178
|
+
heal.base === "main" || heal.base === "master" ? "" : ` (base: ${heal.base})`;
|
|
186
179
|
p.log.success(
|
|
187
|
-
`
|
|
180
|
+
`Adopted ${heal.added.length} untracked branch(es) into ${pc.yellow(stackName)}${pc.dim(baseNote)}`,
|
|
188
181
|
);
|
|
189
182
|
}
|
|
190
183
|
|
|
191
184
|
console.log();
|
|
192
|
-
console.log(` ${pc.dim(heal.
|
|
185
|
+
console.log(` ${pc.dim(heal.base)}`);
|
|
193
186
|
for (let i = 0; i < heal.chain.length; i++) {
|
|
194
187
|
const b = heal.chain[i]!;
|
|
195
188
|
const isLast = i === heal.chain.length - 1;
|
|
@@ -256,16 +249,21 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
256
249
|
}
|
|
257
250
|
|
|
258
251
|
if (prNumber) {
|
|
259
|
-
// ── Existing PR: update base if
|
|
252
|
+
// ── Existing PR: update base only if the parent actually changed ──
|
|
260
253
|
existing++;
|
|
261
254
|
if (dryRun) {
|
|
262
255
|
p.log.step(`${pc.dim("exists")} PR #${prNumber} for ${branchName}`);
|
|
263
256
|
} else {
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
257
|
+
// Skip the network round-trip when the base we last set still matches
|
|
258
|
+
// the current parent — the common case on a re-submit (issue #16).
|
|
259
|
+
if (branchMeta.prBase !== parentBranch) {
|
|
260
|
+
try {
|
|
261
|
+
await $`gh pr edit ${prNumber} --base ${parentBranch}`.quiet();
|
|
262
|
+
branchMeta.prBase = parentBranch;
|
|
263
|
+
metadataChanged = true;
|
|
264
|
+
} catch {
|
|
265
|
+
// Best effort — base might already be correct
|
|
266
|
+
}
|
|
269
267
|
}
|
|
270
268
|
p.log.info(`${pc.dim("✓")} PR #${prNumber} for ${pc.blue(branchName)}`);
|
|
271
269
|
}
|
|
@@ -327,6 +325,8 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
327
325
|
|
|
328
326
|
if (newPrNumber) {
|
|
329
327
|
branchMeta.pr = newPrNumber;
|
|
328
|
+
branchMeta.prTitle = title;
|
|
329
|
+
branchMeta.prBase = parentBranch;
|
|
330
330
|
metadataChanged = true;
|
|
331
331
|
s.stop(`Created PR #${newPrNumber} for ${pc.blue(branchName)}`);
|
|
332
332
|
} else {
|
|
@@ -342,11 +342,6 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// Save metadata if we discovered or created PRs
|
|
346
|
-
if (metadataChanged && !dryRun) {
|
|
347
|
-
await writeMetadata(meta);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
345
|
console.log();
|
|
351
346
|
|
|
352
347
|
if (dryRun) {
|
|
@@ -358,76 +353,23 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
358
353
|
return;
|
|
359
354
|
}
|
|
360
355
|
|
|
361
|
-
// ── Phase 2:
|
|
356
|
+
// ── Phase 2: Refresh the stack visualization (renders locally, PATCHes only
|
|
357
|
+
// the PRs whose block actually changed; see issue #16). ──
|
|
362
358
|
p.log.info(pc.cyan("Updating stack visualization..."));
|
|
359
|
+
const viz = await refreshStackViz(meta, stack, ordered);
|
|
363
360
|
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
prTitle: string;
|
|
369
|
-
prUrl: string | null;
|
|
370
|
-
reviewEmojiStr: string;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const branchInfos: BranchPrInfo[] = [];
|
|
374
|
-
|
|
375
|
-
for (const branchName of ordered) {
|
|
376
|
-
const branchMeta = stack.branches[branchName]!;
|
|
377
|
-
const prNum = branchMeta.pr ?? null;
|
|
378
|
-
let prTitle = branchMeta.description || branchName;
|
|
379
|
-
let prUrl: string | null = null;
|
|
380
|
-
let review = "PENDING";
|
|
381
|
-
|
|
382
|
-
if (prNum) {
|
|
383
|
-
const info = await getPrInfo(prNum);
|
|
384
|
-
if (info) {
|
|
385
|
-
prTitle = info.title || prTitle;
|
|
386
|
-
prUrl = info.url;
|
|
387
|
-
review = info.reviewDecision || "PENDING";
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
branchInfos.push({
|
|
392
|
-
branch: branchName,
|
|
393
|
-
prNumber: prNum,
|
|
394
|
-
prTitle,
|
|
395
|
-
prUrl,
|
|
396
|
-
reviewEmojiStr: reviewEmoji(review),
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Update each PR that has a number
|
|
401
|
-
let vizUpdated = 0;
|
|
402
|
-
|
|
403
|
-
for (let i = 0; i < branchInfos.length; i++) {
|
|
404
|
-
const info = branchInfos[i]!;
|
|
405
|
-
if (!info.prNumber) continue;
|
|
406
|
-
|
|
407
|
-
const stackViz = buildStackViz(branchInfos, i);
|
|
408
|
-
|
|
409
|
-
const currentBody = await getPrBody(info.prNumber);
|
|
410
|
-
if (currentBody === null) continue;
|
|
411
|
-
|
|
412
|
-
// Remove existing stack section
|
|
413
|
-
let updatedBody = currentBody;
|
|
414
|
-
const stackSectionIdx = updatedBody.indexOf("### 📚 Stacked on");
|
|
415
|
-
if (stackSectionIdx !== -1) {
|
|
416
|
-
updatedBody = updatedBody.slice(0, stackSectionIdx).trimEnd();
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Append new stack section
|
|
420
|
-
const newBody = updatedBody ? `${updatedBody}\n\n${stackViz}` : stackViz;
|
|
421
|
-
|
|
422
|
-
const ok = await updatePrBody(info.prNumber, newBody);
|
|
423
|
-
if (ok) vizUpdated++;
|
|
361
|
+
// One persist for everything we cached this run (discovered PRs, base edits,
|
|
362
|
+
// backfilled titles, viz hashes).
|
|
363
|
+
if (metadataChanged || viz.dirty) {
|
|
364
|
+
await writeMetadata(meta);
|
|
424
365
|
}
|
|
425
366
|
|
|
426
367
|
// ── Summary ──
|
|
427
368
|
console.log();
|
|
428
369
|
p.outro(
|
|
429
370
|
pc.green(
|
|
430
|
-
`Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${
|
|
371
|
+
`Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s)` +
|
|
372
|
+
(viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
|
|
431
373
|
),
|
|
432
374
|
);
|
|
433
375
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
findStackForBranch,
|
|
7
|
+
getOrderedBranches,
|
|
8
|
+
saveRestackState,
|
|
9
|
+
stackBase,
|
|
10
|
+
} from "../lib/metadata.ts";
|
|
6
11
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
7
12
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
8
13
|
import { confirmAction } from "../lib/ui.ts";
|
|
@@ -56,12 +61,27 @@ with the base branch included.
|
|
|
56
61
|
|
|
57
62
|
p.intro(pc.cyan("Git Stack Sync"));
|
|
58
63
|
|
|
59
|
-
// Find the
|
|
64
|
+
// Find the root branch (whose parent is the stack's base)
|
|
65
|
+
const base = stackBase(stack);
|
|
66
|
+
const baseIsTrunk = base === "main" || base === "master";
|
|
60
67
|
const baseBranch = ordered[0]!;
|
|
61
68
|
const baseParent = stack.branches[baseBranch]?.parent;
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
// sync only makes sense for trunk-rooted stacks — it fetches main and
|
|
71
|
+
// rebases onto it. A split stack is rooted on a branch in another stack, so
|
|
72
|
+
// syncing it means restacking onto that base (handled by `restack`), or
|
|
73
|
+
// re-rooting onto main once the parent stack has merged.
|
|
74
|
+
if (!baseIsTrunk) {
|
|
75
|
+
p.cancel(
|
|
76
|
+
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
|
|
77
|
+
` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
|
|
78
|
+
` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
|
|
79
|
+
);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (baseParent !== base) {
|
|
84
|
+
p.cancel(`Root branch ${pc.yellow(baseBranch)} doesn't have ${base} as parent`);
|
|
65
85
|
process.exit(1);
|
|
66
86
|
}
|
|
67
87
|
|
|
@@ -1,11 +1,162 @@
|
|
|
1
1
|
// gh-stack update-prs — Update PR descriptions with stack visualization
|
|
2
|
-
//
|
|
2
|
+
//
|
|
3
|
+
// The visualization is rendered entirely from local metadata (PR number,
|
|
4
|
+
// cached title, stack position) plus a single repo-identity lookup for URLs —
|
|
5
|
+
// no per-PR API call. `submit` reuses `refreshStackViz` and skips PATCHing any
|
|
6
|
+
// PR whose rendered block hasn't changed (see issue #16). Review/CI status is
|
|
7
|
+
// intentionally NOT shown here; that's tracked out-of-band.
|
|
3
8
|
import * as p from "../lib/output.ts";
|
|
4
9
|
import pc from "picocolors";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
5
11
|
import * as git from "../lib/git.ts";
|
|
6
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
findStackForBranch,
|
|
14
|
+
getOrderedBranches,
|
|
15
|
+
stackBase,
|
|
16
|
+
writeMetadata,
|
|
17
|
+
} from "../lib/metadata.ts";
|
|
7
18
|
import { ensureMetadata } from "../lib/safety.ts";
|
|
8
|
-
import { getPrInfo, getPrBody, updatePrBody,
|
|
19
|
+
import { getPrInfo, getPrBody, updatePrBody, getRepoNwo, prUrlFor } from "../lib/github.ts";
|
|
20
|
+
import type { StackMetadata, Stack } from "../types.ts";
|
|
21
|
+
|
|
22
|
+
/** The ref a stack is rooted on, for rendering the base node of the viz. */
|
|
23
|
+
export interface BaseRef {
|
|
24
|
+
label: string; // "main" or a branch name (for split stacks)
|
|
25
|
+
prNumber?: number | null;
|
|
26
|
+
prUrl?: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BranchPrInfo {
|
|
30
|
+
branch: string;
|
|
31
|
+
prNumber: number | null;
|
|
32
|
+
prTitle: string;
|
|
33
|
+
prUrl: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Derive a readable PR title from a branch name (fallback when uncached). */
|
|
37
|
+
export function branchToTitle(branch: string): string {
|
|
38
|
+
let name = branch.includes("/") ? branch.split("/").slice(1).join("/") : branch;
|
|
39
|
+
name = name.replace(/-/g, " ");
|
|
40
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the base node for a stack's visualization. For a split stack whose
|
|
45
|
+
* base is a branch in another stack, link to that branch's PR. The URL is
|
|
46
|
+
* constructed locally from the repo identity — no API call.
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveBaseRef(
|
|
49
|
+
meta: StackMetadata,
|
|
50
|
+
stack: Stack,
|
|
51
|
+
nwo: string | null,
|
|
52
|
+
): Promise<BaseRef> {
|
|
53
|
+
const base = stackBase(stack);
|
|
54
|
+
if (base === "main" || base === "master") return { label: base };
|
|
55
|
+
|
|
56
|
+
const ownerName = findStackForBranch(meta, base);
|
|
57
|
+
const prNumber = ownerName ? (meta.stacks[ownerName]?.branches[base]?.pr ?? null) : null;
|
|
58
|
+
return { label: base, prNumber, prUrl: prNumber ? prUrlFor(nwo, prNumber) : null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a BranchPrInfo from metadata alone (no network). */
|
|
62
|
+
function toBranchInfo(stack: Stack, branch: string, nwo: string | null): BranchPrInfo {
|
|
63
|
+
const bm = stack.branches[branch]!;
|
|
64
|
+
const prNumber = bm.pr ?? null;
|
|
65
|
+
return {
|
|
66
|
+
branch,
|
|
67
|
+
prNumber,
|
|
68
|
+
prTitle: bm.prTitle || bm.description || branchToTitle(branch),
|
|
69
|
+
prUrl: prNumber ? prUrlFor(nwo, prNumber) : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fill in missing cached PR titles (one-time, e.g. right after migrating to v3
|
|
75
|
+
* or adopting existing PRs). Fetches only the branches that lack a title, in
|
|
76
|
+
* parallel; subsequent submits read straight from the cache. Returns true if
|
|
77
|
+
* any title was written.
|
|
78
|
+
*/
|
|
79
|
+
async function backfillTitles(stack: Stack, ordered: string[]): Promise<boolean> {
|
|
80
|
+
const missing = ordered.filter((b) => {
|
|
81
|
+
const bm = stack.branches[b]!;
|
|
82
|
+
return bm.pr != null && !bm.prTitle;
|
|
83
|
+
});
|
|
84
|
+
if (missing.length === 0) return false;
|
|
85
|
+
|
|
86
|
+
await mapLimit(missing, 8, async (branch) => {
|
|
87
|
+
const bm = stack.branches[branch]!;
|
|
88
|
+
const info = await getPrInfo(bm.pr!);
|
|
89
|
+
if (info?.title) bm.prTitle = info.title;
|
|
90
|
+
});
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function vizHash(viz: string): string {
|
|
95
|
+
return createHash("sha1").update(viz).digest("hex");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Splice the rendered viz block into a PR body, replacing any existing one. */
|
|
99
|
+
function mergeViz(body: string, viz: string): string {
|
|
100
|
+
let base = body;
|
|
101
|
+
const idx = base.indexOf("### 📚 Stacked on");
|
|
102
|
+
if (idx !== -1) base = base.slice(0, idx).trimEnd();
|
|
103
|
+
return base ? `${base}\n\n${viz}` : viz;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface VizResult {
|
|
107
|
+
updated: number; // PRs whose description we PATCHed
|
|
108
|
+
unchanged: number; // PRs skipped because their rendered block was identical
|
|
109
|
+
skipped: number; // branches with no PR
|
|
110
|
+
dirty: boolean; // metadata mutated (caller should persist)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Refresh the stack visualization across a stack's PRs. Renders every block
|
|
115
|
+
* locally, then PATCHes only the PRs whose block actually changed (unless
|
|
116
|
+
* `force`), running the surviving PATCHes concurrently.
|
|
117
|
+
*/
|
|
118
|
+
export async function refreshStackViz(
|
|
119
|
+
meta: StackMetadata,
|
|
120
|
+
stack: Stack,
|
|
121
|
+
ordered: string[],
|
|
122
|
+
opts: { force?: boolean } = {},
|
|
123
|
+
): Promise<VizResult> {
|
|
124
|
+
const force = opts.force ?? false;
|
|
125
|
+
const nwo = await getRepoNwo();
|
|
126
|
+
|
|
127
|
+
let dirty = await backfillTitles(stack, ordered);
|
|
128
|
+
|
|
129
|
+
const branchInfos = ordered.map((b) => toBranchInfo(stack, b, nwo));
|
|
130
|
+
const baseRef = await resolveBaseRef(meta, stack, nwo);
|
|
131
|
+
|
|
132
|
+
// Render every block locally and decide what needs writing — no network yet.
|
|
133
|
+
const plans = branchInfos
|
|
134
|
+
.map((info, i) => ({ info, i }))
|
|
135
|
+
.filter(({ info }) => info.prNumber != null)
|
|
136
|
+
.map(({ info, i }) => {
|
|
137
|
+
const viz = buildStackViz(branchInfos, i, baseRef);
|
|
138
|
+
const hash = vizHash(viz);
|
|
139
|
+
const bm = stack.branches[info.branch]!;
|
|
140
|
+
return { info, viz, hash, bm, needsUpdate: force || bm.vizHash !== hash };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const toUpdate = plans.filter((plan) => plan.needsUpdate);
|
|
144
|
+
const unchanged = plans.length - toUpdate.length;
|
|
145
|
+
const skipped = branchInfos.length - plans.length;
|
|
146
|
+
|
|
147
|
+
const oks = await mapLimit(toUpdate, 8, async (plan) => {
|
|
148
|
+
const body = await getPrBody(plan.info.prNumber!);
|
|
149
|
+
if (body === null) return false;
|
|
150
|
+
const ok = await updatePrBody(plan.info.prNumber!, mergeViz(body, plan.viz));
|
|
151
|
+
if (ok) plan.bm.vizHash = plan.hash;
|
|
152
|
+
return ok;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const updated = oks.filter(Boolean).length;
|
|
156
|
+
if (updated > 0) dirty = true;
|
|
157
|
+
|
|
158
|
+
return { updated, unchanged, skipped, dirty };
|
|
159
|
+
}
|
|
9
160
|
|
|
10
161
|
export default async function updatePrs(args: string[]): Promise<void> {
|
|
11
162
|
if (args.includes("--help")) {
|
|
@@ -13,14 +164,16 @@ export default async function updatePrs(args: string[]): Promise<void> {
|
|
|
13
164
|
gh-stack update-prs — Update PR descriptions with stack visualization
|
|
14
165
|
|
|
15
166
|
USAGE
|
|
16
|
-
gh-stack update-prs
|
|
167
|
+
gh-stack update-prs [--force]
|
|
17
168
|
|
|
18
169
|
Updates all PRs in the current stack with a standardized stack section
|
|
19
|
-
showing the tree structure, PR links, and
|
|
170
|
+
showing the tree structure, PR links, and per-PR stack index. By default
|
|
171
|
+
PRs whose rendered block is unchanged are skipped; --force rewrites all.
|
|
20
172
|
`);
|
|
21
173
|
return;
|
|
22
174
|
}
|
|
23
175
|
|
|
176
|
+
const force = args.includes("--force");
|
|
24
177
|
const meta = await ensureMetadata();
|
|
25
178
|
const branch = await git.currentBranch();
|
|
26
179
|
const stackName = findStackForBranch(meta, branch);
|
|
@@ -38,117 +191,43 @@ showing the tree structure, PR links, and review status.
|
|
|
38
191
|
p.log.info(`Found ${ordered.length} branch(es) in stack`);
|
|
39
192
|
console.log();
|
|
40
193
|
|
|
41
|
-
// Collect PR info for all branches
|
|
42
194
|
const s = p.spinner();
|
|
43
|
-
s.start("
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
branch: string;
|
|
47
|
-
prNumber: number | null;
|
|
48
|
-
prTitle: string;
|
|
49
|
-
prUrl: string | null;
|
|
50
|
-
reviewEmojiStr: string;
|
|
51
|
-
}
|
|
195
|
+
s.start("Updating PR descriptions...");
|
|
196
|
+
const result = await refreshStackViz(meta, stack, ordered, { force });
|
|
197
|
+
s.stop("Done");
|
|
52
198
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for (const branchName of ordered) {
|
|
56
|
-
const branchMeta = stack.branches[branchName]!;
|
|
57
|
-
const prNumber = branchMeta.pr ?? null;
|
|
58
|
-
let prTitle = branchMeta.description || branchName;
|
|
59
|
-
let prUrl: string | null = null;
|
|
60
|
-
let review = "PENDING";
|
|
61
|
-
|
|
62
|
-
if (prNumber) {
|
|
63
|
-
const info = await getPrInfo(prNumber);
|
|
64
|
-
if (info) {
|
|
65
|
-
prTitle = info.title || prTitle;
|
|
66
|
-
prUrl = info.url;
|
|
67
|
-
review = info.reviewDecision || "PENDING";
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
branchInfos.push({
|
|
72
|
-
branch: branchName,
|
|
73
|
-
prNumber,
|
|
74
|
-
prTitle,
|
|
75
|
-
prUrl,
|
|
76
|
-
reviewEmojiStr: reviewEmoji(review),
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
s.stop("Fetched PR info");
|
|
81
|
-
|
|
82
|
-
// Update each PR
|
|
83
|
-
let updated = 0;
|
|
84
|
-
let skipped = 0;
|
|
85
|
-
|
|
86
|
-
for (let targetIdx = 0; targetIdx < branchInfos.length; targetIdx++) {
|
|
87
|
-
const target = branchInfos[targetIdx]!;
|
|
88
|
-
|
|
89
|
-
if (!target.prNumber) {
|
|
90
|
-
p.log.info(`${pc.dim("⏭")} ${target.branch} — no PR, skipping`);
|
|
91
|
-
skipped++;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Build stack visualization for this PR
|
|
96
|
-
const stackViz = buildStackViz(branchInfos, targetIdx);
|
|
97
|
-
|
|
98
|
-
// Get current PR body and replace/append stack section
|
|
99
|
-
const currentBody = await getPrBody(target.prNumber);
|
|
100
|
-
if (currentBody === null) {
|
|
101
|
-
p.log.warn(`Could not fetch body for PR #${target.prNumber}`);
|
|
102
|
-
skipped++;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Remove existing stack section
|
|
107
|
-
let updatedBody = currentBody;
|
|
108
|
-
const stackSectionIdx = updatedBody.indexOf("### 📚 Stacked on");
|
|
109
|
-
if (stackSectionIdx !== -1) {
|
|
110
|
-
updatedBody = updatedBody.slice(0, stackSectionIdx).trimEnd();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Append new stack section
|
|
114
|
-
const newBody = updatedBody ? `${updatedBody}\n\n${stackViz}` : stackViz;
|
|
115
|
-
|
|
116
|
-
// Update PR
|
|
117
|
-
const ok = await updatePrBody(target.prNumber, newBody);
|
|
118
|
-
if (ok) {
|
|
119
|
-
p.log.success(`PR #${target.prNumber} ${target.prTitle}`);
|
|
120
|
-
updated++;
|
|
121
|
-
} else {
|
|
122
|
-
p.log.error(`Failed to update PR #${target.prNumber}`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
199
|
+
if (result.dirty) await writeMetadata(meta);
|
|
125
200
|
|
|
126
201
|
console.log();
|
|
127
202
|
p.outro(
|
|
128
203
|
pc.green(
|
|
129
|
-
`
|
|
204
|
+
`Updated ${result.updated} PR(s)` +
|
|
205
|
+
(result.unchanged > 0 ? `, ${result.unchanged} unchanged` : "") +
|
|
206
|
+
(result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
|
|
130
207
|
),
|
|
131
208
|
);
|
|
132
209
|
}
|
|
133
210
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
reviewEmojiStr: string;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function buildStackViz(branches: BranchPrInfo[], targetIndex: number): string {
|
|
211
|
+
export function buildStackViz(
|
|
212
|
+
branches: BranchPrInfo[],
|
|
213
|
+
targetIndex: number,
|
|
214
|
+
base: BaseRef = { label: "main" },
|
|
215
|
+
): string {
|
|
143
216
|
const lines: string[] = ["### 📚 Stacked on", ""];
|
|
144
217
|
|
|
218
|
+
// Base node: link to the base PR if there is one (split stacks), else the
|
|
219
|
+
// plain ref name. `**bold**` renders in markdown but not inside <pre>.
|
|
220
|
+
const baseLink = base.prNumber
|
|
221
|
+
? `${base.prUrl ? `<a href="${base.prUrl}">#${base.prNumber}</a>` : `#${base.prNumber}`} ${base.label}`
|
|
222
|
+
: null;
|
|
223
|
+
|
|
145
224
|
if (branches.length === 1) {
|
|
146
|
-
lines.push(
|
|
225
|
+
lines.push(`- ⚫ ${baseLink ?? `**${base.label}**`}`);
|
|
147
226
|
return lines.join("\n");
|
|
148
227
|
}
|
|
149
228
|
|
|
150
229
|
lines.push("<pre>");
|
|
151
|
-
lines.push(
|
|
230
|
+
lines.push(`⚫ ${baseLink ?? base.label}`);
|
|
152
231
|
lines.push("┃");
|
|
153
232
|
|
|
154
233
|
for (let i = 0; i < branches.length; i++) {
|
|
@@ -170,7 +249,9 @@ export function buildStackViz(branches: BranchPrInfo[], targetIndex: number): st
|
|
|
170
249
|
// Tree character
|
|
171
250
|
const tree = isLast ? "┗━" : "┣━";
|
|
172
251
|
|
|
173
|
-
|
|
252
|
+
// Leading `N.` is the branch's 1-based position in the stack — makes it
|
|
253
|
+
// easy to tell which PR is #6 in a 12-PR stack at a glance.
|
|
254
|
+
lines.push(`${tree} ${i + 1}. ${prLink} ${info.prTitle}${marker}`);
|
|
174
255
|
|
|
175
256
|
if (!isLast) {
|
|
176
257
|
lines.push("┃");
|
|
@@ -180,3 +261,23 @@ export function buildStackViz(branches: BranchPrInfo[], targetIndex: number): st
|
|
|
180
261
|
lines.push("</pre>");
|
|
181
262
|
return lines.join("\n");
|
|
182
263
|
}
|
|
264
|
+
|
|
265
|
+
/** Run `fn` over `items` with at most `limit` concurrent in flight. */
|
|
266
|
+
async function mapLimit<T, R>(
|
|
267
|
+
items: T[],
|
|
268
|
+
limit: number,
|
|
269
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
270
|
+
): Promise<R[]> {
|
|
271
|
+
const results = Array.from<R>({ length: items.length });
|
|
272
|
+
let next = 0;
|
|
273
|
+
async function worker(): Promise<void> {
|
|
274
|
+
while (true) {
|
|
275
|
+
const i = next++;
|
|
276
|
+
if (i >= items.length) return;
|
|
277
|
+
results[i] = await fn(items[i]!, i);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
281
|
+
await Promise.all(workers);
|
|
282
|
+
return results;
|
|
283
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,10 @@ switch (command) {
|
|
|
69
69
|
await (await import("./commands/delete.ts")).default(commandArgs);
|
|
70
70
|
break;
|
|
71
71
|
|
|
72
|
+
case "split":
|
|
73
|
+
await (await import("./commands/split.ts")).default(commandArgs);
|
|
74
|
+
break;
|
|
75
|
+
|
|
72
76
|
case "checkout":
|
|
73
77
|
case "co":
|
|
74
78
|
await (await import("./commands/checkout.ts")).default(commandArgs);
|
|
@@ -103,6 +107,14 @@ switch (command) {
|
|
|
103
107
|
await (await import("./commands/undo.ts")).default(commandArgs);
|
|
104
108
|
break;
|
|
105
109
|
|
|
110
|
+
case "doctor":
|
|
111
|
+
await (await import("./commands/doctor.ts")).default(commandArgs);
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case "update-prs":
|
|
115
|
+
await (await import("./commands/update-prs.ts")).default(commandArgs);
|
|
116
|
+
break;
|
|
117
|
+
|
|
106
118
|
case "archive":
|
|
107
119
|
await (await import("./commands/archive.ts")).default(commandArgs);
|
|
108
120
|
break;
|
|
@@ -165,12 +177,15 @@ ${bold("STACK MANAGEMENT")}
|
|
|
165
177
|
${green("restack")} Rebase children onto updated parents
|
|
166
178
|
${green("sync")} Sync base with main + restack all
|
|
167
179
|
${green("merge")} Local squash-merge top-down
|
|
180
|
+
${green("split")} Cut a stack into two at a branch
|
|
168
181
|
${green("delete")} Remove a branch from the stack
|
|
169
182
|
|
|
170
183
|
${bold("INFO & MAINTENANCE")}
|
|
171
184
|
${green("status")} PR dashboard (CI, reviews)
|
|
172
185
|
${green("undo")} Restore from last snapshot
|
|
173
186
|
${green("archive")} Manage archived stacks
|
|
187
|
+
${green("update-prs")} Refresh stack visualization in PR descriptions
|
|
188
|
+
${green("doctor")} Migrate & repair stack metadata
|
|
174
189
|
|
|
175
190
|
${bold("GLOBAL OPTIONS")}
|
|
176
191
|
--yes, -y Skip confirmations ${dim("(for agents/CI)")}
|