gh-stack 0.1.2 → 0.2.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.
@@ -1,4 +1,4 @@
1
- // gh-stack init — Create a new stack with the current branch
1
+ // gh-stack init — Create a new stack, auto-detecting branch chains
2
2
  import * as p from "@clack/prompts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
@@ -8,14 +8,80 @@ import {
8
8
  createStack,
9
9
  addBranchToStack,
10
10
  metadataExists,
11
+ findStackForBranch,
11
12
  } from "../lib/metadata.ts";
12
13
  import { getPrNumber } from "../lib/github.ts";
13
- import { selectParent } from "../lib/ui.ts";
14
+ import { isAutoYes } from "../lib/ui.ts";
14
15
  import type { StackMetadata, Branch } from "../types.ts";
15
16
 
16
- export default async function init(args: string[]): Promise<void> {
17
- const branch = await git.currentBranch();
17
+ const HELP = `
18
+ gh-stack init — Create a new stack from the current branch
19
+
20
+ USAGE
21
+ gh-stack init [options]
22
+
23
+ Creates a new stack and adds the current branch. If the current branch
24
+ is part of a chain of branches (e.g. main → feat-1 → feat-2), init
25
+ auto-detects the chain and offers to add all branches to the stack.
26
+
27
+ Best used from the TOP of your branch chain so all branches are found.
28
+
29
+ OPTIONS
30
+ --name <name> Stack name (default: current branch name)
31
+ --description <desc> Stack description
32
+ --parent <branch> Parent/trunk branch (default: main)
33
+
34
+ EXAMPLES
35
+ gh-stack init # Auto-detect chain, use defaults
36
+ gh-stack init --name my-feature # Custom stack name
37
+ gh-stack init --parent develop # Different trunk branch
38
+ `;
39
+
40
+ /**
41
+ * Detect a chain of branches from trunk to the current branch.
42
+ *
43
+ * Walks down from the current branch by finding local branches whose
44
+ * tips are strict ancestors of the current branch. Sorts by commit
45
+ * distance (closest = direct parent) to reconstruct the chain.
46
+ *
47
+ * Returns branches in bottom-up order: [closest-to-trunk, ..., current]
48
+ */
49
+ async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
50
+ const allBranches = await git.allLocalBranches();
51
+
52
+ // Branches to exclude: current, trunk, and always main/master
53
+ const excluded = new Set([currentBranch, trunk, "main", "master"]);
54
+
55
+ // Find branches that are strict ancestors of current (and not trunk)
56
+ const ancestors: { name: string; distance: number }[] = [];
57
+
58
+ for (const branch of allBranches) {
59
+ if (excluded.has(branch)) continue;
60
+
61
+ // Is this branch's tip an ancestor of current?
62
+ if (await git.isAncestor(branch, currentBranch)) {
63
+ // Skip branches already merged into trunk (stale/old branches)
64
+ if (await git.isAncestor(branch, trunk)) continue;
65
+
66
+ // How many commits between this branch and current?
67
+ const distance = await git.commitCount(branch, currentBranch);
68
+ if (distance > 0) {
69
+ ancestors.push({ name: branch, distance });
70
+ }
71
+ }
72
+ }
18
73
 
74
+ // Sort by distance descending (furthest from current = closest to trunk)
75
+ ancestors.sort((a, b) => b.distance - a.distance);
76
+
77
+ // Build chain: ancestors (sorted trunk→current) + current branch
78
+ const chain = ancestors.map((a) => a.name);
79
+ chain.push(currentBranch);
80
+
81
+ return chain;
82
+ }
83
+
84
+ export default async function init(args: string[]): Promise<void> {
19
85
  // Parse flags
20
86
  let nameFlag: string | undefined;
21
87
  let descFlag: string | undefined;
@@ -33,25 +99,15 @@ export default async function init(args: string[]): Promise<void> {
33
99
  parentFlag = args[++i];
34
100
  break;
35
101
  case "--help":
36
- console.log(`
37
- gh-stack init — Create a new stack
38
-
39
- USAGE
40
- gh-stack init [--name <name>] [--description <desc>] [--parent <branch>]
41
-
42
- OPTIONS
43
- --name <name> Stack name (skip prompt)
44
- --description <desc> Stack description
45
- --parent <branch> Parent branch for current branch (default: main)
46
- `);
102
+ console.log(HELP);
47
103
  return;
48
104
  }
49
105
  }
