@cvr/stacked 0.4.3 → 0.5.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.
@@ -80,20 +80,24 @@ export const create = Command.make("create", {
80
80
 
81
81
  const existing = yield* stacks.findBranchStack(baseBranch);
82
82
  let stackName = existing?.name ?? null;
83
+ let shouldAddBranch = true;
83
84
 
84
85
  yield* git.createBranch(name, baseBranch);
85
86
 
86
87
  if (stackName === null) {
87
88
  if (baseBranch === trunk) {
88
89
  stackName = name;
89
- yield* stacks.createStack(name, []);
90
+ shouldAddBranch = false;
91
+ yield* stacks.createStack(name, [name]);
90
92
  } else {
91
93
  stackName = baseBranch;
92
94
  yield* stacks.createStack(baseBranch, [baseBranch]);
93
95
  }
94
96
  }
95
97
 
96
- yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
98
+ if (shouldAddBranch) {
99
+ yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
100
+ }
97
101
 
98
102
  if (json) {
99
103
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
@@ -50,6 +50,7 @@ export const deleteCmd = Command.make("delete", {
50
50
 
51
51
  if (idx < stack.branches.length - 1 && !force) {
52
52
  return yield* new StackError({
53
+ code: ErrorCode.HAS_CHILDREN,
53
54
  message: `Branch "${name}" has children. Use --force to delete anyway.`,
54
55
  });
55
56
  }
@@ -101,7 +102,7 @@ export const deleteCmd = Command.make("delete", {
101
102
  }
102
103
 
103
104
  yield* git.deleteBranch(name, force);
104
- yield* stacks.removeBranch(stackName, name);
105
+ yield* stacks.removeBranch(name);
105
106
 
106
107
  if (willDeleteRemote) {
107
108
  yield* git.deleteRemoteBranch(name).pipe(Effect.catchTag("GitError", () => Effect.void));
@@ -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,13 +28,16 @@ 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
 
34
39
  if (untracked.length === 0) {
35
- yield* Console.error("No untracked branches found");
40
+ yield* info("No untracked branches found");
36
41
  return;
37
42
  }
38
43
 
@@ -42,48 +47,74 @@ 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
+ // Include both untracked and already-tracked branches in tip resolution
54
+ // so untracked branches can discover parents that are already in stacks
55
+ const trackedBranches = [...alreadyTracked];
56
+ const allCandidatesForTips = [...untracked, ...trackedBranches];
57
+
58
+ const tipResults = yield* Effect.forEach(
59
+ allCandidatesForTips,
60
+ (branch) =>
61
+ git.revParse(branch).pipe(
62
+ Effect.map((oid) => [branch, oid] as const),
63
+ Effect.catchTag("GitError", () => Effect.succeed(null)),
64
+ ),
65
+ { concurrency: 5 },
66
+ );
67
+
68
+ const tipOwners = new Map<string, string[]>();
69
+ for (const result of tipResults) {
70
+ if (result === null) continue;
71
+ const [branch, oid] = result;
72
+ const owners = tipOwners.get(oid) ?? [];
73
+ owners.push(branch);
74
+ tipOwners.set(oid, owners);
75
+ }
48
76
 
49
77
  yield* Effect.forEach(
50
78
  untracked,
51
79
  (branch) =>
52
80
  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
- );
81
+ const commits = yield* git
82
+ .firstParentUniqueCommits(branch, trunk, { limit: DETECT_COMMIT_LIMIT })
83
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed([])));
64
84
 
65
- const ancestors = ancestryResults.filter(([_, is]) => is).map(([name]) => name);
85
+ if (commits.length === 0) {
86
+ unclassified.push(branch);
87
+ return;
88
+ }
66
89
 
67
- if (ancestors.length === 0) return;
90
+ let parent: string | null = null;
91
+ let ambiguous = false;
68
92
 
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;
93
+ for (const oid of commits) {
94
+ const owners = (tipOwners.get(oid) ?? []).filter((owner) => owner !== branch);
95
+ if (owners.length > 1) {
96
+ ambiguous = true;
97
+ break;
98
+ }
99
+ const [owner] = owners;
100
+ if (owner !== undefined) {
101
+ parent = owner;
102
+ break;
103
+ }
78
104
  }
79
105
 
80
- childOf.set(branch, closest);
106
+ if (ambiguous || (commits.length >= DETECT_COMMIT_LIMIT && parent === null)) {
107
+ unclassified.push(branch);
108
+ return;
109
+ }
110
+
111
+ childOf.set(branch, parent ?? trunk);
81
112
  }),
82
113
  { concurrency: 5 },
83
114
  );
84
115
 
85
116
  // Build linear chains from trunk
86
- // Find branches whose parent is trunk (chain roots)
117
+ // Find branches whose parent is trunk or a tracked branch (chain roots)
87
118
  const childrenByParent = new Map<string, string[]>();
88
119
  for (const branch of untracked) {
89
120
  const parent = childOf.get(branch);
@@ -93,17 +124,48 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
93
124
  childrenByParent.set(parent, children);
94
125
  }
95
126
 
127
+ // Branches whose parent is already tracked — extend existing stacks
128
+ const adoptions = new Map<string, string[]>(); // trackedParent → [children]
129
+ for (const branch of untracked) {
130
+ const parent = childOf.get(branch);
131
+ if (parent !== undefined && alreadyTracked.has(parent)) {
132
+ const siblings = childrenByParent.get(parent) ?? [];
133
+ if (siblings.length === 1) {
134
+ const existing = adoptions.get(parent) ?? [];
135
+ // Walk the chain from this branch
136
+ const chain = [branch];
137
+ let current = branch;
138
+ while (true) {
139
+ const children = childrenByParent.get(current) ?? [];
140
+ const child = children[0];
141
+ if (children.length === 1 && child !== undefined) {
142
+ chain.push(child);
143
+ current = child;
144
+ } else break;
145
+ }
146
+ adoptions.set(parent, [...existing, ...chain]);
147
+ }
148
+ }
149
+ }
150
+
96
151
  const chains: string[][] = [];
97
152
  const roots = childrenByParent.get(trunk) ?? [];
98
153
 
154
+ // Track branches consumed by adoptions so they're not double-counted
155
+ const adoptedBranches = new Set<string>();
156
+ for (const branches of adoptions.values()) {
157
+ for (const b of branches) adoptedBranches.add(b);
158
+ }
159
+
99
160
  for (const root of roots) {
161
+ if (adoptedBranches.has(root)) continue;
100
162
  const chain = [root];
101
163
  let current = root;
102
164
 
103
165
  while (true) {
104
166
  const children = childrenByParent.get(current) ?? [];
105
167
  const child = children[0];
106
- if (children.length === 1 && child !== undefined) {
168
+ if (children.length === 1 && child !== undefined && !adoptedBranches.has(child)) {
107
169
  chain.push(child);
108
170
  current = child;
109
171
  } else {
@@ -124,21 +186,45 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
124
186
 
125
187
  if (json) {
126
188
  const stacksData = chains.map((chain) => ({ name: chain[0] ?? "", branches: chain }));
189
+ const adoptionData = [...adoptions.entries()].map(([parent, branches]) => ({
190
+ parent,
191
+ branches,
192
+ }));
127
193
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
128
- yield* Console.log(JSON.stringify({ stacks: stacksData, forks }, null, 2));
194
+ yield* Console.log(
195
+ JSON.stringify({ stacks: stacksData, adoptions: adoptionData, forks }, null, 2),
196
+ );
129
197
  return;
130
198
  }
131
199
 
132
- if (chains.length === 0) {
200
+ if (chains.length === 0 && adoptions.size === 0) {
133
201
  yield* info("No linear branch chains detected");
134
202
  return;
135
203
  }
136
204
 
205
+ // Adopt branches into existing stacks
206
+ for (const [parent, branches] of adoptions) {
207
+ const parentStack = yield* stacks.findBranchStack(parent);
208
+ if (parentStack === null) continue;
209
+ for (const branch of branches) {
210
+ if (dryRun) {
211
+ yield* Console.error(
212
+ `Would adopt "${branch}" into stack "${parentStack.name}" after "${parent}"`,
213
+ );
214
+ } else {
215
+ yield* stacks.addBranch(parentStack.name, branch, parent);
216
+ yield* success(
217
+ `Adopted "${branch}" into stack "${parentStack.name}" after "${parent}"`,
218
+ );
219
+ }
220
+ }
221
+ }
222
+
137
223
  for (const chain of chains) {
138
224
  const name = chain[0];
139
225
  if (name === undefined) continue;
140
- const currentData = yield* stacks.load();
141
- if (currentData.stacks[name] !== undefined) {
226
+ const existing = yield* stacks.getStack(name);
227
+ if (existing !== null) {
142
228
  yield* warn(`Stack "${name}" already exists, skipping: ${chain.join(" → ")}`);
143
229
  continue;
144
230
  }
@@ -151,9 +237,8 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
151
237
  }
152
238
 
153
239
  if (dryRun) {
154
- yield* Console.error(
155
- `\n${chains.length} stack${chains.length === 1 ? "" : "s"} would be created`,
156
- );
240
+ const total = chains.length + adoptions.size;
241
+ yield* Console.error(`\n${total} action${total === 1 ? "" : "s"} would be performed`);
157
242
  }
158
243
 
159
244
  if (forks.length > 0) {
@@ -162,6 +247,12 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
162
247
  yield* Console.error(` ${fork.branch} → ${fork.children.join(", ")}`);
163
248
  }
164
249
  }
250
+
251
+ if (unclassified.length > 0 && !json) {
252
+ yield* warn(
253
+ `Skipped ${unclassified.length} unclassified branch${unclassified.length === 1 ? "" : "es"}: ${unclassified.join(", ")}`,
254
+ );
255
+ }
165
256
  }),
166
257
  ),
167
258
  );
@@ -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,16 @@ 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
+ const allGitBranches = yield* git
70
+ .listBranches()
71
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed([] as string[])));
72
+ const gitBranchSet = new Set(allGitBranches);
73
+
74
+ for (const { name: stackName, stack } of stackEntries) {
62
75
  for (const branch of stack.branches) {
63
- const exists = yield* git
64
- .branchExists(branch)
65
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
66
- if (!exists) {
76
+ if (!gitBranchSet.has(branch)) {
67
77
  if (fix) {
68
- yield* stacks.removeBranch(stackName, branch);
78
+ yield* stacks.removeBranch(branch);
69
79
  findings.push({
70
80
  type: "stale_branch",
71
81
  message: `Removed stale branch "${branch}" from stack "${stackName}"`,
@@ -84,12 +94,10 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
84
94
 
85
95
  // Check 3: no branches in multiple stacks
86
96
  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
- }
97
+ for (const [branch, record] of Object.entries(data.branches)) {
98
+ const existing = branchToStacks.get(branch) ?? [];
99
+ existing.push(record.stack);
100
+ branchToStacks.set(branch, existing);
93
101
  }
94
102
  for (const [branch, stackNames] of branchToStacks) {
95
103
  if (stackNames.length > 1) {
@@ -0,0 +1,139 @@
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.forEach(
103
+ branches,
104
+ (branch) => {
105
+ const existing = initialPrMap?.get(branch);
106
+ if (existing !== undefined) {
107
+ return Effect.succeed([branch, existing] as const);
108
+ }
109
+ return gh.getPR(branch).pipe(Effect.map((pr) => [branch, pr] as const));
110
+ },
111
+ { concurrency: 5 },
112
+ );
113
+ const prMap = new Map(prEntries);
114
+
115
+ // Collect all updates, then apply in parallel
116
+ const updates: Array<{ branch: string; body: string }> = [];
117
+ for (let i = 0; i < branches.length; i++) {
118
+ const branch = branches[i];
119
+ if (branch === undefined) continue;
120
+ if (shouldUpdateBranch !== undefined && !shouldUpdateBranch(branch)) continue;
121
+
122
+ const existingPrData = prMap.get(branch) ?? null;
123
+ if (existingPrData === null || existingPrData.state !== "OPEN") continue;
124
+
125
+ const metadata = generateStackMetadata(branches, prMap, i, stackName);
126
+ const body = updatePRBody(
127
+ existingPrData.body ?? undefined,
128
+ getUserBody?.(branch, i),
129
+ metadata,
130
+ );
131
+ updates.push({ branch, body });
132
+ }
133
+
134
+ yield* Effect.forEach(updates, ({ branch, body }) => gh.updatePR({ branch, body }), {
135
+ concurrency: 5,
136
+ });
137
+
138
+ return prMap;
139
+ });
@@ -25,7 +25,7 @@ import { amend } from "./amend.js";
25
25
 
26
26
  const root = Command.make("stacked").pipe(
27
27
  Command.withDescription(
28
- "Branch-based stacked PR manager\n\nGlobal flags:\n --verbose Show detailed output\n --quiet, -q Suppress non-essential output\n --no-color Disable color output\n --yes, -y Skip confirmation prompts",
28
+ "Branch-based stacked PR manager\n\nGlobal flags:\n --verbose, -v Show detailed output\n --quiet, -q Suppress non-essential output\n --no-color Disable color output\n --yes, -y Skip confirmation prompts",
29
29
  ),
30
30
  Command.withExamples([
31
31
  { command: "stacked create feat-auth", description: "Create a new branch in the stack" },
@@ -1,4 +1,4 @@
1
- import { Command } from "effect/unstable/cli";
1
+ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Config, Console, Effect } from "effect";
3
3
  import { StackError } from "../errors/index.js";
4
4
  import { mkdirSync, writeFileSync } from "fs";
@@ -7,10 +7,12 @@ import { homedir } from "os";
7
7
 
8
8
  const skillContent = typeof __SKILL_CONTENT__ !== "undefined" ? __SKILL_CONTENT__ : null;
9
9
 
10
- export const init = Command.make("init").pipe(
10
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
11
+
12
+ export const init = Command.make("init", { json: jsonFlag }).pipe(
11
13
  Command.withDescription("Install the stacked Claude skill to ~/.claude/skills"),
12
14
  Command.withExamples([{ command: "stacked init", description: "Install the Claude skill" }]),
13
- Command.withHandler(() =>
15
+ Command.withHandler(({ json }) =>
14
16
  Effect.gen(function* () {
15
17
  if (skillContent === null) {
16
18
  return yield* new StackError({
@@ -24,7 +26,9 @@ export const init = Command.make("init").pipe(
24
26
  const targetDir = join(skillsDir, "stacked");
25
27
  const targetPath = join(targetDir, "SKILL.md");
26
28
 
27
- yield* Console.error(`Writing skill to ${targetPath}...`);
29
+ if (!json) {
30
+ yield* Console.error(`Writing skill to ${targetPath}...`);
31
+ }
28
32
  yield* Effect.try({
29
33
  try: () => {
30
34
  mkdirSync(targetDir, { recursive: true });
@@ -33,12 +37,17 @@ export const init = Command.make("init").pipe(
33
37
  catch: (e) => new StackError({ message: `Failed to write skill: ${e}` }),
34
38
  });
35
39
 
36
- yield* Console.error(`Installed stacked skill to ${targetPath}`);
37
- yield* Console.error("\nNext steps:");
38
- yield* Console.error(" stacked create <name> # start your first stack");
39
- yield* Console.error(
40
- " stacked trunk <name> # only if auto-detection picks the wrong trunk",
41
- );
40
+ if (json) {
41
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
42
+ yield* Console.log(JSON.stringify({ path: targetPath }, null, 2));
43
+ } else {
44
+ yield* Console.error(`Installed stacked skill to ${targetPath}`);
45
+ yield* Console.error("\nNext steps:");
46
+ yield* Console.error(" stacked create <name> # start your first stack");
47
+ yield* Console.error(
48
+ " stacked trunk <name> # only if auto-detection picks the wrong trunk",
49
+ );
50
+ }
42
51
  }),
43
52
  ),
44
53
  );
@@ -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`,
@@ -28,13 +28,23 @@ export const log = Command.make("log", { json: jsonFlag }).pipe(
28
28
 
29
29
  const trunk = yield* stacks.getTrunk();
30
30
  const { branches } = result.stack;
31
+ const data = yield* stacks.load();
32
+ const mergedSet = new Set(data.mergedBranches);
33
+
34
+ const effectiveBase = (i: number): string => {
35
+ for (let j = i - 1; j >= 0; j--) {
36
+ const candidate = branches[j];
37
+ if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
38
+ }
39
+ return trunk;
40
+ };
31
41
 
32
42
  if (json) {
33
43
  const entries = [];
34
44
  for (let i = 0; i < branches.length; i++) {
35
45
  const branch = branches[i];
36
46
  if (branch === undefined) continue;
37
- const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
47
+ const base = effectiveBase(i);
38
48
  const commits = yield* git
39
49
  .log(`${base}..${branch}`, { oneline: true })
40
50
  .pipe(Effect.catchTag("GitError", () => Effect.succeed("")));
@@ -48,7 +58,7 @@ export const log = Command.make("log", { json: jsonFlag }).pipe(
48
58
  for (let i = 0; i < branches.length; i++) {
49
59
  const branch = branches[i];
50
60
  if (branch === undefined) continue;
51
- const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
61
+ const base = effectiveBase(i);
52
62
  yield* Console.log(`\n── ${branch} ──`);
53
63
  const rangeLog = yield* git
54
64
  .log(`${base}..${branch}`, { oneline: true })
@@ -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