gh-stack 0.7.1 → 0.10.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.
@@ -1,11 +1,162 @@
1
1
  // gh-stack update-prs — Update PR descriptions with stack visualization
2
- // Port of reference/gh-stack-update-pr.sh
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 { findStackForBranch, getOrderedBranches } from "../lib/metadata.ts";
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, reviewEmoji } from "../lib/github.ts";
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 review status.
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("Fetching PR info from GitHub...");
44
-
45
- interface BranchPrInfo {
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
- const branchInfos: BranchPrInfo[] = [];
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
- `Done! Updated ${updated} PR(s)${skipped > 0 ? `, skipped ${skipped} without PRs` : ""}`,
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
- interface BranchPrInfo {
135
- branch: string;
136
- prNumber: number | null;
137
- prTitle: string;
138
- prUrl: string | null;
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("- ⚫ **main**");
225
+ lines.push(`- ⚫ ${baseLink ?? `**${base.label}**`}`);
147
226
  return lines.join("\n");
148
227
  }
149
228
 
150
229
  lines.push("<pre>");
151
- lines.push("⚫ main");
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
- lines.push(`${tree} ${info.reviewEmojiStr} ${prLink} ${info.prTitle}${marker}`);
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,14 @@ 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
+
76
+ case "rename":
77
+ await (await import("./commands/rename.ts")).default(commandArgs);
78
+ break;
79
+
72
80
  case "checkout":
73
81
  case "co":
74
82
  await (await import("./commands/checkout.ts")).default(commandArgs);
@@ -103,6 +111,18 @@ switch (command) {
103
111
  await (await import("./commands/undo.ts")).default(commandArgs);
104
112
  break;
105
113
 
114
+ case "doctor":
115
+ await (await import("./commands/doctor.ts")).default(commandArgs);
116
+ break;
117
+
118
+ case "update-prs":
119
+ await (await import("./commands/update-prs.ts")).default(commandArgs);
120
+ break;
121
+
122
+ case "stacks":
123
+ await (await import("./commands/stacks.ts")).default(commandArgs);
124
+ break;
125
+
106
126
  case "archive":
107
127
  await (await import("./commands/archive.ts")).default(commandArgs);
108
128
  break;
@@ -160,17 +180,22 @@ ${bold("STACK NAVIGATION")}
160
180
  ${green("top")} Jump to the tip of the stack
161
181
  ${green("bottom")} Jump to the base of the stack
162
182
  ${green("ls")} List branches with numbers
183
+ ${green("stacks")} List all stacks (--json for tools)
163
184
 
164
185
  ${bold("STACK MANAGEMENT")}
165
186
  ${green("restack")} Rebase children onto updated parents
166
187
  ${green("sync")} Sync base with main + restack all
167
188
  ${green("merge")} Local squash-merge top-down
189
+ ${green("split")} Cut a stack into two at a branch
190
+ ${green("rename")} Rename a tracked branch (keeps metadata in sync)
168
191
  ${green("delete")} Remove a branch from the stack
169
192
 
170
193
  ${bold("INFO & MAINTENANCE")}
171
194
  ${green("status")} PR dashboard (CI, reviews)
172
195
  ${green("undo")} Restore from last snapshot
173
196
  ${green("archive")} Manage archived stacks
197
+ ${green("update-prs")} Refresh stack visualization in PR descriptions
198
+ ${green("doctor")} Migrate & repair stack metadata
174
199
 
175
200
  ${bold("GLOBAL OPTIONS")}
176
201
  --yes, -y Skip confirmations ${dim("(for agents/CI)")}
@@ -0,0 +1,132 @@
1
+ // Git-native branch membership index.
2
+ //
3
+ // Each branch records which stack it belongs to (plus its parent and PR) in
4
+ // git's own config under the `branch.<name>` section:
5
+ //
6
+ // branch.<name>.ghstack-stack <stack>
7
+ // branch.<name>.ghstack-parent <parent-branch>
8
+ // branch.<name>.ghstack-pr <number>
9
+ // branch.<name>.ghstack-id <uuid> (stable identity; survives rename)
10
+ //
11
+ // Why config and not JSON: git maintains the `branch.<name>` section for us.
12
+ // • `git branch -m old new` → the section moves → rename tracking free
13
+ // • `git branch -D name` → the section is deleted → no orphaned membership
14
+ //
15
+ // So this is a self-cleaning, rename-safe index that runs alongside the
16
+ // `.gh-stack/` topology files and lets `doctor` cross-check the two. We use
17
+ // dash-separated, all-lowercase variable names on purpose: git only permits
18
+ // alphanumerics and `-` in config keys (underscores are rejected outright),
19
+ // and it lowercases the variable portion on read — so `ghstack-stack` round
20
+ // -trips exactly while a camelCase key would read back mangled.
21
+ import { $ } from "bun";
22
+
23
+ export interface BranchMembership {
24
+ stack?: string;
25
+ parent?: string;
26
+ pr?: number;
27
+ id?: string;
28
+ }
29
+
30
+ const KEY_PREFIX = "ghstack-";
31
+ const STACK_KEY = "ghstack-stack";
32
+ const PARENT_KEY = "ghstack-parent";
33
+ const PR_KEY = "ghstack-pr";
34
+ const ID_KEY = "ghstack-id";
35
+
36
+ function configKey(branch: string, key: string): string {
37
+ return `branch.${branch}.${key}`;
38
+ }
39
+
40
+ /**
41
+ * Set (or clear) the membership config for a single branch. Any field left
42
+ * undefined is removed, so callers can pass a full desired state and trust the
43
+ * branch ends up matching it exactly.
44
+ */
45
+ export async function setBranchMembership(
46
+ branch: string,
47
+ membership: BranchMembership,
48
+ ): Promise<void> {
49
+ await setOrUnset(branch, STACK_KEY, membership.stack);
50
+ await setOrUnset(branch, PARENT_KEY, membership.parent);
51
+ await setOrUnset(branch, PR_KEY, membership.pr != null ? String(membership.pr) : undefined);
52
+ await setOrUnset(branch, ID_KEY, membership.id);
53
+ }
54
+
55
+ async function setOrUnset(branch: string, key: string, value: string | undefined): Promise<void> {
56
+ if (value === undefined || value === "") {
57
+ await unsetKey(branch, key);
58
+ return;
59
+ }
60
+ await $`git config ${configKey(branch, key)} ${value}`.quiet().nothrow();
61
+ }
62
+
63
+ async function unsetKey(branch: string, key: string): Promise<void> {
64
+ // `--unset` exits 5 when the key is already absent — harmless, so nothrow.
65
+ await $`git config --unset ${configKey(branch, key)}`.quiet().nothrow();
66
+ }
67
+
68
+ /**
69
+ * Read one branch's membership. Returns an empty object if the branch has no
70
+ * gh-stack config.
71
+ */
72
+ export async function getBranchMembership(branch: string): Promise<BranchMembership> {
73
+ const all = await allBranchMemberships();
74
+ return all.get(branch) ?? {};
75
+ }
76
+
77
+ /**
78
+ * Remove all gh-stack membership keys from a branch (leaves the branch and its
79
+ * other config untouched).
80
+ */
81
+ export async function clearBranchMembership(branch: string): Promise<void> {
82
+ await unsetKey(branch, STACK_KEY);
83
+ await unsetKey(branch, PARENT_KEY);
84
+ await unsetKey(branch, PR_KEY);
85
+ await unsetKey(branch, ID_KEY);
86
+ }
87
+
88
+ /**
89
+ * Read every branch's gh-stack membership in one shot, keyed by branch name.
90
+ *
91
+ * Branch names may legally contain dots (`feat.x`), so we can't naively split
92
+ * the config key on `.`. We anchor on the known `.ghstack-<field>` suffix and
93
+ * treat everything between `branch.` and that suffix as the branch name.
94
+ */
95
+ export async function allBranchMemberships(): Promise<Map<string, BranchMembership>> {
96
+ const out = new Map<string, BranchMembership>();
97
+
98
+ const { stdout, exitCode } = await $`git config --get-regexp ${"^branch\\..*\\.ghstack-"}`
99
+ .quiet()
100
+ .nothrow();
101
+ // exit 1 simply means no matching keys — an empty repo, not an error.
102
+ if (exitCode !== 0) return out;
103
+
104
+ const text = stdout.toString().trim();
105
+ if (!text) return out;
106
+
107
+ for (const line of text.split("\n")) {
108
+ const sep = line.indexOf(" ");
109
+ if (sep === -1) continue;
110
+ const fullKey = line.slice(0, sep);
111
+ const value = line.slice(sep + 1);
112
+
113
+ // fullKey looks like: branch.<name>.ghstack-<field>
114
+ if (!fullKey.startsWith("branch.")) continue;
115
+ const dotField = fullKey.lastIndexOf(`.${KEY_PREFIX}`);
116
+ if (dotField === -1) continue;
117
+ const branch = fullKey.slice("branch.".length, dotField);
118
+ const field = fullKey.slice(dotField + 1); // ghstack-<field>
119
+
120
+ const m = out.get(branch) ?? {};
121
+ if (field === STACK_KEY) m.stack = value;
122
+ else if (field === PARENT_KEY) m.parent = value;
123
+ else if (field === ID_KEY) m.id = value;
124
+ else if (field === PR_KEY) {
125
+ const n = Number(value);
126
+ if (Number.isFinite(n)) m.pr = n;
127
+ }
128
+ out.set(branch, m);
129
+ }
130
+
131
+ return out;
132
+ }