@cvr/stacked 0.4.2 → 0.4.4

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.
@@ -5,6 +5,8 @@ import { StackService } from "../services/Stack.js";
5
5
  import { success, warn, info } from "../ui.js";
6
6
  import { detectLimitConfig, limitUntrackedBranches } from "./helpers/detect.js";
7
7
 
8
+ const DETECT_COMMIT_LIMIT = 2048;
9
+
8
10
  const dryRunFlag = Flag.boolean("dry-run").pipe(
9
11
  Flag.withDescription("Show what would be detected without making changes"),
10
12
  );
@@ -26,8 +28,11 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
26
28
  const candidates = allBranches.filter((b) => b !== trunk);
27
29
 
28
30
  const data = yield* stacks.load();
29
- const alreadyTracked = new Set(Object.values(data.stacks).flatMap((s) => [...s.branches]));
30
- const untrackedAll = candidates.filter((b) => !alreadyTracked.has(b));
31
+ const alreadyTracked = new Set(Object.keys(data.branches));
32
+ const mergedBranches = new Set(data.mergedBranches);
33
+ const untrackedAll = candidates.filter(
34
+ (b) => !alreadyTracked.has(b) && !mergedBranches.has(b),
35
+ );
31
36
  const detectLimit = yield* detectLimitConfig;
32
37
  const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
33
38
 
@@ -42,42 +47,63 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
42
47
  );
43
48
  }
44
49
 
45
- // Build parent map: for each branch, find its direct parent among other branches
46
- // A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
47
50
  const childOf = new Map<string, string>();
51
+ const unclassified: string[] = [];
52
+
53
+ const tipResults = yield* Effect.forEach(
54
+ untracked,
55
+ (branch) =>
56
+ git.revParse(branch).pipe(
57
+ Effect.map((oid) => [branch, oid] as const),
58
+ Effect.catchTag("GitError", () => Effect.succeed(null)),
59
+ ),
60
+ { concurrency: 5 },
61
+ );
62
+
63
+ const tipOwners = new Map<string, string[]>();
64
+ for (const result of tipResults) {
65
+ if (result === null) continue;
66
+ const [branch, oid] = result;
67
+ const owners = tipOwners.get(oid) ?? [];
68
+ owners.push(branch);
69
+ tipOwners.set(oid, owners);
70
+ }
48
71
 
49
72
  yield* Effect.forEach(
50
73
  untracked,
51
74
  (branch) =>
52
75
  Effect.gen(function* () {
53
- // Check all potential ancestors (trunk + other untracked) in parallel
54
- const potentialAncestors = [trunk, ...untracked.filter((b) => b !== branch)];
55
- const ancestryResults = yield* Effect.forEach(
56
- potentialAncestors,
57
- (other) =>
58
- git.isAncestor(other, branch).pipe(
59
- Effect.catchTag("GitError", () => Effect.succeed(false)),
60
- Effect.map((is) => [other, is] as const),
61
- ),
62
- { concurrency: 5 },
63
- );
64
-
65
- const ancestors = ancestryResults.filter(([_, is]) => is).map(([name]) => name);
66
-
67
- if (ancestors.length === 0) return;
68
-
69
- // Find the closest ancestor — the one that is a descendant of all others
70
- let closest = ancestors[0] ?? trunk;
71
- for (let i = 1; i < ancestors.length; i++) {
72
- const candidate = ancestors[i];
73
- if (candidate === undefined) continue;
74
- const candidateIsCloser = yield* git
75
- .isAncestor(closest, candidate)
76
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
77
- if (candidateIsCloser) closest = candidate;
76
+ const commits = yield* git
77
+ .firstParentUniqueCommits(branch, trunk, { limit: DETECT_COMMIT_LIMIT })
78
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed([])));
79
+
80
+ if (commits.length === 0) {
81
+ unclassified.push(branch);
82
+ return;
83
+ }
84
+
85
+ let parent: string | null = null;
86
+ let ambiguous = false;
87
+
88
+ for (const oid of commits) {
89
+ const owners = (tipOwners.get(oid) ?? []).filter((owner) => owner !== branch);
90
+ if (owners.length > 1) {
91
+ ambiguous = true;
92
+ break;
93
+ }
94
+ const [owner] = owners;
95
+ if (owner !== undefined) {
96
+ parent = owner;
97
+ break;
98
+ }
99
+ }
100
+
101
+ if (ambiguous || (commits.length >= DETECT_COMMIT_LIMIT && parent === null)) {
102
+ unclassified.push(branch);
103
+ return;
78
104
  }
79
105
 
80
- childOf.set(branch, closest);
106
+ childOf.set(branch, parent ?? trunk);
81
107
  }),
