@cvr/stacked 0.2.0 → 0.4.0

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