@cvr/stacked 0.3.0 → 0.4.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,68 +1,324 @@
1
1
  import { Command, Flag } from "effect/unstable/cli";
2
- import { Console, Effect } from "effect";
2
+ import { Console, Effect, Option } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
  import { GitHubService } from "../services/GitHub.js";
6
+ import { ErrorCode, StackError } from "../errors/index.js";
7
+ import { withSpinner, success } from "../ui.js";
6
8
 
7
- const draftFlag = Flag.boolean("draft").pipe(Flag.withAlias("d"));
8
- const forceFlag = Flag.boolean("force").pipe(Flag.withAlias("f"));
9
- const dryRunFlag = Flag.boolean("dry-run");
9
+ const draftFlag = Flag.boolean("draft").pipe(
10
+ Flag.withAlias("d"),
11
+ Flag.withDescription("Create PRs as drafts"),
12
+ );
13
+ const noForceFlag = Flag.boolean("no-force").pipe(
14
+ Flag.withDescription("Disable force-push (force-with-lease is on by default)"),
15
+ );
16
+ const dryRunFlag = Flag.boolean("dry-run").pipe(
17
+ Flag.withDescription("Show what would happen without making changes"),
18
+ );
19
+ const titleFlag = Flag.string("title").pipe(
20
+ Flag.optional,
21
+ Flag.withAlias("t"),
22
+ Flag.withDescription(
23
+ "PR title (defaults to branch name). Comma-delimited for per-branch titles.",
24
+ ),
25
+ );
26
+ const bodyFlag = Flag.string("body").pipe(
27
+ Flag.optional,
28
+ Flag.withAlias("b"),
29
+ Flag.withDescription("PR body/description. Comma-delimited for per-branch bodies."),
30
+ );
31
+ const onlyFlag = Flag.boolean("only").pipe(Flag.withDescription("Only submit the current branch"));
32
+
33
+ interface SubmitResult {
34
+ branch: string;
35
+ base: string;
36
+ number?: number;
37
+ url?: string;
38
+ action: "created" | "updated" | "unchanged" | "would-create" | "would-update" | "would-unchanged";
39
+ }
40
+
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
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
10
114
 