82
108
  { concurrency: 5 },
83
109
  );
@@ -137,8 +163,8 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
137
163
  for (const chain of chains) {
138
164
  const name = chain[0];
139
165
  if (name === undefined) continue;
140
- const currentData = yield* stacks.load();
141
- if (currentData.stacks[name] !== undefined) {
166
+ const existing = yield* stacks.getStack(name);
167
+ if (existing !== null) {
142
168
  yield* warn(`Stack "${name}" already exists, skipping: ${chain.join(" → ")}`);
143
169
  continue;
144
170
  }
@@ -162,6 +188,12 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
162
188
  yield* Console.error(` ${fork.branch} → ${fork.children.join(", ")}`);
163
189
  }
164
190
  }
191
+
192
+ if (unclassified.length > 0 && !json) {
193
+ yield* warn(
194
+ `Skipped ${unclassified.length} unclassified branch${unclassified.length === 1 ? "" : "es"}: ${unclassified.join(", ")}`,
195
+ );
196
+ }
165
197
  }),
166
198
  ),
167
199
  );
@@ -26,6 +26,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
26
26
 
27
27
  const data = yield* stacks.load();
28
28
  const findings: Finding[] = [];
29
+ const stackEntries = yield* stacks.listStacks().pipe(
30
+ Effect.catchTag("StackError", (error) =>
31
+ Effect.sync(() => {
32
+ findings.push({ type: "parse_error", message: error.message, fixed: false });
33
+ return [] as const;
34
+ }),
35
+ ),
36
+ );
29
37
 
30
38
  // Check 1: trunk branch exists
31
39
  const trunkExists = yield* git
@@ -58,14 +66,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
58
66
  }
59
67
 
60
68
  // Check 2: all tracked branches exist in git
