@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.
@@ -2,26 +2,80 @@ import { Argument, 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 { StackError } from "../errors/index.js";
5
+ import { ErrorCode, StackError } from "../errors/index.js";
6
+ import { validateBranchName } from "./helpers/validate.js";
7
+ import { dim } from "../ui.js";
6
8
 
7
- const branchArg = Argument.string("branch");
8
- const afterFlag = Flag.string("after").pipe(Flag.optional, Flag.withAlias("a"));
9
+ const branchArg = Argument.string("branch").pipe(Argument.withDescription("Branch name to adopt"));
10
+ const afterFlag = Flag.string("after").pipe(
11
+ Flag.optional,
12
+ Flag.withAlias("a"),
13
+ Flag.withDescription("Insert after this branch in the stack"),
14
+ );
15
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
9
16
 
10
- export const adopt = Command.make("adopt", { branch: branchArg, after: afterFlag }).pipe(
17
+ export const adopt = Command.make("adopt", {
18
+ branch: branchArg,
19
+ after: afterFlag,
20
+ json: jsonFlag,
21
+ }).pipe(
11
22
  Command.withDescription("Adopt existing git branch into current stack"),
12
- Command.withHandler(({ branch, after }) =>
23
+ Command.withExamples([
24
+ { command: "stacked adopt feat-existing", description: "Add branch to current stack" },
25
+ {
26
+ command: "stacked adopt feat-x --after feat-a",
27
+ description: "Insert after a specific branch",
28
+ },
29
+ ]),
30
+ Command.withHandler(({ branch, after, json }) =>
13
31
  Effect.gen(function* () {
14
32
  const git = yield* GitService;
15
33
  const stacks = yield* StackService;
16
34
 
35
+ yield* validateBranchName(branch);
36
+
37
+ const trunk = yield* stacks.getTrunk();
38
+ if (branch === trunk) {
39
+ return yield* new StackError({
40
+ code: ErrorCode.TRUNK_ERROR,
41
+ message: `Cannot adopt trunk branch "${trunk}" into a stack`,
42
+ });
43
+ }
44
+
17
45
  const exists = yield* git.branchExists(branch);
18
46
  if (!exists) {
19
- return yield* new StackError({ message: `Branch "${branch}" does not exist` });
47
+ return yield* new StackError({
48
+ code: ErrorCode.BRANCH_NOT_FOUND,
49
+ message: `Branch "${branch}" does not exist`,
50
+ });
51
+ }
52
+
53
+ const alreadyTracked = yield* stacks.findBranchStack(branch);
54
+ if (alreadyTracked !== null) {
55
+ const result = yield* stacks.currentStack();
56
+ if (result !== null && alreadyTracked.name === result.name) {
57
+ if (json) {
58
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
59
+ yield* Console.log(
60
+ JSON.stringify(
61
+ { adopted: branch, stack: result.name, alreadyTracked: true },
62
+ null,
63
+ 2,
64
+ ),
65
+ );
66
+ } else {
67
+ yield* Console.error(`Branch "${branch}" is already in stack "${result.name}"`);
68
+ }
69
+ return;
70
+ }
71
+ return yield* new StackError({
72
+ code: ErrorCode.BRANCH_EXISTS,
73
+ message: `Branch "${branch}" is already tracked in stack "${alreadyTracked.name}"`,
74
+ });
20
75
  }
21
76
 
22
77
  const result = yield* stacks.currentStack();
23
78
  if (result === null) {
24
- const trunk = yield* stacks.getTrunk();
25
79
  const currentBranch = yield* git.currentBranch();
26
80
  if (currentBranch === trunk) {
27
81
  yield* stacks.createStack(branch, [branch]);
@@ -33,7 +87,15 @@ export const adopt = Command.make("adopt", { branch: branchArg, after: afterFlag
33
87
  yield* stacks.addBranch(result.name, branch, afterBranch);
34
88
  }
35
89
 
36
- yield* Console.log(`Adopted ${branch} into stack`);
90
+ if (json) {
91
+ const stackResult = yield* stacks.currentStack();
92
+ const stackName = stackResult?.name ?? branch;
93
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
94
+ yield* Console.log(JSON.stringify({ adopted: branch, stack: stackName }, null, 2));
95
+ } else {
96
+ yield* Console.error(`Adopted ${branch} into stack`);
97
+ yield* Console.error(dim("Run 'stacked sync' to rebase onto the new parent."));
98
+ }
37
99
  }),
38
100
  ),
39
101
  );
@@ -0,0 +1,107 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Console, Effect, Option } 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, withSpinner } from "../ui.js";
7
+
8
+ const editFlag = Flag.boolean("edit").pipe(Flag.withDescription("Open editor for commit message"));
9
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
10
+ const fromFlag = Flag.string("from").pipe(
11
+ Flag.optional,
12
+ Flag.withDescription("Start syncing from this branch (defaults to current)"),
13
+ );
14
+
15
+ export const amend = Command.make("amend", {
16
+ edit: editFlag,
17
+ json: jsonFlag,
18
+ from: fromFlag,
19
+ }).pipe(
20
+ Command.withDescription("Amend current commit and rebase children"),
21
+ Command.withExamples([
22
+ { command: "stacked amend", description: "Amend and auto-rebase children" },
23
+ { command: "stacked amend --edit", description: "Amend with editor" },
24
+ ]),
25
+ Command.withHandler(({ edit, json, from }) =>
26
+ Effect.gen(function* () {
27
+ const git = yield* GitService;
28
+ const stacks = yield* StackService;
29
+
30
+ const currentBranch = yield* git.currentBranch();
31
+ const result = yield* stacks.currentStack();
32
+ if (result === null) {
33
+ return yield* new StackError({
34
+ code: ErrorCode.NOT_IN_STACK,
35
+ message:
36
+ "Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
37
+ });
38
+ }
39
+
40
+ yield* git.commitAmend({ edit });
41
+
42
+ const fromBranch = Option.isSome(from) ? from.value : currentBranch;
43
+
44
+ // Find children to rebase
45
+ const { branches } = result.stack;
46
+ const idx = branches.indexOf(fromBranch);
47
+ if (idx === -1 || idx >= branches.length - 1) {
48
+ if (json) {
49
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
50
+ yield* Console.log(JSON.stringify({ amended: currentBranch, synced: [] }, null, 2));
51
+ } else {
52
+ yield* success(`Amended ${currentBranch} (no children to rebase)`);
53
+ }
54
+ return;
55
+ }
56
+
57
+ // Rebase children
58
+ const children = branches.slice(idx + 1);
59
+ const synced: string[] = [];
60
+
61
+ yield* Effect.gen(function* () {
62
+ for (let i = 0; i < children.length; i++) {
63
+ const branch = children[i];
64
+ if (branch === undefined) continue;
65
+ const newBase = i === 0 ? fromBranch : (children[i - 1] ?? fromBranch);
66
+
67
+ const oldBase = yield* git
68
+ .mergeBase(branch, newBase)
69
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase)));
70
+
71
+ yield* git.checkout(branch);
72
+ yield* withSpinner(
73
+ `Rebasing ${branch} onto ${newBase}`,
74
+ git.rebaseOnto(branch, newBase, oldBase),
75
+ ).pipe(
76
+ Effect.catchTag("GitError", (e) =>
77
+ Effect.fail(
78
+ new StackError({
79
+ code: ErrorCode.REBASE_CONFLICT,
80
+ message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
81
+ }),
82
+ ),
83
+ ),
84
+ );
85
+ synced.push(branch);
86
+ }
87
+ }).pipe(
88
+ Effect.ensuring(
89
+ git
90
+ .isRebaseInProgress()
91
+ .pipe(
92
+ Effect.andThen((inProgress) =>
93
+ inProgress ? Effect.void : git.checkout(currentBranch).pipe(Effect.ignore),
94
+ ),
95
+ ),
96
+ ),
97
+ );
98
+
99
+ if (json) {
100
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
101
+ yield* Console.log(JSON.stringify({ amended: currentBranch, synced }, null, 2));
102
+ } else {
103
+ yield* success(`Amended ${currentBranch} and rebased ${synced.length} child branch(es)`);
104
+ }
105
+ }),
106
+ ),
107
+ );
@@ -1,29 +1,47 @@
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 bottom = Command.make("bottom").pipe(
8
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
9
+
10
+ export const bottom = Command.make("bottom", { json: jsonFlag }).pipe(
7
11
  Command.withDescription("Checkout bottom branch of stack"),
8
- Command.withHandler(() =>
12
+ Command.withExamples([
13
+ { command: "stacked bottom", description: "Jump to the bottom of the stack" },
14
+ ]),
15
+ Command.withHandler(({ json }) =>
9
16
  Effect.gen(function* () {
10
17
  const git = yield* GitService;
11
18
  const stacks = yield* StackService;
12
19
 
13
20
  const result = yield* stacks.currentStack();
14
21
  if (result === null) {
15
- yield* Console.error("Not on a stacked branch");
16
- return;
22
+ return yield* new StackError({
23
+ code: ErrorCode.NOT_IN_STACK,
24
+ message:
25
+ "Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
26
+ });
17
27
  }
18
28
 
19
29
  const bottomBranch = result.stack.branches[0];
20
30
  if (bottomBranch === undefined) {
21
- yield* Console.error("Stack is empty");
22
- return;
31
+ return yield* new StackError({
32
+ code: ErrorCode.STACK_EMPTY,
33
+ message: "Stack is empty. Run 'stacked create <name>' to add a branch.",
34
+ });
23
35
  }
24
36
 
25
37
  yield* git.checkout(bottomBranch);
26
- yield* Console.log(`Switched to ${bottomBranch}`);
38
+
39
+ if (json) {
40
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
41
+ yield* Console.log(JSON.stringify({ branch: bottomBranch }, null, 2));
42
+ } else {
43
+ yield* success(`Switched to ${bottomBranch}`);
44
+ }
27
45
  }),
28
46
  ),
29
47
  );
@@ -1,15 +1,34 @@
1
- import { Argument, Command } from "effect/unstable/cli";
2
- import { Effect } from "effect";
1
+ import { Argument, Command, Flag } from "effect/unstable/cli";
2
+ import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
+ import { StackService } from "../services/Stack.js";
5
+ import { success } from "../ui.js";
4
6
 
5
- const nameArg = Argument.string("name");
7
+ const nameArg = Argument.string("name").pipe(Argument.withDescription("Branch name to check out"));
8
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
6
9
 
7
- export const checkout = Command.make("checkout", { name: nameArg }).pipe(
8
- Command.withDescription("Switch to branch in current stack"),
9
- Command.withHandler(({ name }) =>
10
+ export const checkout = Command.make("checkout", { name: nameArg, json: jsonFlag }).pipe(
11
+ Command.withDescription("Switch to a branch (falls through to git if not in a stack)"),
12
+ Command.withExamples([
13
+ { command: "stacked checkout feat-b", description: "Switch to a stacked branch" },
14
+ ]),
15
+ Command.withHandler(({ name, json }) =>
10
16
  Effect.gen(function* () {
11
17
  const git = yield* GitService;
18
+ const stacks = yield* StackService;
19
+
20
+ const result = yield* stacks.findBranchStack(name);
21
+
12
22
  yield* git.checkout(name);
23
+
24
+ if (json) {
25
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
26
+ yield* Console.log(JSON.stringify({ branch: name, inStack: result !== null }, null, 2));
27
+ } else if (result !== null) {
28
+ yield* success(`Switched to ${name}`);
29
+ } else {
30
+ yield* success(`Switched to ${name} (not in a stack)`);
31
+ }
13
32
  }),
14
33
  ),
15
34
  );
@@ -3,27 +3,60 @@ import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { GitHubService } from "../services/GitHub.js";
5
5
  import { StackService } from "../services/Stack.js";
6
+ import { ErrorCode, StackError } from "../errors/index.js";
7
+ import { success, warn, dim, confirm } from "../ui.js";
6
8
 
7
- const dryRunFlag = Flag.boolean("dry-run");
9
+ const dryRunFlag = Flag.boolean("dry-run").pipe(
10
+ Flag.withDescription("Show what would be removed without making changes"),
11
+ );
12
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
8
13
 
9
- export const clean = Command.make("clean", { dryRun: dryRunFlag }).pipe(
14
+ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag }).pipe(
10
15
  Command.withDescription("Remove merged branches from stacks (bottom-up)"),
11
- Command.withHandler(({ dryRun }) =>
16
+ Command.withExamples([
17
+ { command: "stacked clean", description: "Remove merged branches" },
18
+ { command: "stacked clean --dry-run", description: "Preview what would be removed" },
19
+ ]),
20
+ Command.withHandler(({ dryRun, json }) =>
12
21
  Effect.gen(function* () {
13
22
  const git = yield* GitService;
14
23
  const gh = yield* GitHubService;
15
24
  const stacks = yield* StackService;
16
25
 
17
- const currentBranch = yield* git.currentBranch();
26
+ const ghInstalled = yield* gh.isGhInstalled();
27
+ if (!ghInstalled) {
28
+ return yield* new StackError({
29
+ code: ErrorCode.GH_NOT_INSTALLED,
30
+ message: "gh CLI is not installed. Install it from https://cli.github.com",
31
+ });
32
+ }
33
+
34
+ let currentBranch = yield* git.currentBranch();
18
35
  const data = yield* stacks.load();
19
36
 
37
+ // Fetch all PR statuses in parallel across all stacks
38
+ const allBranches = Object.entries(data.stacks).flatMap(([stackName, stack]) =>
39
+ stack.branches.map((branch) => ({ stackName, branch })),
40
+ );
41
+
42
+ const prResults = yield* Effect.forEach(
43
+ allBranches,
44
+ ({ branch }) =>
45
+ gh.getPR(branch).pipe(
46
+ Effect.catchTag("GitHubError", () => Effect.succeed(null)),
47
+ Effect.map((pr) => [branch, pr] as const),
48
+ ),
49
+ { concurrency: 5 },
50
+ );
51
+ const prMap = new Map(prResults);
52
+
20
53
  const toRemove: Array<{ stackName: string; branch: string }> = [];
21
54
  const skippedMerged: Array<{ stackName: string; branch: string }> = [];
22
55
 
23
56
  for (const [stackName, stack] of Object.entries(data.stacks)) {
24
57
  let hitNonMerged = false;
25
58
  for (const branch of stack.branches) {
26
- const pr = yield* gh.getPR(branch).pipe(Effect.catch(() => Effect.succeed(null)));
59
+ const pr = prMap.get(branch) ?? null;
27
60
  const isMerged = pr !== null && pr.state === "MERGED";
28
61
 
29
62
  if (!hitNonMerged && isMerged) {
@@ -36,48 +69,94 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag }).pipe(
36
69
  }
37
70
 
38
71
  if (toRemove.length === 0) {
39
- yield* Console.log("Nothing to clean");
40
- if (skippedMerged.length > 0) {
41
- yield* Console.log(
42
- `\nNote: ${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
43
- );
44
- for (const { branch, stackName } of skippedMerged) {
45
- yield* Console.log(` ${branch} (${stackName})`);
72
+ if (json) {
73
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
74
+ yield* Console.log(JSON.stringify({ removed: [], skipped: [] }, null, 2));
75
+ } else {
76
+ yield* Console.error("Nothing to clean");
77
+ if (skippedMerged.length > 0) {
78
+ yield* warn(
79
+ `${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
80
+ );
81
+ for (const { branch, stackName } of skippedMerged) {
82
+ yield* Console.error(dim(` ${branch} (${stackName})`));
83
+ }
46
84
  }
47
85
  }
48
86
  return;
49
87
  }
50
88
 
89
+ if (!dryRun) {
90
+ for (const { branch } of toRemove) {
91
+ yield* Console.error(dim(` ${branch}`));
92
+ }
93
+ const confirmed = yield* confirm(
94
+ `Remove ${toRemove.length} merged branch${toRemove.length === 1 ? "" : "es"}?`,
95
+ );
96
+ if (!confirmed) {
97
+ yield* Console.error("Aborted");
98
+ return;
99
+ }
100
+ }
101
+
102
+ const removed: string[] = [];
103
+
51
104
  for (const { stackName, branch } of toRemove) {
52
105
  if (dryRun) {
53
- yield* Console.log(`Would remove ${branch} from ${stackName}`);
106
+ yield* Console.error(`Would remove ${branch} from ${stackName}`);
107
+ removed.push(branch);
54
108
  } else {
55
109
  if (currentBranch === branch) {
56
110
  const trunk = yield* stacks.getTrunk();
57
111
  yield* git.checkout(trunk);
112
+ currentBranch = trunk;
113
+ }
114
+ const deleted = yield* git.deleteBranch(branch, true).pipe(
115
+ Effect.as(true),
116
+ Effect.catchTag("GitError", (e) =>
117
+ Console.error(`Warning: failed to delete local branch ${branch}: ${e.message}`).pipe(
118
+ Effect.as(false),
119
+ ),
120
+ ),
121
+ );
122
+ yield* git
123
+ .deleteRemoteBranch(branch)
124
+ .pipe(
125
+ Effect.catchTag("GitError", (e) =>
126
+ Console.error(`Warning: failed to delete remote branch ${branch}: ${e.message}`),
127
+ ),
128
+ );
129
+ if (deleted) {
130
+ yield* stacks.removeBranch(stackName, branch);
131
+ removed.push(branch);
132
+ yield* success(`Removed ${branch} from ${stackName}`);
58
133
  }
59
- yield* stacks.removeBranch(stackName, branch);
60
- yield* git.deleteBranch(branch, true).pipe(Effect.catch(() => Effect.void));
61
- yield* Console.log(`Removed ${branch} from ${stackName}`);
62
134
  }
63
135
  }
64
136
 
65
- if (dryRun) {
66
- yield* Console.log(
137
+ if (json) {
138
+ const skipped = skippedMerged.map((x) => x.branch);
139
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
140
+ yield* Console.log(JSON.stringify({ removed, skipped }, null, 2));
141
+ } else if (dryRun) {
142
+ yield* Console.error(
67
143
  `\n${toRemove.length} branch${toRemove.length === 1 ? "" : "es"} would be removed`,
68
144
  );
69
145
  } else {
70
- yield* Console.log(
71
- `\nCleaned ${toRemove.length} merged branch${toRemove.length === 1 ? "" : "es"}`,
146
+ yield* success(
147
+ `Cleaned ${removed.length} merged branch${removed.length === 1 ? "" : "es"}`,
72
148
  );
73
- }
74
-
75
- if (skippedMerged.length > 0) {
76
- yield* Console.log(
77
- `\nNote: ${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
149
+ yield* Console.error(
150
+ dim("Run 'stacked sync' then 'stacked submit' to rebase and retarget PRs."),
78
151
  );
79
- for (const { branch, stackName } of skippedMerged) {
80
- yield* Console.log(` ${branch} (${stackName})`);
152
+
153
+ if (skippedMerged.length > 0) {
154
+ yield* warn(
155
+ `${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
156
+ );
157
+ for (const { branch, stackName } of skippedMerged) {
158
+ yield* Console.error(dim(` ${branch} (${stackName})`));
159
+ }
81
160
  }
82
161
  }
83
162
  }),
@@ -2,31 +2,87 @@ import { Argument, 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 { validateBranchName } from "./helpers/validate.js";
5
7
 
6
- const nameArg = Argument.string("name");
7
- const fromFlag = Flag.string("from").pipe(Flag.optional, Flag.withAlias("f"));
8
+ const nameArg = Argument.string("name").pipe(Argument.withDescription("Branch name to create"));
9
+ const fromFlag = Flag.string("from").pipe(
10
+ Flag.optional,
11
+ Flag.withAlias("f"),
12
+ Flag.withDescription("Branch from a specific branch instead of current"),
13
+ );
14
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
8
15
 
9
- export const create = Command.make("create", { name: nameArg, from: fromFlag }).pipe(
16
+ export const create = Command.make("create", {
17
+ name: nameArg,
18
+ from: fromFlag,
19
+ json: jsonFlag,
20
+ }).pipe(
10
21
  Command.withDescription("Create a new branch on top of current branch in stack"),
11
- Command.withHandler(({ name, from }) =>
22
+ Command.withExamples([
23
+ { command: "stacked create feat-auth", description: "Create branch on top of current" },
24
+ {
25
+ command: "stacked create feat-ui --from feat-auth",
26
+ description: "Branch from a specific branch",
27
+ },
28
+ ]),
29
+ Command.withHandler(({ name, from, json }) =>
12
30
  Effect.gen(function* () {
13
31
  const git = yield* GitService;
14
32
  const stacks = yield* StackService;
15
33
 
34
+ yield* validateBranchName(name);
35
+
16
36
  const currentBranch = yield* git.currentBranch();
17
37
  const baseBranch = Option.isSome(from) ? from.value : currentBranch;
18
38
  const trunk = yield* stacks.getTrunk();
19
39
 
20
- const data = yield* stacks.load();
21
- let stackName: string | null = null;
40
+ if (name === trunk) {
41
+ return yield* new StackError({
42
+ code: ErrorCode.TRUNK_ERROR,
43
+ message: `Cannot create a branch with the same name as trunk ("${trunk}")`,
44
+ });
45
+ }
46
+
47
+ if (Option.isSome(from)) {
48
+ const fromExists = yield* git.branchExists(from.value);
49
+ if (!fromExists) {
50
+ return yield* new StackError({
51
+ code: ErrorCode.BRANCH_NOT_FOUND,
52
+ message: `Branch "${from.value}" does not exist`,
53
+ });
54
+ }
55
+ }
22
56
 
23
- for (const [sName, stack] of Object.entries(data.stacks)) {
24
- if (stack.branches.includes(baseBranch)) {
25
- stackName = sName;
26
- break;
57
+ const branchAlreadyExists = yield* git.branchExists(name);
58
+ if (branchAlreadyExists) {
59
+ const tracked = yield* stacks.findBranchStack(name);
60
+ if (tracked !== null) {
61
+ if (json) {
62
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
63
+ yield* Console.log(
64
+ JSON.stringify(
65
+ { branch: name, stack: tracked.name, base: baseBranch, alreadyExists: true },
66
+ null,
67
+ 2,
68
+ ),
69
+ );
70
+ } else {
71
+ yield* Console.error(`Branch "${name}" already exists in stack "${tracked.name}"`);
72
+ }
73
+ return;
27
74
  }
75
+ return yield* new StackError({
76
+ code: ErrorCode.BRANCH_EXISTS,
77
+ message: `Branch "${name}" already exists but is not tracked in any stack`,
78
+ });
28
79
  }
29
80
 
81
+ const existing = yield* stacks.findBranchStack(baseBranch);
82
+ let stackName = existing?.name ?? null;
83
+
84
+ yield* git.createBranch(name, baseBranch);
85
+
30
86
  if (stackName === null) {
31
87
  if (baseBranch === trunk) {
32
88
  stackName = name;
@@ -37,10 +93,16 @@ export const create = Command.make("create", { name: nameArg, from: fromFlag }).
37
93
  }
38
94
  }
39
95
 
40
- yield* git.createBranch(name, baseBranch);
41
96
  yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
42
97
 
43
- yield* Console.log(`Created branch ${name} on top of ${baseBranch}`);
98
+ if (json) {
99
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
100
+ yield* Console.log(
101
+ JSON.stringify({ branch: name, stack: stackName, base: baseBranch }, null, 2),
102
+ );
103
+ } else {
104
+ yield* Console.error(`Created branch ${name} on top of ${baseBranch}`);
105
+ }
44
106
  }),
45
107
  ),
46
108
  );