11
115
  export const submit = Command.make("submit", {
12
116
  draft: draftFlag,
13
- force: forceFlag,
117
+ noForce: noForceFlag,
14
118
  dryRun: dryRunFlag,
119
+ title: titleFlag,
120
+ body: bodyFlag,
121
+ only: onlyFlag,
122
+ json: jsonFlag,
15
123
  }).pipe(
16
124
  Command.withDescription("Push all stack branches and create/update PRs via gh"),
17
- Command.withHandler(({ draft, force, dryRun }) =>
125
+ Command.withExamples([
126
+ { command: "stacked submit", description: "Push and create/update PRs for all branches" },
127
+ { command: "stacked submit --draft", description: "Create PRs as drafts" },
128
+ { command: "stacked submit --only", description: "Submit only the current branch" },
129
+ {
130
+ command: 'stacked submit --title "Add auth" --body "Implements OAuth2"',
131
+ description: "With PR title and body",
132
+ },
133
+ ]),
134
+ Command.withHandler(({ draft, noForce, dryRun, title: titleOpt, body: bodyOpt, only, json }) =>
18
135
  Effect.gen(function* () {
19
136
  const git = yield* GitService;
20
137
  const stacks = yield* StackService;
21
138
  const gh = yield* GitHubService;
22
139
 
140
+ const ghInstalled = yield* gh.isGhInstalled();
141
+ if (!ghInstalled) {
142
+ return yield* new StackError({
143
+ code: ErrorCode.GH_NOT_INSTALLED,
144
+ message: "gh CLI is not installed. Install it from https://cli.github.com",
145
+ });
146
+ }
147
+
23
148
  const result = yield* stacks.currentStack();
24
149
  if (result === null) {
25
- yield* Console.error("Not on a stacked branch");
26
- return;
150
+ return yield* new StackError({
151
+ code: ErrorCode.NOT_IN_STACK,
152
+ message:
153
+ "Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
154
+ });
27
155
  }
28
156
 
29
157
  const trunk = yield* stacks.getTrunk();
158
+ const currentBranch = yield* git.currentBranch();
30
159
  const { branches } = result.stack;
31
160
 
161
+ const rawTitle = Option.isSome(titleOpt) ? titleOpt.value : undefined;
162
+ const rawBody = Option.isSome(bodyOpt) ? bodyOpt.value : undefined;
163
+
164
+ // Parse comma-delimited titles/bodies for per-branch support
165
+ const titles =
166
+ rawTitle !== undefined && rawTitle.includes(",")
167
+ ? rawTitle.split(",").map((s) => s.trim())
168
+ : undefined;
169
+ const bodies =
170
+ rawBody !== undefined && rawBody.includes(",")
171
+ ? rawBody.split(",").map((s) => s.trim())
172
+ : undefined;
173
+
174
+ if (titles !== undefined && titles.length !== branches.length) {
175
+ return yield* new StackError({
176
+ message: `--title has ${titles.length} values but stack has ${branches.length} branches`,
177
+ });
178
+ }
179
+ if (bodies !== undefined && bodies.length !== branches.length) {
180
+ return yield* new StackError({
181
+ message: `--body has ${bodies.length} values but stack has ${branches.length} branches`,
182
+ });
183
+ }
184
+
185
+ const getTitleForBranch = (branch: string, idx: number): string | undefined => {
186
+ if (titles !== undefined) return titles[idx];
187
+ // Single --title: apply only to current branch
188
+ if (rawTitle !== undefined && branch === currentBranch) return rawTitle;
189
+ return undefined;
190
+ };
191
+
192
+ const getBodyForBranch = (branch: string, idx: number): string | undefined => {
193
+ if (bodies !== undefined) return bodies[idx];
194
+ // Single --body: apply only to current branch
195
+ if (rawBody !== undefined && branch === currentBranch) return rawBody;
196
+ return undefined;
197
+ };
198
+
199
+ const results: SubmitResult[] = [];
200
+ const prMap = new Map<
201
+ string,
202
+ { number: number; url: string; state: string; body?: string | null } | null
203
+ >();
204
+
32
205
  for (let i = 0; i < branches.length; i++) {
33
206
  const branch = branches[i];
34
207
  if (branch === undefined) continue;
35
208
  const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
36
209
 
210
+ // --only: skip branches that aren't current
211
+ if (only && branch !== currentBranch) continue;
212
+
37
213
  if (dryRun) {
38
- yield* Console.log(`Would push ${branch} and create/update PR (base: ${base})`);
214
+ const existingPR = yield* gh.getPR(branch);
215
+ const action =
216
+ existingPR === null
217
+ ? "would-create"
218
+ : existingPR.base !== base
219
+ ? "would-update"
220
+ : "would-unchanged";
221
+ results.push({
222
+ branch,
223
+ base,
224
+ number: existingPR?.number,
225
+ url: existingPR?.url,
226
+ action,
227
+ });
228
+ if (!json) {
229
+ yield* Console.error(`Would push ${branch} and create/update PR (base: ${base})`);
230
+ }
39
231
  continue;
40
232
  }
41
233
 
42
- yield* Console.log(`Pushing ${branch}...`);
43
- yield* git.push(branch, { force });
234
+ yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: !noForce }));
44
235
 
45
236
  const existingPR = yield* gh.getPR(branch);
237
+ prMap.set(branch, existingPR);
46
238
 
47
239
  if (existingPR !== null) {
48
240
  if (existingPR.base !== base) {
49
- yield* Console.log(`Updating PR #${existingPR.number} base to ${base}`);
241
+ yield* Console.error(`Updating PR #${existingPR.number} base to ${base}`);
50
242
  yield* gh.updatePR({ branch, base });
243
+ results.push({
244
+ branch,
245
+ base,
246
+ number: existingPR.number,
247
+ url: existingPR.url,
248
+ action: "updated",
249
+ });
51
250
  } else {
52
- yield* Console.log(`PR #${existingPR.number} already exists: ${existingPR.url}`);
251
+ yield* Console.error(`PR #${existingPR.number} already exists: ${existingPR.url}`);
252
+ results.push({
253
+ branch,
254
+ base,
255
+ number: existingPR.number,
256
+ url: existingPR.url,
257
+ action: "unchanged",
258
+ });
53
259
  }
54
260
  } else {
261
+ const userTitle = getTitleForBranch(branch, i);
262
+ const title =
263
+ userTitle ?? branch.replace(/[-_]/g, " ").replace(/^\w/, (c) => c.toUpperCase());
264
+ const metadata = generateStackMetadata(branches, prMap, i, result.name);
265
+ const userBody = getBodyForBranch(branch, i);
266
+ const body = composePRBody(userBody, metadata);
267
+
55
268
  const pr = yield* gh.createPR({
56
269
  head: branch,
57
270
  base,
58
- title: branch,
271
+ title,
272
+ body,
59
273
  draft,
60
274
  });
61
- yield* Console.log(`Created PR #${pr.number}: ${pr.url}`);
275
+ prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN" });
276
+ yield* success(`Created PR #${pr.number}: ${pr.url}`);
277
+ results.push({
278
+ branch,
279
+ base,
280
+ number: pr.number,
281
+ url: pr.url,
282
+ action: "created",
283
+ });
284
+ }
285
+ }
286
+
287
+ if (dryRun) {
288
+ if (json) {
289
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
290
+ yield* Console.log(JSON.stringify({ results }, null, 2));
62
291
  }
292
+ return;
293
+ }
294
+
295
+ // Update all processed PRs with complete stack metadata.
296
+ // 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 });
63
311
  }
64
312
 
65
- yield* Console.log("Done");
313
+ // Print structured output to stdout
314
+ if (json) {
315
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
316
+ yield* Console.log(JSON.stringify({ results }, null, 2));
317
+ } else {
318
+ for (const r of results) {
319
+ yield* Console.log(`${r.branch} #${r.number} ${r.url} ${r.action}`);
320
+ }
321
+ }
66
322
  }),
