@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.
@@ -2,37 +2,50 @@ import { Argument, 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 { StackError } from "../errors/index.js";
5
+ import { ErrorCode, StackError } from "../errors/index.js";
6
+ import { confirm } from "../ui.js";
6
7
 
7
- const nameArg = Argument.string("name");
8
- const forceFlag = Flag.boolean("force").pipe(Flag.withAlias("f"));
8
+ const nameArg = Argument.string("name").pipe(Argument.withDescription("Branch name to delete"));
9
+ const forceFlag = Flag.boolean("force").pipe(
10
+ Flag.withAlias("f"),
11
+ Flag.withDescription("Delete even if branch has children in the stack"),
12
+ );
13
+ const keepRemoteFlag = Flag.boolean("keep-remote").pipe(
14
+ Flag.withDescription("Don't delete the remote branch"),
15
+ );
16
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
17
+ const dryRunFlag = Flag.boolean("dry-run").pipe(
18
+ Flag.withDescription("Show what would happen without making changes"),
19
+ );
9
20
 
10
- export const deleteCmd = Command.make("delete", { name: nameArg, force: forceFlag }).pipe(
21
+ export const deleteCmd = Command.make("delete", {
22
+ name: nameArg,
23
+ force: forceFlag,
24
+ keepRemote: keepRemoteFlag,
25
+ json: jsonFlag,
26
+ dryRun: dryRunFlag,
27
+ }).pipe(
11
28
  Command.withDescription("Remove branch from stack and delete git branch"),
12
- Command.withHandler(({ name, force }) =>
29
+ Command.withExamples([
30
+ { command: "stacked delete feat-old", description: "Delete a leaf branch" },
31
+ { command: "stacked delete feat-mid --force", description: "Force delete a mid-stack branch" },
32
+ ]),
33
+ Command.withHandler(({ name, force, keepRemote, json, dryRun }) =>
13
34
  Effect.gen(function* () {
14
35
  const git = yield* GitService;
15
36
  const stacks = yield* StackService;
16
37
 
17
38
  const currentBranch = yield* git.currentBranch();
18
- const data = yield* stacks.load();
19
-
20
- let stackName: string | null = null;
21
- for (const [sName, stack] of Object.entries(data.stacks)) {
22
- if (stack.branches.includes(name)) {
23
- stackName = sName;
24
- break;
25
- }
26
- }
39
+ const trunk = yield* stacks.getTrunk();
27
40
 
28
- if (stackName === null) {
29
- return yield* new StackError({ message: `Branch "${name}" not found in any stack` });
30
- }
31
-
32
- const stack = data.stacks[stackName];
33
- if (stack === undefined) {
34
- return yield* new StackError({ message: `Stack "${stackName}" not found` });
41
+ const result = yield* stacks.findBranchStack(name);
42
+ if (result === null) {
43
+ return yield* new StackError({
44
+ code: ErrorCode.BRANCH_NOT_FOUND,
45
+ message: `Branch "${name}" not found in any stack`,
46
+ });
35
47
  }
48
+ const { name: stackName, stack } = result;
36
49
  const idx = stack.branches.indexOf(name);
37
50
 
38
51
  if (idx < stack.branches.length - 1 && !force) {
@@ -41,15 +54,70 @@ export const deleteCmd = Command.make("delete", { name: nameArg, force: forceFla
41
54
  });
42
55
  }
43
56
 
57
+ const hadChildren = idx < stack.branches.length - 1;
58
+ const willDeleteRemote = !keepRemote;
59
+
60
+ if (dryRun) {
61
+ if (json) {
62
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
63
+ yield* Console.log(
64
+ JSON.stringify(
65
+ { branch: name, stack: stackName, hadChildren, deleteRemote: willDeleteRemote },
66
+ null,
67
+ 2,
68
+ ),
69
+ );
70
+ } else {
71
+ yield* Console.error(`Would delete branch "${name}" from stack "${stackName}"`);
72
+ if (hadChildren) {
73
+ yield* Console.error("Warning: branch has children that would be orphaned");
74
+ }
75
+ if (willDeleteRemote) {
76
+ yield* Console.error("Would also delete remote branch");
77
+ }
78
+ }
79
+ return;
80
+ }
81
+
82
+ const confirmed = yield* confirm(
83
+ `Delete branch "${name}"${keepRemote ? "" : " (local + remote)"}?`,
84
+ );
85
+ if (!confirmed) {
86
+ yield* Console.error("Aborted");
87
+ return;
88
+ }
89
+
44
90
  if (currentBranch === name) {
45
- const parent = idx === 0 ? data.trunk : (stack.branches[idx - 1] ?? data.trunk);
91
+ const clean = yield* git.isClean();
92
+ if (!clean) {
93
+ return yield* new StackError({
94
+ code: ErrorCode.DIRTY_WORKTREE,
95
+ message:
96
+ "Working tree has uncommitted changes. Commit or stash before deleting the current branch.",
97
+ });
98
+ }
99
+ const parent = idx === 0 ? trunk : (stack.branches[idx - 1] ?? trunk);
46
100
  yield* git.checkout(parent);
47
101
  }
48
102
 
49
- yield* stacks.removeBranch(stackName, name);
50
103
  yield* git.deleteBranch(name, force);
104
+ yield* stacks.removeBranch(stackName, name);
105
+
106
+ if (willDeleteRemote) {
107
+ yield* git.deleteRemoteBranch(name).pipe(Effect.catchTag("GitError", () => Effect.void));
108
+ }
51
109
 
52
- yield* Console.log(`Deleted ${name}`);
110
+ if (json) {
111
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
112
+ yield* Console.log(JSON.stringify({ deleted: name, hadChildren }, null, 2));
113
+ } else {
114
+ yield* Console.error(`Deleted ${name}`);
115
+ if (hadChildren) {
116
+ yield* Console.error(
117
+ "Warning: branch had children — commits unique to this branch may be lost if children don't include them. Run 'stacked sync' to rebase them onto the new parent.",
118
+ );
119
+ }
120
+ }
53
121
  }),
54
122
  ),
55
123
  );
@@ -2,12 +2,21 @@ 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 { success, warn, info } from "../ui.js";
6
+ import { detectLimitConfig, limitUntrackedBranches } from "./helpers/detect.js";
5
7
 
6
- const dryRunFlag = Flag.boolean("dry-run");
8
+ const dryRunFlag = Flag.boolean("dry-run").pipe(
9
+ Flag.withDescription("Show what would be detected without making changes"),
10
+ );
11
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
7
12
 
8
- export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
13
+ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFlag }).pipe(
9
14
  Command.withDescription("Detect and register branch stacks from git history"),
10
- Command.withHandler(({ dryRun }) =>
15
+ Command.withExamples([
16
+ { command: "stacked detect", description: "Auto-discover and register stacks" },
17
+ { command: "stacked detect --dry-run", description: "Preview what would be detected" },
18
+ ]),
19
+ Command.withHandler(({ dryRun, json }) =>
11
20
  Effect.gen(function* () {
12
21
  const git = yield* GitService;
13
22
  const stacks = yield* StackService;
@@ -18,62 +27,81 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
18
27
 
19
28
  const data = yield* stacks.load();
20
29
  const alreadyTracked = new Set(Object.values(data.stacks).flatMap((s) => [...s.branches]));
21
- const untracked = candidates.filter((b) => !alreadyTracked.has(b));
30
+ const untrackedAll = candidates.filter((b) => !alreadyTracked.has(b));
31
+ const detectLimit = yield* detectLimitConfig;
32
+ const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
22
33
 
23
34
  if (untracked.length === 0) {
24
- yield* Console.log("No untracked branches found");
35
+ yield* Console.error("No untracked branches found");
25
36
  return;
26
37
  }
27
38
 
39
+ if (skipped > 0) {
40
+ yield* warn(
41
+ `Analyzing ${untracked.length}/${untrackedAll.length} untracked branches (set STACKED_DETECT_MAX_BRANCHES to adjust)`,
42
+ );
43
+ }
44
+
28
45
  // Build parent map: for each branch, find its direct parent among other branches
29
46
  // A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
30
47
  const childOf = new Map<string, string>();
31
48
 
32
- for (const branch of untracked) {
33
- const ancestors: string[] = [];
34
-
35
- // Check trunk
36
- const trunkIsAncestor = yield* git
37
- .isAncestor(trunk, branch)
38
- .pipe(Effect.catch(() => Effect.succeed(false)));
39
- if (trunkIsAncestor) ancestors.push(trunk);
40
-
41
- // Check other untracked branches
42
- for (const other of untracked) {
43
- if (other === branch) continue;
44
- const is = yield* git
45
- .isAncestor(other, branch)
46
- .pipe(Effect.catch(() => Effect.succeed(false)));
47
- if (is) ancestors.push(other);
48
- }
49
-
50
- if (ancestors.length === 0) continue;
51
-
52
- // Find the closest ancestor — the one that is a descendant of all others
53
- let closest = ancestors[0] ?? trunk;
54
- for (let i = 1; i < ancestors.length; i++) {
55
- const candidate = ancestors[i];
56
- if (candidate === undefined) continue;
57
- const candidateIsCloser = yield* git
58
- .isAncestor(closest, candidate)
59
- .pipe(Effect.catch(() => Effect.succeed(false)));
60
- if (candidateIsCloser) closest = candidate;
61
- }
62
-
63
- childOf.set(branch, closest);
64
- }
49
+ yield* Effect.forEach(
50
+ untracked,
51
+ (branch) =>
52
+ Effect.gen(function* () {
53
+ // Check all potential ancestors (trunk + other untracked) in parallel
54
+ const potentialAncestors = [trunk, ...untracked.filter((b) => b !== branch)];
55
+ const ancestryResults = yield* Effect.forEach(
56
+ potentialAncestors,
57
+ (other) =>
58
+ git.isAncestor(other, branch).pipe(
59
+ Effect.catchTag("GitError", () => Effect.succeed(false)),
60
+ Effect.map((is) => [other, is] as const),
61
+ ),
62
+ { concurrency: 5 },
63
+ );
64
+
65
+ const ancestors = ancestryResults.filter(([_, is]) => is).map(([name]) => name);
66
+
67
+ if (ancestors.length === 0) return;
68
+
69
+ // Find the closest ancestor — the one that is a descendant of all others
70
+ let closest = ancestors[0] ?? trunk;
71
+ for (let i = 1; i < ancestors.length; i++) {
72
+ const candidate = ancestors[i];
73
+ if (candidate === undefined) continue;
74
+ const candidateIsCloser = yield* git
75
+ .isAncestor(closest, candidate)
76
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
77
+ if (candidateIsCloser) closest = candidate;
78
+ }
79
+
80
+ childOf.set(branch, closest);
81
+ }),
82
+ { concurrency: 5 },
83
+ );
65
84
 
66
85
  // Build linear chains from trunk
67
86
  // Find branches whose parent is trunk (chain roots)
87
+ const childrenByParent = new Map<string, string[]>();
88
+ for (const branch of untracked) {
89
+ const parent = childOf.get(branch);
90
+ if (parent === undefined) continue;
91
+ const children = childrenByParent.get(parent) ?? [];
92
+ children.push(branch);
93
+ childrenByParent.set(parent, children);
94
+ }
95
+
68
96
  const chains: string[][] = [];
69
- const roots = untracked.filter((b) => childOf.get(b) === trunk);
97
+ const roots = childrenByParent.get(trunk) ?? [];
70
98
 
71
99
  for (const root of roots) {
72
100
  const chain = [root];
73
101
  let current = root;
74
102
 
75
103
  while (true) {
76
- const children = untracked.filter((b) => childOf.get(b) === current);
104
+ const children = childrenByParent.get(current) ?? [];
77
105
  const child = children[0];
78
106
  if (children.length === 1 && child !== undefined) {
79
107
  chain.push(child);
@@ -87,38 +115,51 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
87
115
  chains.push(chain);
88
116
  }
89
117
 
118
+ // Report forks
119
+ const forkPoints = untracked.filter((b) => (childrenByParent.get(b)?.length ?? 0) > 1);
120
+ const forks = forkPoints.map((b) => ({
121
+ branch: b,
122
+ children: childrenByParent.get(b) ?? [],
123
+ }));
124
+
125
+ if (json) {
126
+ const stacksData = chains.map((chain) => ({ name: chain[0] ?? "", branches: chain }));
127
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
128
+ yield* Console.log(JSON.stringify({ stacks: stacksData, forks }, null, 2));
129
+ return;
130
+ }
131
+
90
132
  if (chains.length === 0) {
91
- yield* Console.log("No linear branch chains detected");
133
+ yield* info("No linear branch chains detected");
92
134
  return;
93
135
  }
94
136
 
95
137
  for (const chain of chains) {
96
138
  const name = chain[0];
97
139
  if (name === undefined) continue;
140
+ const currentData = yield* stacks.load();
141
+ if (currentData.stacks[name] !== undefined) {
142
+ yield* warn(`Stack "${name}" already exists, skipping: ${chain.join(" → ")}`);
143
+ continue;
144
+ }
98
145
  if (dryRun) {
99
- yield* Console.log(`Would create stack "${name}": ${chain.join(" → ")}`);
146
+ yield* Console.error(`Would create stack "${name}": ${chain.join(" → ")}`);
100
147
  } else {
101
148
  yield* stacks.createStack(name, chain);
102
- yield* Console.log(`Created stack "${name}": ${chain.join(" → ")}`);
149
+ yield* success(`Created stack "${name}": ${chain.join(" → ")}`);
103
150
  }
104
151
  }
105
152
 
106
153
  if (dryRun) {
107
- yield* Console.log(
154
+ yield* Console.error(
108
155
  `\n${chains.length} stack${chains.length === 1 ? "" : "s"} would be created`,
109
156
  );
110
157
  }
111
158
 
112
- // Report forks
113
- const forkPoints = untracked.filter((b) => {
114
- const children = untracked.filter((c) => childOf.get(c) === b);
115
- return children.length > 1;
116
- });
117
- if (forkPoints.length > 0) {
118
- yield* Console.log("\nNote: forked branches detected (not supported yet):");
119
- for (const branch of forkPoints) {
120
- const children = untracked.filter((c) => childOf.get(c) === branch);
121
- yield* Console.log(` ${branch} → ${children.join(", ")}`);
159
+ if (forks.length > 0) {
160
+ yield* warn("Forked branches detected (not supported yet):");
161
+ for (const fork of forks) {
162
+ yield* Console.error(` ${fork.branch} → ${fork.children.join(", ")}`);
122
163
  }
123
164
  }
124
165
  }),
@@ -0,0 +1,124 @@
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 { success, warn } from "../ui.js";
6
+
7
+ const fixFlag = Flag.boolean("fix").pipe(Flag.withDescription("Auto-fix issues where possible"));
8
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
9
+
10
+ interface Finding {
11
+ type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "parse_error";
12
+ message: string;
13
+ fixed: boolean;
14
+ }
15
+
16
+ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).pipe(
17
+ Command.withDescription("Check stack metadata for issues and optionally fix them"),
18
+ Command.withExamples([
19
+ { command: "stacked doctor", description: "Check for metadata drift" },
20
+ { command: "stacked doctor --fix", description: "Auto-fix detected issues" },
21
+ ]),
22
+ Command.withHandler(({ fix, json }) =>
23
+ Effect.gen(function* () {
24
+ const git = yield* GitService;
25
+ const stacks = yield* StackService;
26
+
27
+ const data = yield* stacks.load();
28
+ const findings: Finding[] = [];
29
+
30
+ // Check 1: trunk branch exists
31
+ const trunkExists = yield* git
32
+ .branchExists(data.trunk)
33
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
34
+ if (!trunkExists) {
35
+ if (fix) {
36
+ // Auto-detect a trunk
37
+ for (const candidate of ["main", "master", "develop"]) {
38
+ const exists = yield* git
39
+ .branchExists(candidate)
40
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
41
+ if (exists) {
42
+ yield* stacks.setTrunk(candidate);
43
+ findings.push({
44
+ type: "missing_trunk",
45
+ message: `Trunk "${data.trunk}" not found, set to "${candidate}"`,
46
+ fixed: true,
47
+ });
48
+ break;
49
+ }
50
+ }
51
+ } else {
52
+ findings.push({
53
+ type: "missing_trunk",
54
+ message: `Trunk branch "${data.trunk}" does not exist`,
55
+ fixed: false,
56
+ });
57
+ }
58
+ }
59
+
60
+ // Check 2: all tracked branches exist in git
61
+ for (const [stackName, stack] of Object.entries(data.stacks)) {
62
+ 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) {
67
+ if (fix) {
68
+ yield* stacks.removeBranch(stackName, branch);
69
+ findings.push({
70
+ type: "stale_branch",
71
+ message: `Removed stale branch "${branch}" from stack "${stackName}"`,
72
+ fixed: true,
73
+ });
74
+ } else {
75
+ findings.push({
76
+ type: "stale_branch",
77
+ message: `Branch "${branch}" in stack "${stackName}" does not exist in git`,
78
+ fixed: false,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ // Check 3: no branches in multiple stacks
86
+ 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
+ }
93
+ }
94
+ for (const [branch, stackNames] of branchToStacks) {
95
+ if (stackNames.length > 1) {
96
+ findings.push({
97
+ type: "duplicate_branch",
98
+ message: `Branch "${branch}" appears in multiple stacks: ${stackNames.join(", ")}`,
99
+ fixed: false,
100
+ });
101
+ }
102
+ }
103
+
104
+ if (json) {
105
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
106
+ yield* Console.log(JSON.stringify({ findings }, null, 2));
107
+ } else if (findings.length === 0) {
108
+ yield* success("No issues found");
109
+ } else {
110
+ for (const f of findings) {
111
+ if (f.fixed) {
112
+ yield* success(f.message);
113
+ } else {
114
+ yield* warn(f.message);
115
+ }
116
+ }
117
+ const fixable = findings.filter((f) => !f.fixed).length;
118
+ if (fixable > 0 && !fix) {
119
+ yield* Console.error(`\nRun 'stacked doctor --fix' to auto-fix ${fixable} issue(s)`);
120
+ }
121
+ }
122
+ }),
123
+ ),
124
+ );
@@ -0,0 +1,62 @@
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 down = Command.make("down", { json: jsonFlag }).pipe(
11
+ Command.withDescription("Move down one branch in the stack"),
12
+ Command.withExamples([{ command: "stacked down", description: "Move to the next branch below" }]),
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
+ if (idx === 0) {
38
+ return yield* new StackError({
39
+ code: ErrorCode.ALREADY_AT_BOTTOM,
40
+ message: "Already at the bottom of the stack",
41
+ });
42
+ }
43
+
44
+ const prev = branches[idx - 1];
45
+ if (prev === undefined) {
46
+ return yield* new StackError({
47
+ code: ErrorCode.ALREADY_AT_BOTTOM,
48
+ message: "Already at the bottom of the stack",
49
+ });
50
+ }
51
+
52
+ yield* git.checkout(prev);
53
+
54
+ if (json) {
55
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
56
+ yield* Console.log(JSON.stringify({ branch: prev, from: currentBranch }, null, 2));
57
+ } else {
58
+ yield* success(`Switched to ${prev}`);
59
+ }
60
+ }),
61
+ ),
62
+ );
@@ -0,0 +1,22 @@
1
+ import { Config } from "effect";
2
+
3
+ export const DEFAULT_MAX_DETECT_BRANCHES = 200;
4
+
5
+ export interface LimitedBranches {
6
+ readonly untracked: readonly string[];
7
+ readonly skipped: number;
8
+ }
9
+
10
+ export const detectLimitConfig = Config.int("STACKED_DETECT_MAX_BRANCHES").pipe(
11
+ Config.withDefault(DEFAULT_MAX_DETECT_BRANCHES),
12
+ Config.orElse(() => Config.succeed(DEFAULT_MAX_DETECT_BRANCHES)),
13
+ Config.map((value) => (value > 0 ? value : DEFAULT_MAX_DETECT_BRANCHES)),
14
+ );
15
+
16
+ export const limitUntrackedBranches = (
17
+ untrackedAll: readonly string[],
18
+ limit: number,
19
+ ): LimitedBranches => {
20
+ const untracked = untrackedAll.slice(0, limit);
21
+ return { untracked, skipped: untrackedAll.length - untracked.length };
22
+ };
@@ -0,0 +1,61 @@
1
+ import { Effect } from "effect";
2
+ import { ErrorCode, StackError } from "../../errors/index.js";
3
+
4
+ const BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/;
5
+
6
+ export const validateBranchName = Effect.fn("validateBranchName")(function* (name: string) {
7
+ if (name === "") {
8
+ return yield* new StackError({
9
+ code: ErrorCode.INVALID_BRANCH_NAME,
10
+ message: "Branch name cannot be empty",
11
+ });
12
+ }
13
+ if (name.startsWith("-")) {
14
+ return yield* new StackError({
15
+ code: ErrorCode.INVALID_BRANCH_NAME,
16
+ message: `Invalid branch name "${name}": cannot start with "-"`,
17
+ });
18
+ }
19
+ if (name.includes("..")) {
20
+ return yield* new StackError({
21
+ code: ErrorCode.INVALID_BRANCH_NAME,
22
+ message: `Invalid branch name "${name}": cannot contain ".."`,
23
+ });
24
+ }
25
+ if (name.includes(" ")) {
26
+ return yield* new StackError({
27
+ code: ErrorCode.INVALID_BRANCH_NAME,
28
+ message: `Invalid branch name "${name}": cannot contain spaces`,
29
+ });
30
+ }
31
+ if (name.endsWith(".lock")) {
32
+ return yield* new StackError({
33
+ code: ErrorCode.INVALID_BRANCH_NAME,
34
+ message: `Invalid branch name "${name}": cannot end with ".lock"`,
35
+ });
36
+ }
37
+ if (name.endsWith(".")) {
38
+ return yield* new StackError({
39
+ code: ErrorCode.INVALID_BRANCH_NAME,
40
+ message: `Invalid branch name "${name}": cannot end with "."`,
41
+ });
42
+ }
43
+ if (name.endsWith("/")) {
44
+ return yield* new StackError({
45
+ code: ErrorCode.INVALID_BRANCH_NAME,
46
+ message: `Invalid branch name "${name}": cannot end with "/"`,
47
+ });
48
+ }
49
+ if (name === "@") {
50
+ return yield* new StackError({
51
+ code: ErrorCode.INVALID_BRANCH_NAME,
52
+ message: `Invalid branch name "${name}": "@" alone is not a valid branch name`,
53
+ });
54
+ }
55
+ if (!BRANCH_NAME_PATTERN.test(name)) {
56
+ return yield* new StackError({
57
+ code: ErrorCode.INVALID_BRANCH_NAME,
58
+ message: `Invalid branch name "${name}": must start with alphanumeric and contain only alphanumerics, dots, hyphens, underscores, or slashes`,
59
+ });
60
+ }
61
+ });