61
- for (const [stackName, stack] of Object.entries(data.stacks)) {
69
+ for (const { name: stackName, stack } of stackEntries) {
62
70
  for (const branch of stack.branches) {
63
71
  const exists = yield* git
64
72
  .branchExists(branch)
65
73
  .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
66
74
  if (!exists) {
67
75
  if (fix) {
68
- yield* stacks.removeBranch(stackName, branch);
76
+ yield* stacks.removeBranch(branch);
69
77
  findings.push({
70
78
  type: "stale_branch",
71
79
  message: `Removed stale branch "${branch}" from stack "${stackName}"`,
@@ -84,12 +92,10 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
84
92
 
85
93
  // Check 3: no branches in multiple stacks
86
94
  const branchToStacks = new Map<string, string[]>();
87
- for (const [stackName, stack] of Object.entries(data.stacks)) {
88
- for (const branch of stack.branches) {
89
- const existing = branchToStacks.get(branch) ?? [];
90
- existing.push(stackName);
91
- branchToStacks.set(branch, existing);
92
- }
95
+ for (const [branch, record] of Object.entries(data.branches)) {
96
+ const existing = branchToStacks.get(branch) ?? [];
97
+ existing.push(record.stack);
98
+ branchToStacks.set(branch, existing);
93
99
  }
94
100
  for (const [branch, stackNames] of branchToStacks) {
95
101
  if (stackNames.length > 1) {
@@ -0,0 +1,131 @@
1
+ import type { ServiceMap } from "effect";
2
+ import { Effect } from "effect";
3
+ import type { GitHubService } from "../../services/GitHub.js";
4
+
5
+ const STACKED_MARKER_START = "<!-- stacked -->";
6
+ const STACKED_MARKER_END = "<!-- /stacked -->";
7
+
8
+ type PullRequest = {
9
+ number: number;
10
+ url: string;
11
+ state: string;
12
+ body?: string | null;
13
+ } | null;
14
+
15
+ type GitHubApi = ServiceMap.Service.Shape<typeof GitHubService>;
16
+
17
+ export const generateStackMetadata = (
18
+ branches: readonly string[],
19
+ prMap: Map<string, PullRequest>,
20
+ currentIdx: number,
21
+ stackName: string,
22
+ ): string => {
23
+ const rows = branches.map((branch, i) => {
24
+ const pr = prMap.get(branch) ?? null;
25
+ const isCurrent = i === currentIdx;
26
+ const branchCol = isCurrent ? `**\`${branch}\`**` : `\`${branch}\``;
27
+ const numCol = i + 1;
28
+ const numStr = isCurrent ? `**${numCol}**` : `${numCol}`;
29
+
30
+ let prCol: string;
31
+ if (pr === null) {
32
+ prCol = "—";
33
+ } else if (pr.state === "MERGED") {
34
+ prCol = `[#${pr.number}](${pr.url}) ✅`;
35
+ } else if (isCurrent) {
36
+ prCol = `**#${pr.number} ← you are here**`;
37
+ } else {
38
+ prCol = `[#${pr.number}](${pr.url})`;
39
+ }
40
+
41
+ return `| ${numStr} | ${branchCol} | ${prCol} |`;
42
+ });
43
+
44
+ return [
45
+ STACKED_MARKER_START,
46
+ `**Stack: \`${stackName}\`** (${currentIdx + 1} of ${branches.length})`,
47
+ "",
48
+ "| # | Branch | PR |",
49
+ "|---|--------|----|",
50
+ ...rows,
51
+ STACKED_MARKER_END,
52
+ ].join("\n");
53
+ };
54
+
55
+ export const composePRBody = (userBody: string | undefined, metadata: string): string => {
56
+ if (userBody !== undefined) {
57
+ return `${userBody}\n\n---\n\n${metadata}`;
58
+ }
59
+ return metadata;
60
+ };
61
+
62
+ export const updatePRBody = (
63
+ existingBody: string | undefined,
64
+ userBody: string | undefined,
65
+ metadata: string,
66
+ ): string => {
67
+ if (userBody !== undefined) {
68
+ return composePRBody(userBody, metadata);
69
+ }
70
+
71
+ if (existingBody !== undefined) {
72
+ const startIdx = existingBody.indexOf(STACKED_MARKER_START);
73
+ if (startIdx !== -1) {
74
+ const prefix = existingBody.substring(0, startIdx).replace(/\n*---\n*$/, "");
75
+ if (prefix.trim().length > 0) {
76
+ return `${prefix.trim()}\n\n---\n\n${metadata}`;
77
+ }
78
+ return metadata;
79
+ }
80
+ return `${existingBody.trim()}\n\n---\n\n${metadata}`;
81
+ }
82
+
83
+ return metadata;
84
+ };
85
+
86
+ export const refreshStackedPRBodies = ({
87
+ branches,
88
+ stackName,
89
+ gh,
90
+ initialPrMap,
91
+ shouldUpdateBranch,
92
+ getUserBody,
93
+ }: {
94
+ branches: readonly string[];
95
+ stackName: string;
96
+ gh: GitHubApi;
97
+ initialPrMap?: Map<string, PullRequest>;
98
+ shouldUpdateBranch?: (branch: string) => boolean;
99
+ getUserBody?: (branch: string, idx: number) => string | undefined;
100
+ }) =>
101
+ Effect.gen(function* () {
102
+ const prEntries = yield* Effect.all(
103
+ branches.map((branch) => {
104
+ const existing = initialPrMap?.get(branch);
105
+ if (existing !== undefined) {
106
+ return Effect.succeed([branch, existing] as const);
107
+ }
108
+ return gh.getPR(branch).pipe(Effect.map((pr) => [branch, pr] as const));
109
+ }),
110
+ );
111
+ const prMap = new Map(prEntries);
112
+
113
+ for (let i = 0; i < branches.length; i++) {
114
+ const branch = branches[i];
115
+ if (branch === undefined) continue;
116
+ if (shouldUpdateBranch !== undefined && !shouldUpdateBranch(branch)) continue;
117
+
118
+ const existingPrData = prMap.get(branch) ?? null;
119
+ if (existingPrData === null || existingPrData.state !== "OPEN") continue;
120
+
121
+ const metadata = generateStackMetadata(branches, prMap, i, stackName);
122
+ const body = updatePRBody(
123
+ existingPrData.body ?? undefined,
124
+ getUserBody?.(branch, i),
125
+ metadata,
126
+ );
127
+ yield* gh.updatePR({ branch, body });
128
+ }
129
+
130
+ return prMap;
131
+ });
@@ -25,15 +25,14 @@ export const list = Command.make("list", { stackName: stackNameArg, json: jsonFl
25
25
  const stacks = yield* StackService;
26
26
 
27
27
  const currentBranch = yield* git.currentBranch();
28
- const data = yield* stacks.load();
29
- const trunk = data.trunk;
28
+ const trunk = yield* stacks.getTrunk();
30
29
 
31
30
  let targetStackName: string | null = null;
32
31
  let targetStack: { readonly branches: readonly string[] } | null = null;
33
32
 
34
33
  if (Option.isSome(stackName)) {
35
- const s = data.stacks[stackName.value];
36
- if (s === undefined) {
34
+ const s = yield* stacks.getStack(stackName.value);
35
+ if (s === null) {
37
36
  return yield* new StackError({
38
37
  code: ErrorCode.STACK_NOT_FOUND,
39
38
  message: `Stack "${stackName.value}" not found`,
@@ -16,27 +16,23 @@ export const rename = Command.make("rename", { old: oldArg, new: newArg, json: j
16
16
  Command.withHandler(({ old: oldName, new: newName, json }) =>
17
17
  Effect.gen(function* () {
18
18
  const stacks = yield* StackService;
19
-
20
- const data = yield* stacks.load();
21
-
22
- if (data.stacks[oldName] === undefined) {
19
+ const existing = yield* stacks.getStack(oldName);
20
+ if (existing === null) {
23
21
  return yield* new StackError({
24
22
  code: ErrorCode.STACK_NOT_FOUND,
25
23
  message: `Stack "${oldName}" not found`,
26
24
  });
27
25
  }
28
26
 
29
- if (data.stacks[newName] !== undefined) {
27
+ const collision = yield* stacks.getStack(newName);
28
+ if (collision !== null) {
30
29
  return yield* new StackError({
31
30
  code: ErrorCode.STACK_EXISTS,
32
31
  message: `Stack "${newName}" already exists`,
33
32
  });
34
33
  }
35
34
 
36
- const stack = data.stacks[oldName];
37
- if (stack === undefined) return;
38
- const { [oldName]: _, ...rest } = data.stacks;
39
- yield* stacks.save({ ...data, stacks: { ...rest, [newName]: stack } });
35
+ yield* stacks.renameStack(oldName, newName);
40
36
 
41
37
  if (json) {
42
38
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
@@ -74,28 +74,22 @@ export const reorder = Command.make("reorder", {
74
74
  });
75
75
  }
76
76
 
77
- // Remove from current position
78
- branches.splice(currentIdx, 1);
79
-
80
- // Insert at target position
81
- const newTargetIdx = branches.indexOf(target);
82
- if (Option.isSome(before)) {
83
- branches.splice(newTargetIdx, 0, branch);
84
- } else {
85
- branches.splice(newTargetIdx + 1, 0, branch);
86
- }
87
-
88
- const data = yield* stacks.load();
89
- yield* stacks.save({
90
- ...data,
91
- stacks: { ...data.stacks, [stackName]: { branches } },
77
+ const updated = yield* stacks.reorderBranch(branch, {
78
+ before: Option.getOrUndefined(before),
79
+ after: Option.getOrUndefined(after),
92
80
  });
93
81
 
94
82
  if (json) {
95
83
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
96
- yield* Console.log(JSON.stringify({ branch, stack: stackName, branches }, null, 2));
84
+ yield* Console.log(
85
+ JSON.stringify(
86
+ { branch, stack: updated.name, branches: [...updated.stack.branches] },
87
+ null,
88
+ 2,
89
+ ),
90
+ );
97
91
  } else {
98
- yield* success(`Moved "${branch}" in stack "${stackName}"`);
92
+ yield* success(`Moved "${branch}" in stack "${updated.name}"`);
99
93
  yield* warn("Run 'stacked sync' to rebase branches in new order");
100
94
  }
101
95
  }),
@@ -35,17 +35,9 @@ export const split = Command.make("split", {
35
35
  }
36
36
 
37
37
  const { name: stackName, stack } = result;
38
- const branches = [...stack.branches];
39
- const splitIdx = branches.indexOf(branch);
40
-
41
- if (splitIdx === 0) {
42
- return yield* new StackError({
43
- message: `Branch "${branch}" is at the bottom of the stack — nothing to split`,
44
- });
45
- }
46
-
47
- const below = branches.slice(0, splitIdx);
48
- const above = branches.slice(splitIdx);
38
+ const splitIdx = stack.branches.indexOf(branch);
39
+ const below = stack.branches.slice(0, splitIdx);
40
+ const above = stack.branches.slice(splitIdx);
49
41
  const newStackName = branch;
50
42
 
51
43
  if (dryRun) {
@@ -68,23 +60,7 @@ export const split = Command.make("split", {
68
60
  return;
69
61
  }
70
62
 
71
- const data = yield* stacks.load();
72
-
73
- if (data.stacks[newStackName] !== undefined) {
74
- return yield* new StackError({
75
- code: ErrorCode.STACK_EXISTS,
76
- message: `Stack "${newStackName}" already exists — choose a different split point or rename it first`,
77
- });
78
- }
79
-
80
- yield* stacks.save({
81
- ...data,
82
- stacks: {
83
- ...data.stacks,
84
- [stackName]: { branches: below },
85
- [newStackName]: { branches: above },
86
- },
87
- });
63
+ yield* stacks.splitStack(branch);
88
64
 
89
65
  if (json) {
90
66
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
@@ -18,9 +18,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
18
18
  const stackService = yield* StackService;
19
19
 
20
20
  const currentBranch = yield* git.currentBranch();
21
- const data = yield* stackService.load();
22
-
23
- const entries = Object.entries(data.stacks);
21
+ const entries = yield* stackService.listStacks();
24
22
  if (entries.length === 0) {
25
23
  if (json) {
26
24
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
@@ -32,7 +30,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
32
30
  }
33
31
 
34
32
  if (json) {
35
- const stackList = entries.map(([name, stack]) => ({
33
+ const stackList = entries.map(({ name, stack }) => ({
36
34
  name,
37
35
  branches: stack.branches.length,
38
36
  current: stack.branches.includes(currentBranch),
@@ -43,7 +41,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
43
41
  }
44
42
 
45
43
  const lines: string[] = [];
46
- for (const [name, stack] of entries) {
44
+ for (const { name, stack } of entries) {
47
45
  const isCurrent = stack.branches.includes(currentBranch);
48
46
  const marker = isCurrent ? yield* stdout.green("* ") : " ";
49
47
  const label = isCurrent ? yield* stdout.bold(name) : name;
@@ -4,6 +4,11 @@ import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
  import { GitHubService } from "../services/GitHub.js";
6
6
  import { ErrorCode, StackError } from "../errors/index.js";
7
+ import {
8
+ composePRBody,
9
+ generateStackMetadata,
10
+ refreshStackedPRBodies,
11
+ } from "./helpers/pr-metadata.js";
7
12
  import { withSpinner, success } from "../ui.js";
8
13
 
9
14
  const draftFlag = Flag.boolean("draft").pipe(
@@ -38,78 +43,6 @@ interface SubmitResult {
38
43
  action: "created" | "updated" | "unchanged" | "would-create" | "would-update" | "would-unchanged";
39
44
  }
40
45
 
41
- const STACKED_MARKER_START = "<!-- stacked -->";
42
- const STACKED_MARKER_END = "<!-- /stacked -->";
43
-
44
- const generateStackMetadata = (
45
- branches: readonly string[],
46
- prMap: Map<string, { number: number; url: string; state: string } | null>,
47
- currentIdx: number,
48
- stackName: string,
49
- ): string => {
50
- const rows = branches.map((branch, i) => {
51
- const pr = prMap.get(branch) ?? null;
52
- const isCurrent = i === currentIdx;
53
- const branchCol = isCurrent ? `**\`${branch}\`**` : `\`${branch}\``;
54
- const numCol = i + 1;
55
- const numStr = isCurrent ? `**${numCol}**` : `${numCol}`;
56
-
57
- let prCol: string;
58
- if (pr === null) {
59
- prCol = "—";
60
- } else if (pr.state === "MERGED") {
61
- prCol = `[#${pr.number}](${pr.url}) ✅`;
62
- } else if (isCurrent) {
63
- prCol = `**#${pr.number} ← you are here**`;
64
- } else {
65
- prCol = `[#${pr.number}](${pr.url})`;
66
- }
67
-
68
- return `| ${numStr} | ${branchCol} | ${prCol} |`;
69
- });
70
-
71
- return [
72
- STACKED_MARKER_START,
73
- `**Stack: \`${stackName}\`** (${currentIdx + 1} of ${branches.length})`,
74
- "",
75
- "| # | Branch | PR |",
76
- "|---|--------|----|",
77
- ...rows,
78
- STACKED_MARKER_END,
79
- ].join("\n");
80
- };
81
-
82
- const composePRBody = (userBody: string | undefined, metadata: string): string => {
83
- if (userBody !== undefined) {
84
- return `${userBody}\n\n---\n\n${metadata}`;
85
- }
86
- return metadata;
87
- };
88
-
89
- const updatePRBody = (
90
- existingBody: string | undefined,
91
- userBody: string | undefined,
92
- metadata: string,
93
- ): string => {
94
- if (userBody !== undefined) {
95
- return composePRBody(userBody, metadata);
96
- }
97
-
98
- if (existingBody !== undefined) {
99
- const startIdx = existingBody.indexOf(STACKED_MARKER_START);
100
- if (startIdx !== -1) {
101
- const prefix = existingBody.substring(0, startIdx).replace(/\n*---\n*$/, "");
102
- if (prefix.trim().length > 0) {
103
- return `${prefix.trim()}\n\n---\n\n${metadata}`;
104
- }
105
- return metadata;
106
- }
107
- return `${existingBody.trim()}\n\n---\n\n${metadata}`;
108
- }
109
-
110
- return metadata;
111
- };
112
-
113
46
  const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
114
47
 
115
48
  export const submit = Command.make("submit", {
@@ -294,21 +227,16 @@ export const submit = Command.make("submit", {
294
227
 
295
228
  // Update all processed PRs with complete stack metadata.
296
229
  // This includes newly created PRs so placeholders get replaced in one submit run.
297
- for (let i = 0; i < branches.length; i++) {
298
- const branch = branches[i];
299
- if (branch === undefined) continue;
300
- if (only && branch !== currentBranch) continue;
301
-
302
- const entry = results.find((x) => x.branch === branch);
303
- if (entry === undefined) continue;
304
-
305
- const metadata = generateStackMetadata(branches, prMap, i, result.name);
306
- const existingPrData = prMap.get(branch) ?? null;
307
- const existingBody = existingPrData?.body ?? undefined;
308
- const userBody = getBodyForBranch(branch, i);
309
- const body = updatePRBody(existingBody, userBody, metadata);
310
- yield* gh.updatePR({ branch, body });
311
- }
230
+ yield* refreshStackedPRBodies({
231
+ branches,
232
+ stackName: result.name,
233
+ gh,
234
+ initialPrMap: prMap,
235
+ shouldUpdateBranch: (branch) =>
236
+ (!only || branch === currentBranch) && results.some((entry) => entry.branch === branch),
237
+ getUserBody: getBodyForBranch,
238
+ });
239
+ yield* stacks.unmarkMergedBranches(branches);
312
240
 
313
241
  // Print structured output to stdout
314
242
  if (json) {