67
323
  ),
68
324
  );
@@ -2,13 +2,45 @@ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Console, Effect, Option } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
+ import { ErrorCode, StackError } from "../errors/index.js";
6
+ import { withSpinner, success, warn } from "../ui.js";
5
7
 
6
- const trunkFlag = Flag.string("trunk").pipe(Flag.optional, Flag.withAlias("t"));
7
- const fromFlag = Flag.string("from").pipe(Flag.optional, Flag.withAlias("f"));
8
+ const trunkFlag = Flag.string("trunk").pipe(
9
+ Flag.optional,
10
+ Flag.withAlias("t"),
11
+ Flag.withDescription("Override trunk branch for this sync"),
12
+ );
13
+ const fromFlag = Flag.string("from").pipe(
14
+ Flag.optional,
15
+ Flag.withAlias("f"),
16
+ Flag.withDescription("Start rebasing after this branch (exclusive)"),
17
+ );
18
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
19
+ const dryRunFlag = Flag.boolean("dry-run").pipe(
20
+ Flag.withDescription("Show rebase plan without executing"),
21
+ );
8
22
 
9
- export const sync = Command.make("sync", { trunk: trunkFlag, from: fromFlag }).pipe(
10
- Command.withDescription("Fetch and rebase stack on trunk. Use --from to start from a branch."),
11
- Command.withHandler(({ trunk: trunkOpt, from: fromOpt }) =>
23
+ interface SyncResult {
24
+ name: string;
25
+ action: "rebased" | "skipped" | "up-to-date";
26
+ base: string;
27
+ }
28
+
29
+ export const sync = Command.make("sync", {
30
+ trunk: trunkFlag,
31
+ from: fromFlag,
32
+ json: jsonFlag,
33
+ dryRun: dryRunFlag,
34
+ }).pipe(
35
+ Command.withDescription(
36
+ "Fetch, rebase, and force-push stack branches. Use --from to start from a branch.",
37
+ ),
38
+ Command.withExamples([
39
+ { command: "stacked sync", description: "Rebase entire stack on trunk" },
40
+ { command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
41
+ { command: "stacked sync --dry-run", description: "Preview rebase plan" },
42
+ ]),
43
+ Command.withHandler(({ trunk: trunkOpt, from: fromOpt, json, dryRun }) =>
12
44
  Effect.gen(function* () {
13
45
  const git = yield* GitService;
14
46
  const stacks = yield* StackService;
@@ -16,13 +48,23 @@ export const sync = Command.make("sync", { trunk: trunkFlag, from: fromFlag }).p
16
48
  const trunk = Option.isSome(trunkOpt) ? trunkOpt.value : yield* stacks.getTrunk();
17
49
  const currentBranch = yield* git.currentBranch();
18
50
 
19
- yield* Console.log(`Fetching ${trunk}...`);
20
- yield* git.fetch();
51
+ if (!dryRun) {
52
+ const clean = yield* git.isClean();
53
+ if (!clean) {
54
+ return yield* new StackError({
55
+ code: ErrorCode.DIRTY_WORKTREE,
56
+ message: "Working tree has uncommitted changes. Commit or stash before syncing.",
57
+ });
58
+ }
59
+ }
21
60
 
22
61
  const result = yield* stacks.currentStack();
23
62
  if (result === null) {
24
- yield* Console.error("Not on a stacked branch");
25
- return;
63
+ return yield* new StackError({
64
+ code: ErrorCode.NOT_IN_STACK,
65
+ message:
66
+ "Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
67
+ });
26
68
  }
27
69
 
28
70
  const { branches } = result.stack;
@@ -32,23 +74,92 @@ export const sync = Command.make("sync", { trunk: trunkFlag, from: fromFlag }).p
32
74
  if (fromBranch !== undefined) {
33
75
  const idx = branches.indexOf(fromBranch);
34
76
  if (idx === -1) {
35
- yield* Console.error(`Branch "${fromBranch}" not found in stack`);
36
- return;
77
+ return yield* new StackError({
78
+ code: ErrorCode.BRANCH_NOT_FOUND,
79
+ message: `Branch "${fromBranch}" not found in stack`,
80
+ });
37
81
  }
38
82
  startIdx = idx + 1;
83
+ if (startIdx >= branches.length) {
84
+ yield* warn(`Nothing to sync — ${fromBranch} is the last branch in the stack`);
85
+ return;
86
+ }
39
87
  }
40
88
 
41
- for (let i = startIdx; i < branches.length; i++) {
42
- const branch = branches[i];
43
- if (branch === undefined) continue;
44
- const base = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
45
- yield* Console.log(`Rebasing ${branch} onto ${base}...`);
46
- yield* git.checkout(branch);
47
- yield* git.rebase(base);
89
+ const results: SyncResult[] = [];
90
+
91
+ if (dryRun) {
92
+ for (let i = startIdx; i < branches.length; i++) {
93
+ const branch = branches[i];
94
+ if (branch === undefined) continue;
95
+ const base = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
96
+ results.push({ name: branch, action: "skipped", base });
97
+ if (!json) {
98
+ yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
99
+ }
100
+ }
101
+
102
+ if (json) {
103
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
104
+ yield* Console.log(JSON.stringify({ branches: results }, null, 2));
105
+ } else {
106
+ yield* Console.error(
107
+ `\n${results.length} branch${results.length === 1 ? "" : "es"} would be rebased`,
108
+ );
109
+ }
110
+ return;
48
111
  }
49
112
 
50
- yield* git.checkout(currentBranch);
51
- yield* Console.log("Stack synced");
113
+ yield* withSpinner(`Fetching ${trunk}`, git.fetch());
114
+
115
+ yield* Effect.gen(function* () {
116
+ for (let i = startIdx; i < branches.length; i++) {
117
+ const branch = branches[i];
118
+ if (branch === undefined) continue;
119
+ const newBase = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
120
+
121
+ // Compute old base (merge-base of this branch and its parent) before rebasing
122
+ const oldBase = yield* git
123
+ .mergeBase(branch, newBase)
124
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase)));
125
+
126
+ yield* git.checkout(branch);
127
+ yield* withSpinner(
128
+ `Rebasing ${branch} onto ${newBase}`,
129
+ git.rebaseOnto(branch, newBase, oldBase),
130
+ ).pipe(
131
+ Effect.catchTag("GitError", (e) => {
132
+ const hint =
133
+ i === 0 ? "stacked sync" : `stacked sync --from ${branches[i - 1] ?? trunk}`;
134
+ return Effect.fail(
135
+ new StackError({
136
+ code: ErrorCode.REBASE_CONFLICT,
137
+ message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue\n ${hint}`,
138
+ }),
139
+ );
140
+ }),
141
+ );
142
+ yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
143
+ results.push({ name: branch, action: "rebased", base: newBase });
144
+ }
145
+ }).pipe(
146
+ Effect.ensuring(
147
+ git
148
+ .isRebaseInProgress()
149
+ .pipe(
150
+ Effect.andThen((inProgress) =>
151
+ inProgress ? Effect.void : git.checkout(currentBranch).pipe(Effect.ignore),
152
+ ),
153
+ ),
154
+ ),
155
+ );
156
+
157
+ if (json) {
158
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
159
+ yield* Console.log(JSON.stringify({ branches: results }, null, 2));
160
+ } else {
161
+ yield* success("Stack synced");
162
+ }
52
163
  }),
53
164
  ),
54
165
  );
@@ -1,29 +1,45 @@
1
- import { Command } from "effect/unstable/cli";
1
+ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
+ import { ErrorCode, StackError } from "../errors/index.js";
6
+ import { success } from "../ui.js";
5
7
 
6
- export const top = Command.make("top").pipe(
8
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
9
+
10
+ export const top = Command.make("top", { json: jsonFlag }).pipe(
7
11
  Command.withDescription("Checkout top branch of stack"),
8
- Command.withHandler(() =>
12
+ Command.withExamples([{ command: "stacked top", description: "Jump to the top of the stack" }]),
13
+ Command.withHandler(({ json }) =>
9
14
  Effect.gen(function* () {
10
15
  const git = yield* GitService;
11
16
  const stacks = yield* StackService;
12
17
 
13
18
  const result = yield* stacks.currentStack();
14
19
  if (result === null) {
15
- yield* Console.error("Not on a stacked branch");
16
- return;
20
+ return yield* new StackError({
21
+ code: ErrorCode.NOT_IN_STACK,
22
+ message:
23
+ "Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
24
+ });
17
25
  }
18
26
 
19
27
  const topBranch = result.stack.branches[result.stack.branches.length - 1];
20
28
  if (topBranch === undefined) {
21
- yield* Console.error("Stack is empty");
22
- return;
29
+ return yield* new StackError({
30
+ code: ErrorCode.STACK_EMPTY,
31
+ message: "Stack is empty. Run 'stacked create <name>' to add a branch.",
32
+ });
23
33
  }
24
34
 
25
35
  yield* git.checkout(topBranch);
26
- yield* Console.log(`Switched to ${topBranch}`);
36
+
37
+ if (json) {
38
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
39
+ yield* Console.log(JSON.stringify({ branch: topBranch }, null, 2));
40
+ } else {
41
+ yield* success(`Switched to ${topBranch}`);
42
+ }
27
43
  }),
28
44
  ),
29
45
  );
@@ -1,20 +1,51 @@
1
- import { Argument, Command } from "effect/unstable/cli";
1
+ import { Argument, Command, Flag } from "effect/unstable/cli";
2
2
  import { Console, Effect, Option } from "effect";
3
+ import { GitService } from "../services/Git.js";
3
4
  import { StackService } from "../services/Stack.js";
5
+ import { ErrorCode, StackError } from "../errors/index.js";
6
+ import { validateBranchName } from "./helpers/validate.js";
4
7
 
5
- const nameArg = Argument.string("name").pipe(Argument.optional);
8
+ const nameArg = Argument.string("name").pipe(
9
+ Argument.withDescription("Trunk branch name to set"),
10
+ Argument.optional,
11
+ );
12
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
6
13
 
7
- export const trunk = Command.make("trunk", { name: nameArg }).pipe(
14
+ export const trunk = Command.make("trunk", { name: nameArg, json: jsonFlag }).pipe(
8
15
  Command.withDescription("Get or set the trunk branch"),
9
- Command.withHandler(({ name }) =>
16
+ Command.withExamples([
17
+ { command: "stacked trunk", description: "Print current trunk branch" },
18
+ { command: "stacked trunk develop", description: "Set trunk to develop" },
19
+ { command: "stacked trunk --json", description: "JSON output" },
20
+ ]),
21
+ Command.withHandler(({ name, json }) =>
10
22
  Effect.gen(function* () {
23
+ const git = yield* GitService;
11
24
  const stacks = yield* StackService;
12
25
  if (Option.isSome(name)) {
26
+ yield* validateBranchName(name.value);
27
+ const exists = yield* git.branchExists(name.value);
28
+ if (!exists) {
29
+ return yield* new StackError({
30
+ code: ErrorCode.BRANCH_NOT_FOUND,
31
+ message: `Branch "${name.value}" does not exist`,
32
+ });
33
+ }
13
34
  yield* stacks.setTrunk(name.value);
14
- yield* Console.log(`Trunk set to ${name.value}`);
35
+ if (json) {
36
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
37
+ yield* Console.log(JSON.stringify({ trunk: name.value }, null, 2));
38
+ } else {
39
+ yield* Console.error(`Trunk set to ${name.value}`);
40
+ }
15
41
  } else {
16
42
  const current = yield* stacks.getTrunk();
17
- yield* Console.log(current);
43
+ if (json) {
44
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
45
+ yield* Console.log(JSON.stringify({ trunk: current }, null, 2));
46
+ } else {
47
+ yield* Console.log(current);
48
+ }
18
49
  }
19
50
  }),
20
51
  ),