50
106
 
51
- p.intro(pc.cyan("Create New Stack"));
107
+ const branch = await git.currentBranch();
108
+ const trunk = parentFlag || "main";
52
109
 
53
- console.log(` Initializing stack with: ${pc.yellow(branch)}`);
54
- console.log();
110
+ p.intro(pc.cyan("Create New Stack"));
55
111
 
56
112
  // Get or create metadata
57
113
  let meta: StackMetadata;
@@ -59,98 +115,91 @@ OPTIONS
59
115
  meta = (await readMetadata())!;
60
116
  } else {
61
117
  meta = await initMetadata();
62
- p.log.success("Initialized empty stack metadata");
63
118
  }
64
119
 
65
- // Get stack name
66
- let stackName = nameFlag;
67
- if (!stackName) {
68
- const result = await p.text({
69
- message: "Stack name",
70
- placeholder: "e.g., podcast-mvp",
71
- validate: (val = "") => {
72
- if (!val.trim()) return "Stack name is required";
73
- if (meta.stacks[val.trim()]) return `Stack "${val}" already exists`;
74
- },
75
- });
76
- if (p.isCancel(result)) {
77
- p.cancel("Cancelled");
78
- process.exit(0);
120
+ // Check if current branch is already in a stack
121
+ const existingStack = findStackForBranch(meta, branch);
122
+ if (existingStack) {
123
+ p.cancel(`Branch ${pc.yellow(branch)} is already in stack ${pc.yellow(existingStack)}`);
124
+ process.exit(1);
125
+ }
126
+
127
+ // ── Detect branch chain ──
128
+ const s = p.spinner();
129
+ s.start("Detecting branch chain...");
130
+ const chain = await detectBranchChain(branch, trunk);
131
+ s.stop(chain.length > 1 ? `Found ${chain.length} branches in chain` : `Single branch: ${branch}`);
132
+
133
+ // Show detected chain
134
+ if (chain.length > 1) {
135
+ console.log();
136
+ console.log(` ${pc.dim(trunk)}`);
137
+ for (let i = 0; i < chain.length; i++) {
138
+ const isLast = i === chain.length - 1;
139
+ const tree = isLast ? " ┗━" : " ┣━";
140
+ const label = chain[i] === branch ? pc.yellow(chain[i]!) + pc.dim(" (current)") : chain[i];
141
+ console.log(`${tree} ${label}`);
142
+ if (!isLast) console.log(" ┃");
143
+ }
144
+ console.log();
145
+
146
+ // Confirm chain
147
+ if (!isAutoYes()) {
148
+ const confirmed = await p.confirm({
149
+ message: "Create stack with all branches?",
150
+ });
151
+ if (p.isCancel(confirmed) || !confirmed) {
152
+ p.cancel("Cancelled");
153
+ process.exit(0);
154
+ }
79
155
  }
80
- stackName = result as string;
81
156
  }
82
157
 
83
- // Check if stack already exists
158
+ // ── Determine stack name ──
159
+ const stackName = nameFlag || branch;
160
+
84
161
  if (meta.stacks[stackName]) {
85
162
  p.cancel(`Stack "${stackName}" already exists`);
86
163
  process.exit(1);
87
164
  }
88
165
 
89
- // Get stack description
90
- let description = descFlag;
91
- if (description === undefined) {
92
- const result = await p.text({
93
- message: "Stack description (optional)",
94
- placeholder: "e.g., Podcast MVP features",
95
- });
96
- if (p.isCancel(result)) {
97
- p.cancel("Cancelled");
98
- process.exit(0);
99
- }
100
- description = (result as string) || "";
101
- }
166
+ // ── Create stack ──
167
+ meta = await createStack(meta, stackName, descFlag || "");
102
168
 
103
- // Create the stack
104
- meta = await createStack(meta, stackName, description);
105
- p.log.success(`Created stack: ${pc.blue(stackName)}`);
106
-
107
- // Get parent branch
108
- let parent: string | undefined = parentFlag;
109
- if (!parent) {
110
- parent = (await selectParent(null, branch)) ?? undefined;
111
- if (!parent) {
112
- p.cancel("Cancelled");
113
- process.exit(0);
114
- }
115
- }
169
+ // ── Add branches with PR detection ──
170
+ const prSpinner = p.spinner();
171
+ prSpinner.start("Detecting PRs...");
116
172
 
117
- p.log.info(`Parent: ${pc.yellow(parent)}`);
173
+ let prCount = 0;
174
+ for (let i = 0; i < chain.length; i++) {
175
+ const branchName = chain[i]!;
176
+ const parent = i === 0 ? trunk : chain[i - 1]!;
118
177
 
119
- // Auto-detect PR number
120
- const s = p.spinner();
121
- s.start("Looking for PR...");
122
- const prNumber = await getPrNumber(branch);
123
- s.stop(prNumber ? `Found PR #${prNumber}` : "No PR found");
124
-
125
- // Get branch description
126
- let branchDesc: string | undefined;
127
- if (!nameFlag) {
128
- // Only prompt for branch description in interactive mode
129
- const result = await p.text({
130
- message: "Branch description (optional)",
131
- placeholder: "e.g., Backend models",
132
- });
133
- if (!p.isCancel(result)) {
134
- branchDesc = (result as string) || undefined;
135
- }
136
- }
178
+ // Auto-detect PR
179
+ const prNumber = await getPrNumber(branchName);
180
+ if (prNumber) prCount++;
137
181
 
138
- // Add branch to stack
139
- const branchData: Branch = {
140
- parent,
141
- ...(prNumber && { pr: prNumber }),
142
- ...(branchDesc && { description: branchDesc }),
143
- };
182
+ const branchData: Branch = {
183
+ parent,
184
+ ...(prNumber && { pr: prNumber }),
185
+ };
144
186
 
145
- meta = await addBranchToStack(meta, stackName, branch, branchData);
187
+ meta = await addBranchToStack(meta, stackName, branchName, branchData);
188
+ }
146
189
 
147
- p.log.success(`Added ${pc.yellow(branch)} to stack`);
190
+ prSpinner.stop(prCount > 0 ? `Found ${prCount} PR(s)` : "No PRs found");
148
191
 
149
- p.outro(pc.green("Stack initialized!"));
192
+ p.outro(pc.green(`Stack ${pc.yellow(stackName)} created with ${chain.length} branch(es)!`));
150
193
 
194
+ // Next steps
195
+ console.log();
196
+ if (chain.length === 1) {
197
+ console.log(" Next steps:");
198
+ console.log(` ${pc.blue("gh-stack create <branch>")} — Add a branch to the stack`);
199
+ } else {
200
+ console.log(" Next steps:");
201
+ console.log(` ${pc.blue("gh-stack submit")} — Push and create PRs`);
202
+ }
203
+ console.log(` ${pc.blue("gh-stack log")} — View stack`);
151
204
  console.log();
152
- console.log(" Next steps:");
153
- console.log(` ${pc.blue("gh-stack add")} — Add more branches`);
154
- console.log(` ${pc.blue("gh-stack")} — View stack`);
155
- console.log(` ${pc.blue("gh-stack restack")} — Sync stack`);
156
205
  }
@@ -14,7 +14,7 @@ USAGE
14
14
  gh-stack ls
15
15
 
16
16
  Lightweight branch listing for quick reference and scripting.
17
- Use the number with 'gh-stack switch <number>' to jump to a branch.
17
+ Use the number with 'gh-stack checkout <number>' to jump to a branch.
18
18
 
19
19
  ALIASES
20
20
  gh-stack ls
@@ -1,4 +1,4 @@
1
- // gh-stack show — Display current stack tree (default command)
1
+ // gh-stack log — Display current stack tree (default command)
2
2
  import * as p from "@clack/prompts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
@@ -10,7 +10,7 @@ import {
10
10
  } from "../lib/metadata.ts";
11
11
  import { renderStackTree } from "../lib/ui.ts";
12
12
 
13
- export default async function show(_args: string[]): Promise<void> {
13
+ export default async function log(_args: string[]): Promise<void> {
14
14
  // Check if metadata exists
15
15
  if (!(await metadataExists())) {
16
16
  p.log.warn("No stack metadata found");
@@ -71,6 +71,6 @@ export default async function show(_args: string[]): Promise<void> {
71
71
  console.log();
72
72
 
73
73
  // Tip
74
- console.log(pc.dim(`Tip: Switch stacks with 'gh-stack switch --stack'`));
74
+ console.log(pc.dim(`Tip: Switch stacks with 'gh-stack checkout --stack'`));
75
75
  console.log();
76
76
  }
@@ -0,0 +1,326 @@
1
+ // gh-stack submit — Push branches and create/update PRs
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { $ } from "bun";
5
+ import * as git from "../lib/git.ts";
6
+ import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
7
+ import { ensureMetadata } from "../lib/safety.ts";
8
+ import { isAutoYes } from "../lib/ui.ts";
9
+ import { getPrNumber, getPrInfo, getPrBody, updatePrBody, reviewEmoji } from "../lib/github.ts";
10
+ import { buildStackViz } from "./update-prs.ts";
11
+
12
+ const HELP = `
13
+ gh-stack submit — Push branches and create/update PRs
14
+
15
+ USAGE
16
+ gh-stack submit [options]
17
+
18
+ Pushes all branches from trunk to the current branch, creating PRs
19
+ for branches that don't have them and updating stack visualization
20
+ in all PR descriptions. Idempotent — safe to run repeatedly.
21
+
22
+ OPTIONS
23
+ -d, --draft Create new PRs as drafts
24
+ -n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
25
+ --dry-run Show what would happen without doing anything
26
+
27
+ EXAMPLES
28
+ gh-stack submit # Push + create PRs interactively
29
+ gh-stack submit -n # Push + create PRs with auto-titles
30
+ gh-stack submit --draft # Create new PRs as drafts
31
+ gh-stack submit --dry-run # Preview what would happen
32
+ `;
33
+
34
+ /**
35
+ * Generate a PR title from a branch name.
36
+ * Strips common prefixes like "kiliman/", replaces hyphens with spaces,
37
+ * and capitalizes the first letter.
38
+ */
39
+ function branchToTitle(branch: string): string {
40
+ // Strip common prefixes like "kiliman/", "user/"
41
+ let name = branch.includes("/") ? branch.split("/").slice(1).join("/") : branch;
42
+ // Replace hyphens with spaces
43
+ name = name.replace(/-/g, " ");
44
+ // Capitalize first letter
45
+ return name.charAt(0).toUpperCase() + name.slice(1);
46
+ }
47
+
48
+ /**
49
+ * Check if a local branch is up-to-date with its remote counterpart.
50
+ */
51
+ async function isBranchUpToDate(branch: string): Promise<boolean> {
52
+ try {
53
+ const localSha = (await $`git rev-parse ${branch}`.text()).trim();
54
+ const remoteSha = (await $`git rev-parse origin/${branch}`.text()).trim();
55
+ return localSha === remoteSha;
56
+ } catch {
57
+ // Remote branch doesn't exist yet
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Push a branch to origin with upstream tracking and force-with-lease.
64
+ */
65
+ async function pushBranch(branch: string): Promise<boolean> {
66
+ try {
67
+ await $`git push -u --force-with-lease origin ${branch}`.quiet();
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ export default async function submit(args: string[]): Promise<void> {
75
+ if (args.includes("--help")) {
76
+ console.log(HELP);
77
+ return;
78
+ }
79
+
80
+ const draftFlag = args.includes("--draft") || args.includes("-d");
81
+ const dryRun = args.includes("--dry-run");
82
+ const noEdit = args.includes("--no-edit") || args.includes("-n") || isAutoYes();
83
+
84
+ const meta = await ensureMetadata();
85
+ const branch = await git.currentBranch();
86
+ const stackName = findStackForBranch(meta, branch);
87
+
88
+ if (!stackName) {
89
+ p.cancel(`Branch ${pc.blue(branch)} not found in any stack`);
90
+ process.exit(1);
91
+ }
92
+
93
+ const stack = meta.stacks[stackName]!;
94
+ const ordered = getOrderedBranches(stack);
95
+
96
+ // Determine downstack scope: branches from trunk up to and including current
97
+ const currentIndex = ordered.indexOf(branch);
98
+ const scope = currentIndex >= 0 ? ordered.slice(0, currentIndex + 1) : ordered;
99
+
100
+ p.intro(pc.cyan("Submit Stack"));
101
+ p.log.info(`Stack: ${pc.yellow(stackName)}`);
102
+ p.log.info(
103
+ `Scope: ${scope.length} of ${ordered.length} branch(es) (downstack from ${pc.blue(branch)})`,
104
+ );
105
+ console.log();
106
+
107
+ if (dryRun) {
108
+ p.log.info(pc.dim("Dry run — no changes will be made"));
109
+ console.log();
110
+ }
111
+
112
+ // ── Phase 1: Push branches and create/update PRs ──
113
+ let pushed = 0;
114
+ let created = 0;
115
+ let existing = 0;
116
+ let metadataChanged = false;
117
+
118
+ for (const branchName of scope) {
119
+ const branchMeta = stack.branches[branchName]!;
120
+ const parentBranch = branchMeta.parent;
121
+
122
+ // ── Push (skip if already up-to-date) ──
123
+ if (dryRun) {
124
+ p.log.step(`${pc.dim("push")} ${branchName} → origin/${branchName}`);
125
+ } else if (await isBranchUpToDate(branchName)) {
126
+ p.log.info(`${pc.dim("✓")} ${pc.blue(branchName)} already up to date`);
127
+ } else {
128
+ const s = p.spinner();
129
+ s.start(`Pushing ${pc.blue(branchName)}...`);
130
+ const ok = await pushBranch(branchName);
131
+ if (ok) {
132
+ s.stop(`Pushed ${pc.blue(branchName)}`);
133
+ pushed++;
134
+ } else {
135
+ s.stop(pc.red(`Failed to push ${branchName}`));
136
+ p.cancel(`Push failed for ${pc.blue(branchName)}. Aborting.`);
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ // ── Check for existing PR ──
142
+ let prNumber = branchMeta.pr ?? null;
143
+
144
+ if (!prNumber && !dryRun) {
145
+ prNumber = await getPrNumber(branchName);
146
+ if (prNumber) {
147
+ // Found an existing PR not tracked in metadata — save it
148
+ branchMeta.pr = prNumber;
149
+ metadataChanged = true;
150
+ }
151
+ }
152
+
153
+ if (prNumber) {
154
+ // ── Existing PR: update base if needed ──
155
+ existing++;
156
+ if (dryRun) {
157
+ p.log.step(`${pc.dim("exists")} PR #${prNumber} for ${branchName}`);
158
+ } else {
159
+ // Ensure the PR base is correct
160
+ try {
161
+ await $`gh pr edit ${prNumber} --base ${parentBranch}`.quiet();
162
+ } catch {
163
+ // Best effort — base might already be correct
164
+ }
165
+ p.log.info(`${pc.dim("✓")} PR #${prNumber} for ${pc.blue(branchName)}`);
166
+ }
167
+ } else {
168
+ // ── Create new PR ──
169
+ const defaultTitle = branchToTitle(branchName);
170
+ let title = defaultTitle;
171
+
172
+ if (!noEdit && !dryRun) {
173
+ const input = await p.text({
174
+ message: `PR title for ${pc.blue(branchName)}`,
175
+ initialValue: defaultTitle,
176
+ placeholder: defaultTitle,
177
+ });
178
+ if (p.isCancel(input)) {
179
+ p.cancel("Cancelled");
180
+ process.exit(0);
181
+ }
182
+ title = (input as string).trim() || defaultTitle;
183
+ }
184
+
185
+ if (dryRun) {
186
+ p.log.step(
187
+ `${pc.dim("create")} PR for ${branchName} → ${parentBranch} "${title}"${draftFlag ? " (draft)" : ""}`,
188
+ );
189
+ created++;
190
+ } else {
191
+ const s = p.spinner();
192
+ s.start(`Creating PR for ${pc.blue(branchName)}...`);
193
+
194
+ const ghArgs = [
195
+ "pr",
196
+ "create",
197
+ "--base",
198
+ parentBranch,
199
+ "--head",
200
+ branchName,
201
+ "--title",
202
+ title,
203
+ "--body",
204
+ "",
205
+ ];
206
+ if (draftFlag) ghArgs.push("--draft");
207
+
208
+ const proc = Bun.spawn(["gh", ...ghArgs], {
209
+ stdout: "pipe",
210
+ stderr: "pipe",
211
+ });
212
+ const output = await new Response(proc.stdout).text();
213
+ const exitCode = await proc.exited;
214
+
215
+ if (exitCode === 0) {
216
+ // Extract PR number from URL output
217
+ const prUrl = output.trim();
218
+ const prNumMatch = prUrl.match(/\/pull\/(\d+)/);
219
+ const newPrNumber = prNumMatch ? parseInt(prNumMatch[1]!, 10) : null;
220
+
221
+ if (newPrNumber) {
222
+ branchMeta.pr = newPrNumber;
223
+ metadataChanged = true;
224
+ s.stop(`Created PR #${newPrNumber} for ${pc.blue(branchName)}`);
225
+ } else {
226
+ s.stop(`Created PR for ${pc.blue(branchName)} (could not parse PR number)`);
227
+ }
228
+ created++;
229
+ } else {
230
+ const stderr = await new Response(proc.stderr).text();
231
+ s.stop(pc.red(`Failed to create PR for ${branchName}`));
232
+ p.log.error(stderr.trim());
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ // Save metadata if we discovered or created PRs
239
+ if (metadataChanged && !dryRun) {
240
+ await writeMetadata(meta);
241
+ }
242
+
243
+ console.log();
244
+
245
+ if (dryRun) {
246
+ p.outro(
247
+ pc.dim(
248
+ `Dry run complete: would push ${scope.length}, create ${created}, update ${existing} PR(s)`,
249
+ ),
250
+ );
251
+ return;
252
+ }
253
+
254
+ // ── Phase 2: Update stack visualization in all PR descriptions ──
255
+ p.log.info(pc.cyan("Updating stack visualization..."));
256
+
257
+ // Collect PR info for ALL branches in the stack (not just scope)
258
+ interface BranchPrInfo {
259
+ branch: string;
260
+ prNumber: number | null;
261
+ prTitle: string;
262
+ prUrl: string | null;
263
+ reviewEmojiStr: string;
264
+ }
265
+
266
+ const branchInfos: BranchPrInfo[] = [];
267
+
268
+ for (const branchName of ordered) {
269
+ const branchMeta = stack.branches[branchName]!;
270
+ const prNum = branchMeta.pr ?? null;
271
+ let prTitle = branchMeta.description || branchName;
272
+ let prUrl: string | null = null;
273
+ let review = "PENDING";
274
+
275
+ if (prNum) {
276
+ const info = await getPrInfo(prNum);
277
+ if (info) {
278
+ prTitle = info.title || prTitle;
279
+ prUrl = info.url;
280
+ review = info.reviewDecision || "PENDING";
281
+ }
282
+ }
283
+
284
+ branchInfos.push({
285
+ branch: branchName,
286
+ prNumber: prNum,
287
+ prTitle,
288
+ prUrl,
289
+ reviewEmojiStr: reviewEmoji(review),
290
+ });
291
+ }
292
+
293
+ // Update each PR that has a number
294
+ let vizUpdated = 0;
295
+
296
+ for (let i = 0; i < branchInfos.length; i++) {
297
+ const info = branchInfos[i]!;
298
+ if (!info.prNumber) continue;
299
+
300
+ const stackViz = buildStackViz(branchInfos, i);
301
+
302
+ const currentBody = await getPrBody(info.prNumber);
303
+ if (currentBody === null) continue;
304
+
305
+ // Remove existing stack section
306
+ let updatedBody = currentBody;
307
+ const stackSectionIdx = updatedBody.indexOf("### 📚 Stacked on");
308
+ if (stackSectionIdx !== -1) {
309
+ updatedBody = updatedBody.slice(0, stackSectionIdx).trimEnd();
310
+ }
311
+
312
+ // Append new stack section
313
+ const newBody = updatedBody ? `${updatedBody}\n\n${stackViz}` : stackViz;
314
+
315
+ const ok = await updatePrBody(info.prNumber, newBody);
316
+ if (ok) vizUpdated++;
317
+ }
318
+
319
+ // ── Summary ──
320
+ console.log();
321
+ p.outro(
322
+ pc.green(
323
+ `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${vizUpdated} description(s)`,
324
+ ),
325
+ );
326
+ }