@burglekitt/worktree 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Burglekitt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # Worktree
2
+
3
+ A CLI tool for managing Git worktrees with enhanced functionality.
4
+
5
+ ![Worktree](https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExNW51dHJlNHRzYnRwd3c3ZWZ0dzllZTB1d3VnaGQxd2s4eDJlbDhnaiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/lqT2GstnDBVJE6qfkK/giphy.gif)
6
+
7
+ ## TODO
8
+
9
+ - [x] Add `config` command to configure everything needed
10
+ - [x] Add `branch` command to create new worktrees
11
+ - [ ] Add `delete` command to delete worktrees
12
+ - [ ] Add `checkout` command to create worktree from a remote branch
13
+ - [ ] Add `cleanup` command to cleanup stale worktrees
14
+ - [ ] Integrate with JIRA for automated branch naming
15
+ - [ ] Integrate with ClickUp for automated branch naming
package/bin/dev.cmd ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+
3
+ node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %*
package/bin/dev.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env -S node --loader ts-node/esm --disable-warning=ExperimentalWarning
2
+
3
+ import { execute } from "@oclif/core";
4
+
5
+ await execute({ development: true, dir: import.meta.url });
package/bin/run.cmd ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+
3
+ node "%~dp0\run" %*
package/bin/run.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execute } from "@oclif/core";
4
+
5
+ await execute({ dir: import.meta.url });
@@ -0,0 +1,18 @@
1
+ import { BaseCommand } from "../lib/base-command.js";
2
+ export default class Branch extends BaseCommand {
3
+ static args: {
4
+ branchName: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ help: import("@oclif/core/interfaces").BooleanFlag<void>;
10
+ source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ private confirmNonOriginSource;
13
+ private confirmRemoteNameConflict;
14
+ private getSourceBranch;
15
+ private validateBranchName;
16
+ private getBranchName;
17
+ run(): Promise<void>;
18
+ }
@@ -0,0 +1,75 @@
1
+ import { confirm, input } from "@inquirer/prompts";
2
+ import { Args, Flags } from "@oclif/core";
3
+ import { BaseCommand } from "../lib/base-command.js";
4
+ import { copyEnvFilesFromRootPath } from "../lib/env.js";
5
+ import { gitCreateWorktree, gitGetConfigValue, gitGetLocalBranches, gitGetRemoteBranches, } from "../lib/git.js";
6
+ import { isValidBranchName } from "../lib/validators.js";
7
+ export default class Branch extends BaseCommand {
8
+ static args = {
9
+ branchName: Args.string({ description: "Name of the branch to create" }),
10
+ };
11
+ static description = "Create a worktree branch";
12
+ static examples = [
13
+ "<%= config.bin %> <%= command.id %> my-new-branch",
14
+ ];
15
+ static flags = {
16
+ help: Flags.help({ char: "h", description: "Show branch help" }),
17
+ source: Flags.string({
18
+ char: "s",
19
+ description: "Source branch to create the worktree from",
20
+ }),
21
+ };
22
+ confirmNonOriginSource() {
23
+ const message = "The source branch does not start with 'origin/'. Are you sure you want to use a local source?";
24
+ return confirm({ message });
25
+ }
26
+ confirmRemoteNameConflict() {
27
+ const message = "A remote branch with the same name exists. Do you want to use the remote branch instead?";
28
+ return confirm({ message });
29
+ }
30
+ async getSourceBranch(sourceFlag) {
31
+ if (sourceFlag) {
32
+ const remoteBranches = await gitGetRemoteBranches();
33
+ if (sourceFlag.startsWith("origin/")) {
34
+ if (!remoteBranches.includes(sourceFlag)) {
35
+ this.error(`Source branch doesn't exist: ${sourceFlag}`);
36
+ }
37
+ return sourceFlag;
38
+ }
39
+ if (await this.confirmNonOriginSource()) {
40
+ const localBranches = await gitGetLocalBranches();
41
+ if (!localBranches.includes(sourceFlag)) {
42
+ this.error(`Source branch doesn't exist: ${sourceFlag}`);
43
+ }
44
+ if (remoteBranches.includes(`origin/${sourceFlag}`)) {
45
+ if (await this.confirmRemoteNameConflict()) {
46
+ return `origin/${sourceFlag}`;
47
+ }
48
+ }
49
+ return sourceFlag;
50
+ }
51
+ }
52
+ return (await gitGetConfigValue("defaultSourceBranch")) || "origin/main";
53
+ }
54
+ validateBranchName(branchName) {
55
+ const result = isValidBranchName(branchName);
56
+ if (result !== true) {
57
+ this.error(result);
58
+ }
59
+ return true;
60
+ }
61
+ async getBranchName(branchNameArg) {
62
+ if (branchNameArg && this.validateBranchName(branchNameArg)) {
63
+ return branchNameArg;
64
+ }
65
+ return await input({ message: "Branch name", validate: isValidBranchName });
66
+ }
67
+ async run() {
68
+ const { args, flags } = await this.parse(Branch);
69
+ const branchName = await this.getBranchName(args.branchName);
70
+ const sourceBranch = await this.getSourceBranch(flags.source);
71
+ const projectPath = await gitCreateWorktree(branchName, sourceBranch);
72
+ await copyEnvFilesFromRootPath(projectPath);
73
+ await this.openWorktreePath(projectPath);
74
+ }
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,207 @@
1
+ import { confirm, input } from "@inquirer/prompts";
2
+ import { copyEnvFilesFromRootPath } from "../lib/env.js";
3
+ import * as git from "../lib/git.js";
4
+ import * as validators from "../lib/validators.js";
5
+ import Branch from "./branch.js";
6
+ // Mock the inquirer module
7
+ vi.mock("@inquirer/prompts", () => ({
8
+ confirm: vi.fn(),
9
+ input: vi.fn(),
10
+ }));
11
+ // Mock env functions
12
+ vi.mock("../lib/env.js", () => ({
13
+ copyEnvFilesFromRootPath: vi.fn().mockResolvedValue(undefined),
14
+ }));
15
+ describe("branch command", () => {
16
+ let branch;
17
+ let mockOpenWorktreePath;
18
+ const mockInput = vi.mocked(input);
19
+ const mockConfirm = vi.mocked(confirm);
20
+ const mockCopyEnvFiles = vi.mocked(copyEnvFilesFromRootPath);
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif testing
24
+ branch = new Branch([], {});
25
+ mockOpenWorktreePath = vi
26
+ // biome-ignore lint/suspicious/noExplicitAny: Required for spying on protected method
27
+ .spyOn(branch, "openWorktreePath")
28
+ .mockResolvedValue(undefined);
29
+ });
30
+ describe("basic functionality", () => {
31
+ it("should create worktree with valid branch name and default source", async () => {
32
+ const mockGitCreateWorktree = vi
33
+ .spyOn(git, "gitCreateWorktree")
34
+ .mockResolvedValue("/path/to/worktree");
35
+ vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("origin/main");
36
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
37
+ branch.parse = vi.fn().mockResolvedValue({
38
+ args: { branchName: "feature/test" },
39
+ flags: {},
40
+ });
41
+ await branch.run();
42
+ expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/main");
43
+ expect(mockCopyEnvFiles).toHaveBeenCalledWith("/path/to/worktree");
44
+ expect(mockOpenWorktreePath).toHaveBeenCalledWith("/path/to/worktree");
45
+ });
46
+ it("should prompt for branch name when not provided", async () => {
47
+ mockInput.mockResolvedValue("prompted-branch");
48
+ const mockGitCreateWorktree = vi
49
+ .spyOn(git, "gitCreateWorktree")
50
+ .mockResolvedValue("/path/to/worktree");
51
+ vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("origin/main");
52
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
53
+ branch.parse = vi.fn().mockResolvedValue({
54
+ args: {},
55
+ flags: {},
56
+ });
57
+ await branch.run();
58
+ expect(mockInput).toHaveBeenCalledWith({
59
+ message: "Branch name",
60
+ validate: validators.isValidBranchName,
61
+ });
62
+ expect(mockGitCreateWorktree).toHaveBeenCalledWith("prompted-branch", "origin/main");
63
+ });
64
+ it("should use custom source branch when provided", async () => {
65
+ const mockGitCreateWorktree = vi
66
+ .spyOn(git, "gitCreateWorktree")
67
+ .mockResolvedValue("/path/to/worktree");
68
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue([
69
+ "origin/main",
70
+ "origin/develop",
71
+ ]);
72
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
73
+ branch.parse = vi.fn().mockResolvedValue({
74
+ args: { branchName: "feature/test" },
75
+ flags: { source: "origin/develop" },
76
+ });
77
+ await branch.run();
78
+ expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/develop");
79
+ });
80
+ });
81
+ describe("branch name validation", () => {
82
+ it("should validate branch name and throw error for invalid name", async () => {
83
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
84
+ branch.parse = vi.fn().mockResolvedValue({
85
+ args: { branchName: "invalid..branch" },
86
+ flags: {},
87
+ });
88
+ const mockError = vi.spyOn(branch, "error").mockImplementation(() => {
89
+ throw new Error("Branch name cannot contain double dots");
90
+ });
91
+ await expect(branch.run()).rejects.toThrow("Branch name cannot contain double dots");
92
+ expect(mockError).toHaveBeenCalledWith("Branch name cannot contain double dots");
93
+ });
94
+ });
95
+ describe("source branch handling", () => {
96
+ it("should handle non-existing origin source branch", async () => {
97
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
98
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
99
+ branch.parse = vi.fn().mockResolvedValue({
100
+ args: { branchName: "feature/test" },
101
+ flags: { source: "origin/nonexistent" },
102
+ });
103
+ const mockError = vi.spyOn(branch, "error").mockImplementation(() => {
104
+ throw new Error("Source branch doesn't exist: origin/nonexistent");
105
+ });
106
+ await expect(branch.run()).rejects.toThrow("Source branch doesn't exist: origin/nonexistent");
107
+ expect(mockError).toHaveBeenCalledWith("Source branch doesn't exist: origin/nonexistent");
108
+ });
109
+ it("should handle local source branch with confirmation", async () => {
110
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
111
+ vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue([
112
+ "main",
113
+ "develop",
114
+ ]);
115
+ mockConfirm.mockResolvedValue(true);
116
+ const mockGitCreateWorktree = vi
117
+ .spyOn(git, "gitCreateWorktree")
118
+ .mockResolvedValue("/path/to/worktree");
119
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
120
+ branch.parse = vi.fn().mockResolvedValue({
121
+ args: { branchName: "feature/test" },
122
+ flags: { source: "develop" },
123
+ });
124
+ await branch.run();
125
+ expect(mockConfirm).toHaveBeenCalledWith({
126
+ message: "The source branch does not start with 'origin/'. Are you sure you want to use a local source?",
127
+ });
128
+ expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "develop");
129
+ });
130
+ it("should handle local source branch with remote conflict confirmation", async () => {
131
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue([
132
+ "origin/main",
133
+ "origin/develop",
134
+ ]);
135
+ vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue([
136
+ "main",
137
+ "develop",
138
+ ]);
139
+ mockConfirm
140
+ .mockResolvedValueOnce(true) // Confirm using local source
141
+ .mockResolvedValueOnce(true); // Confirm using remote instead
142
+ const mockGitCreateWorktree = vi
143
+ .spyOn(git, "gitCreateWorktree")
144
+ .mockResolvedValue("/path/to/worktree");
145
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
146
+ branch.parse = vi.fn().mockResolvedValue({
147
+ args: { branchName: "feature/test" },
148
+ flags: { source: "develop" },
149
+ });
150
+ await branch.run();
151
+ expect(mockConfirm).toHaveBeenNthCalledWith(2, {
152
+ message: "A remote branch with the same name exists. Do you want to use the remote branch instead?",
153
+ });
154
+ expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/develop");
155
+ });
156
+ it("should fallback to origin/main when no default source is configured", async () => {
157
+ const mockGetConfigValue = vi
158
+ .spyOn(git, "gitGetConfigValue")
159
+ .mockResolvedValue("");
160
+ const mockGitCreateWorktree = vi
161
+ .spyOn(git, "gitCreateWorktree")
162
+ .mockResolvedValue("/path/to/worktree");
163
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
164
+ branch.parse = vi.fn().mockResolvedValue({
165
+ args: { branchName: "feature/test" },
166
+ flags: {},
167
+ });
168
+ await branch.run();
169
+ expect(mockGetConfigValue).toHaveBeenCalledWith("defaultSourceBranch");
170
+ expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/main");
171
+ });
172
+ it("should handle non-existing local source branch", async () => {
173
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
174
+ vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue(["main"]);
175
+ mockConfirm.mockResolvedValue(true);
176
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
177
+ branch.parse = vi.fn().mockResolvedValue({
178
+ args: { branchName: "feature/test" },
179
+ flags: { source: "nonexistent" },
180
+ });
181
+ const mockError = vi.spyOn(branch, "error").mockImplementation(() => {
182
+ throw new Error("Source branch doesn't exist: nonexistent");
183
+ });
184
+ await expect(branch.run()).rejects.toThrow("Source branch doesn't exist: nonexistent");
185
+ expect(mockError).toHaveBeenCalledWith("Source branch doesn't exist: nonexistent");
186
+ });
187
+ it("should reject using local source when confirmation is denied", async () => {
188
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(["origin/main"]);
189
+ mockConfirm.mockResolvedValue(false);
190
+ vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("origin/main");
191
+ const mockGitCreateWorktree = vi
192
+ .spyOn(git, "gitCreateWorktree")
193
+ .mockResolvedValue("/path/to/worktree");
194
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
195
+ branch.parse = vi.fn().mockResolvedValue({
196
+ args: { branchName: "feature/test" },
197
+ flags: { source: "develop" },
198
+ });
199
+ await branch.run();
200
+ expect(mockConfirm).toHaveBeenCalledWith({
201
+ message: "The source branch does not start with 'origin/'. Are you sure you want to use a local source?",
202
+ });
203
+ // Should fallback to default source branch
204
+ expect(mockGitCreateWorktree).toHaveBeenCalledWith("feature/test", "origin/main");
205
+ });
206
+ });
207
+ });
@@ -0,0 +1,19 @@
1
+ import { BaseCommand } from "../lib/base-command.js";
2
+ export default class Config extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ name: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
7
+ value: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
8
+ };
9
+ static flags: {
10
+ list: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ help: import("@oclif/core/interfaces").BooleanFlag<void>;
12
+ missing: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ private renderList;
15
+ private getPromptConfigNames;
16
+ private renderInput;
17
+ private isValidArgName;
18
+ run(): Promise<void>;
19
+ }
@@ -0,0 +1,125 @@
1
+ import { EOL } from "node:os";
2
+ import { confirm, input } from "@inquirer/prompts";
3
+ import { Args, Flags } from "@oclif/core";
4
+ import { BaseCommand } from "../lib/base-command.js";
5
+ import { CONFIG_NAMES } from "../lib/constants.js";
6
+ import { gitGetConfigValue, gitSetConfigValue } from "../lib/git.js";
7
+ import { conjoin } from "../lib/utils.js";
8
+ import { isValidBranch, isValidCommand, isValidEmail, validateConfigValue, } from "../lib/validators.js";
9
+ export default class Config extends BaseCommand {
10
+ static description = "Configure worktree CLI settings";
11
+ static examples = ["<%= config.bin %> <%= command.id %>"];
12
+ static args = {
13
+ name: Args.string(),
14
+ value: Args.string(),
15
+ };
16
+ static flags = {
17
+ list: Flags.boolean({
18
+ char: "l",
19
+ description: "List all available variables",
20
+ }),
21
+ help: Flags.help({ char: "h", description: "Show config help" }),
22
+ missing: Flags.boolean({
23
+ char: "m",
24
+ description: "Only prompt missing variables",
25
+ }),
26
+ };
27
+ async renderList(missing) {
28
+ let count = 0;
29
+ for (const variable of CONFIG_NAMES) {
30
+ const value = await gitGetConfigValue(variable);
31
+ if (missing && value) {
32
+ continue;
33
+ }
34
+ this.log(value ? `${variable}=${value}` : variable);
35
+ count++;
36
+ }
37
+ if (missing && count === 0) {
38
+ this.log("No variables have missing values");
39
+ }
40
+ }
41
+ async getPromptConfigNames(missing) {
42
+ if (missing) {
43
+ const configNames = [];
44
+ for (const name of CONFIG_NAMES) {
45
+ if (!(await gitGetConfigValue(name))) {
46
+ configNames.push(name);
47
+ }
48
+ }
49
+ return configNames;
50
+ }
51
+ return [...CONFIG_NAMES];
52
+ }
53
+ async renderInput(missing) {
54
+ const configNames = await this.getPromptConfigNames(missing);
55
+ const hasJiraPrompt = !!configNames.find((name) => name.startsWith("jira"));
56
+ // First check if there is anything to prompt
57
+ if (configNames.length === 0) {
58
+ this.log("No missing config found.");
59
+ return;
60
+ }
61
+ function shouldPrompt(name) {
62
+ return configNames.includes(name);
63
+ }
64
+ if (hasJiraPrompt &&
65
+ (await confirm({ message: "Do you want to configure Jira integration?" }))) {
66
+ if (shouldPrompt("jira.domain")) {
67
+ const jiraDomain = await input({
68
+ message: "Jira subdomain or full domain",
69
+ });
70
+ await gitSetConfigValue("jira.domain", jiraDomain);
71
+ }
72
+ if (shouldPrompt("jira.email")) {
73
+ const jiraEmail = await input({
74
+ message: "Jira email",
75
+ validate: isValidEmail,
76
+ });
77
+ await gitSetConfigValue("jira.email", jiraEmail);
78
+ }
79
+ }
80
+ if (shouldPrompt("defaultSourceBranch")) {
81
+ const defaultBranchName = await input({
82
+ message: "Which branch should new worktrees be based on?",
83
+ default: "origin/main",
84
+ validate: isValidBranch,
85
+ });
86
+ await gitSetConfigValue("defaultSourceBranch", defaultBranchName);
87
+ }
88
+ if (shouldPrompt("codeEditor") &&
89
+ (await confirm({
90
+ message: "Do you want to automatically open the worktree in a code editor?",
91
+ }))) {
92
+ const codeEditor = await input({
93
+ message: "Command to open code editor?",
94
+ default: "code",
95
+ validate: isValidCommand,
96
+ });
97
+ await gitSetConfigValue("codeEditor", codeEditor);
98
+ }
99
+ }
100
+ isValidArgName(name) {
101
+ return CONFIG_NAMES.includes(name);
102
+ }
103
+ async run() {
104
+ const { args, flags } = await this.parse(Config);
105
+ if (args.name) {
106
+ if (!this.isValidArgName(args.name)) {
107
+ this.error([
108
+ `Unknown config name: ${args.name}`,
109
+ `Available variables: ${conjoin(CONFIG_NAMES)}`,
110
+ ].join(EOL));
111
+ }
112
+ if (args.value) {
113
+ await validateConfigValue(args.name, args.value);
114
+ await gitSetConfigValue(args.name, args.value);
115
+ return;
116
+ }
117
+ await gitGetConfigValue(args.name);
118
+ return;
119
+ }
120
+ if (flags.list) {
121
+ return this.renderList(flags.missing);
122
+ }
123
+ return this.renderInput(flags.missing);
124
+ }
125
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import * as git from "../lib/git.js";
2
+ import * as validators from "../lib/validators.js";
3
+ import Config from "./config.js";
4
+ describe("config command", () => {
5
+ let config;
6
+ let mockConsoleLog;
7
+ beforeEach(() => {
8
+ vi.clearAllMocks();
9
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif testing
10
+ config = new Config([], {});
11
+ mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => { });
12
+ });
13
+ describe("--list flag", () => {
14
+ it("should list all config values when they exist", async () => {
15
+ // Mock the parse method to simulate --list flag
16
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
17
+ config.parse = vi.fn().mockResolvedValue({
18
+ args: {},
19
+ flags: { list: true, missing: false },
20
+ });
21
+ vi.spyOn(git, "gitGetConfigValue")
22
+ .mockResolvedValueOnce("https://test.atlassian.net") // jira.domain
23
+ .mockResolvedValueOnce("test@example.com") // jira.email
24
+ .mockResolvedValueOnce("api-token-123") // jira.apiToken
25
+ .mockResolvedValueOnce("code") // codeEditor
26
+ .mockResolvedValueOnce("origin/main"); // defaultSourceBranch
27
+ await config.run();
28
+ expect(mockConsoleLog).toHaveBeenCalledWith("jira.domain=https://test.atlassian.net");
29
+ expect(mockConsoleLog).toHaveBeenCalledWith("jira.email=test@example.com");
30
+ expect(mockConsoleLog).toHaveBeenCalledWith("jira.apiToken=api-token-123");
31
+ expect(mockConsoleLog).toHaveBeenCalledWith("codeEditor=code");
32
+ expect(mockConsoleLog).toHaveBeenCalledWith("defaultSourceBranch=origin/main");
33
+ });
34
+ it("should list config names when values are missing", async () => {
35
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
36
+ config.parse = vi.fn().mockResolvedValue({
37
+ args: {},
38
+ flags: { list: true, missing: false },
39
+ });
40
+ vi.spyOn(git, "gitGetConfigValue").mockResolvedValue(""); // All empty
41
+ await config.run();
42
+ expect(mockConsoleLog).toHaveBeenCalledWith("jira.domain");
43
+ expect(mockConsoleLog).toHaveBeenCalledWith("jira.email");
44
+ expect(mockConsoleLog).toHaveBeenCalledWith("jira.apiToken");
45
+ expect(mockConsoleLog).toHaveBeenCalledWith("codeEditor");
46
+ expect(mockConsoleLog).toHaveBeenCalledWith("defaultSourceBranch");
47
+ });
48
+ it("should handle --missing flag with no missing values", async () => {
49
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
50
+ config.parse = vi.fn().mockResolvedValue({
51
+ args: {},
52
+ flags: { list: true, missing: true },
53
+ });
54
+ vi.spyOn(git, "gitGetConfigValue").mockResolvedValue("some-value"); // All have values
55
+ await config.run();
56
+ expect(mockConsoleLog).toHaveBeenCalledWith("No variables have missing values");
57
+ });
58
+ });
59
+ describe("setting config values", () => {
60
+ it("should set a valid config value", async () => {
61
+ const mockSetConfigValue = vi
62
+ .spyOn(git, "gitSetConfigValue")
63
+ .mockResolvedValue();
64
+ const mockValidateConfigValue = vi
65
+ .spyOn(validators, "validateConfigValue")
66
+ .mockResolvedValue();
67
+ // Mock the parse method to return the args we want
68
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
69
+ config.parse = vi.fn().mockResolvedValue({
70
+ args: { name: "jira.email", value: "test@example.com" },
71
+ flags: {},
72
+ });
73
+ await config.run();
74
+ expect(mockValidateConfigValue).toHaveBeenCalledWith("jira.email", "test@example.com");
75
+ expect(mockSetConfigValue).toHaveBeenCalledWith("jira.email", "test@example.com");
76
+ });
77
+ it("should get config value when only name is provided", async () => {
78
+ const mockGetConfigValue = vi
79
+ .spyOn(git, "gitGetConfigValue")
80
+ .mockResolvedValue("test@example.com");
81
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
82
+ config.parse = vi.fn().mockResolvedValue({
83
+ args: { name: "jira.email" },
84
+ flags: {},
85
+ });
86
+ await config.run();
87
+ expect(mockGetConfigValue).toHaveBeenCalledWith("jira.email");
88
+ });
89
+ });
90
+ describe("error handling", () => {
91
+ it("should handle unknown config name", async () => {
92
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
93
+ config.parse = vi.fn().mockResolvedValue({
94
+ args: { name: "unknown.setting", value: "test" },
95
+ flags: {},
96
+ });
97
+ await expect(config.run()).rejects.toThrow("Unknown config name: unknown.setting");
98
+ });
99
+ it("should handle invalid config value", async () => {
100
+ vi.spyOn(validators, "validateConfigValue").mockRejectedValue(new validators.InvalidConfigValueError("Invalid email address"));
101
+ // biome-ignore lint/suspicious/noExplicitAny: Required for oclif method mocking
102
+ config.parse = vi.fn().mockResolvedValue({
103
+ args: { name: "jira.email", value: "invalid-email" },
104
+ flags: {},
105
+ });
106
+ await expect(config.run()).rejects.toThrow("Invalid email address");
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,6 @@
1
+ import { Command } from "@oclif/core";
2
+ import type { CommandError } from "@oclif/core/interfaces";
3
+ export declare abstract class BaseCommand extends Command {
4
+ protected openWorktreePath(path: string): Promise<void>;
5
+ protected catch(error: CommandError): Promise<any>;
6
+ }
@@ -0,0 +1,36 @@
1
+ import { exec } from "node:child_process";
2
+ import { Command } from "@oclif/core";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import { gitGetConfigValue } from "./git.js";
6
+ export class BaseCommand extends Command {
7
+ async openWorktreePath(path) {
8
+ const codeEditor = await gitGetConfigValue("codeEditor");
9
+ if (codeEditor) {
10
+ const spinner = ora(`Opening in ${codeEditor}`).start();
11
+ exec(`${codeEditor} ${path}`, (error) => {
12
+ if (error) {
13
+ spinner.fail(error.message);
14
+ }
15
+ else {
16
+ spinner.succeed();
17
+ }
18
+ });
19
+ }
20
+ else {
21
+ this.log(`${chalk.green("✔")} Worktree created in ${path}`);
22
+ }
23
+ }
24
+ async catch(error) {
25
+ if (error instanceof Error) {
26
+ if (error.name === "ExitPromptError") {
27
+ // Silently exit
28
+ this.exit();
29
+ }
30
+ // Color the error message red for better visibility
31
+ this.log(chalk.red(`Error: ${error.message}`));
32
+ this.exit();
33
+ }
34
+ return super.catch(error);
35
+ }
36
+ }
@@ -0,0 +1,6 @@
1
+ interface CmdOptions {
2
+ debug?: boolean;
3
+ }
4
+ export declare function cmd(cmd: string, { debug }?: CmdOptions): Promise<string>;
5
+ export declare function commandExists(command: string): Promise<boolean>;
6
+ export {};
@@ -0,0 +1,30 @@
1
+ import { exec } from "node:child_process";
2
+ export function cmd(cmd, { debug = false } = {}) {
3
+ return new Promise((resolve, reject) => {
4
+ if (debug) {
5
+ console.log(`DEBUG: ${cmd}`);
6
+ resolve("");
7
+ return;
8
+ }
9
+ exec(cmd, (error, stdout) => {
10
+ if (error) {
11
+ reject(error);
12
+ return;
13
+ }
14
+ resolve(stdout?.trim() ?? "");
15
+ });
16
+ });
17
+ }
18
+ export async function commandExists(command) {
19
+ try {
20
+ // Extract the base command
21
+ const baseCommand = command.split(" ")[0];
22
+ // Use 'which' on *nix, 'where' on Windows
23
+ const checkCommand = process.platform === "win32" ? "where" : "which";
24
+ await cmd(`${checkCommand} ${baseCommand}`);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
@@ -0,0 +1,2 @@
1
+ export declare const CONFIG_NAMES: readonly ["jira.domain", "jira.email", "jira.apiToken", "codeEditor", "defaultSourceBranch"];
2
+ export type ConfigName = (typeof CONFIG_NAMES)[number];
@@ -0,0 +1,7 @@
1
+ export const CONFIG_NAMES = [
2
+ "jira.domain",
3
+ "jira.email",
4
+ "jira.apiToken",
5
+ "codeEditor",
6
+ "defaultSourceBranch",
7
+ ];
@@ -0,0 +1 @@
1
+ export declare function copyEnvFilesFromRootPath(destinationWorktreePath: string): Promise<void>;
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs";
2
+ import { glob } from "glob";
3
+ import ora from "ora";
4
+ import { gitGetRootPath } from "./git.js";
5
+ export async function copyEnvFilesFromRootPath(destinationWorktreePath) {
6
+ const gitRootPath = await gitGetRootPath();
7
+ // Find both .env and .env.local files
8
+ const envResults = await glob(`${gitRootPath}/**/.{env,env.local}`, {
9
+ ignore: ["node_modules/**"],
10
+ });
11
+ const rootEnvFiles = envResults
12
+ // Change to a relative path
13
+ .map((filePath) => filePath.substring(gitRootPath.length + 1));
14
+ for (const envFile of rootEnvFiles) {
15
+ const sourceFile = `${gitRootPath}/${envFile}`;
16
+ const destinationFile = `${destinationWorktreePath}/${envFile}`;
17
+ const spinner = ora(`Copying ${sourceFile} to worktree`).start();
18
+ fs.copyFileSync(sourceFile, destinationFile);
19
+ spinner.succeed();
20
+ }
21
+ }
@@ -0,0 +1,12 @@
1
+ import type { ConfigName } from "./constants.js";
2
+ export declare function gitGetConfigValue(name: ConfigName): Promise<string>;
3
+ export declare function gitSetConfigValue(name: ConfigName, value: string): Promise<void>;
4
+ export declare function gitFetch(): Promise<string>;
5
+ export declare function gitGetRootPath(): Promise<string>;
6
+ export declare function gitGetLocalBranches(): Promise<string[]>;
7
+ export declare function gitGetRemoteBranches(): Promise<string[]>;
8
+ interface GitCreateWorktreeOptions {
9
+ isRemoteBranch?: boolean;
10
+ }
11
+ export declare function gitCreateWorktree(branchName: string, sourceBranch: string, { isRemoteBranch }?: GitCreateWorktreeOptions): Promise<string>;
12
+ export {};
@@ -0,0 +1,89 @@
1
+ import { EOL } from "node:os";
2
+ import path from "node:path";
3
+ import ora from "ora";
4
+ import { cmd } from "./cli.js";
5
+ export async function gitGetConfigValue(name) {
6
+ try {
7
+ return await cmd(`git config burglekitt.worktree.${name}`);
8
+ }
9
+ catch {
10
+ return "";
11
+ }
12
+ }
13
+ export async function gitSetConfigValue(name, value) {
14
+ await cmd(`git config burglekitt.worktree.${name} "${value}"`);
15
+ }
16
+ async function gitCmdShowTopLevel() {
17
+ return cmd("git rev-parse --show-toplevel");
18
+ }
19
+ async function gitCmdGitPath() {
20
+ return cmd("git rev-parse --absolute-git-dir");
21
+ }
22
+ export async function gitFetch() {
23
+ return cmd("git fetch --prune");
24
+ }
25
+ export async function gitGetRootPath() {
26
+ try {
27
+ const topLevelPath = await gitCmdShowTopLevel();
28
+ if (!topLevelPath.includes(".worktrees/")) {
29
+ return topLevelPath;
30
+ }
31
+ const gitPathResult = await gitCmdGitPath();
32
+ const gitPath = gitPathResult.split("/.git")[0];
33
+ return gitPath;
34
+ }
35
+ catch {
36
+ throw new Error(`Git: Unable find the root path. Are you in a git repository?`);
37
+ }
38
+ }
39
+ export async function gitGetLocalBranches() {
40
+ const res = await cmd("git --no-pager branch");
41
+ return res.split(EOL).map((branch) => {
42
+ const trimmed = branch.trim();
43
+ if (trimmed.includes(" ")) {
44
+ return trimmed.split(" ")[1];
45
+ }
46
+ return trimmed;
47
+ });
48
+ }
49
+ export async function gitGetRemoteBranches() {
50
+ const res = await cmd("git --no-pager branch -r");
51
+ return res.split(EOL).map((branch) => {
52
+ const trimmed = branch.trim();
53
+ if (trimmed.includes(" ")) {
54
+ return trimmed.split(" ")[1];
55
+ }
56
+ return trimmed;
57
+ });
58
+ }
59
+ export async function gitCreateWorktree(branchName, sourceBranch, { isRemoteBranch = false } = {}) {
60
+ const spinner = ora(`Creating worktree ${branchName}`).start();
61
+ try {
62
+ const currentPath = process.env.PWD;
63
+ const gitRootPath = await gitGetRootPath();
64
+ const worktreesRootPath = `../${path.basename(gitRootPath)}.worktrees`;
65
+ const worktreePath = `${worktreesRootPath}/${branchName}`;
66
+ const absoluteWorktreePath = `${gitRootPath}.worktrees/${branchName}`;
67
+ // cd into the root path so we can create a relative worktree. This ensures that
68
+ // everything stays in sync in case the project is moved in the filesystem.
69
+ const cdRoot = `cd ${gitRootPath}`;
70
+ // Fetch the latest changes from the remote
71
+ const gitFetch = "git fetch";
72
+ // If adding from a remote branch, allow tracking
73
+ const addWorktree = isRemoteBranch
74
+ ? `git worktree add ${worktreePath} ${branchName}`
75
+ : `git worktree add --no-track -b ${branchName} ${worktreePath} ${sourceBranch}`;
76
+ // Go back
77
+ const gotoBack = `cd ${currentPath}`;
78
+ // Run them all in sequence
79
+ await cmd(`${cdRoot} && ${gitFetch} && ${addWorktree} && ${gotoBack}`);
80
+ // Stop the spinner
81
+ spinner.succeed();
82
+ // Return the absolute path to the new worktree
83
+ return absoluteWorktreePath;
84
+ }
85
+ catch (error) {
86
+ spinner.fail(error instanceof Error ? error.message : String(error));
87
+ throw error;
88
+ }
89
+ }
@@ -0,0 +1 @@
1
+ export declare function conjoin(arr: readonly (string | number)[], conjunction?: "and" | "or"): string;
@@ -0,0 +1,11 @@
1
+ export function conjoin(arr, conjunction = "and") {
2
+ if (arr.length === 0)
3
+ return "";
4
+ if (arr.length === 1)
5
+ return String(arr[0]);
6
+ if (arr.length === 2)
7
+ return `${arr[0]} ${conjunction} ${arr[1]}`;
8
+ const allButLast = arr.slice(0, -1).join(", ");
9
+ const last = arr[arr.length - 1];
10
+ return `${allButLast} ${conjunction} ${last}`;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { conjoin } from "./utils.js";
2
+ describe("conjoin", () => {
3
+ it.each `
4
+ input | conjunction | expected
5
+ ${[]} | ${undefined} | ${""}
6
+ ${["apple"]} | ${undefined} | ${"apple"}
7
+ ${[42]} | ${undefined} | ${"42"}
8
+ ${["apple", "banana"]} | ${undefined} | ${"apple and banana"}
9
+ ${[1, 2]} | ${undefined} | ${"1 and 2"}
10
+ ${["apple", "banana"]} | ${"or"} | ${"apple or banana"}
11
+ ${[1, 2]} | ${"or"} | ${"1 or 2"}
12
+ ${["apple", "banana", "cherry"]} | ${undefined} | ${"apple, banana and cherry"}
13
+ ${[1, 2, 3, 4]} | ${undefined} | ${"1, 2, 3 and 4"}
14
+ ${["apple", "banana", "cherry"]} | ${"or"} | ${"apple, banana or cherry"}
15
+ ${[1, 2, 3, 4, 5]} | ${"or"} | ${"1, 2, 3, 4 or 5"}
16
+ ${["item1", 2, "item3"]} | ${undefined} | ${"item1, 2 and item3"}
17
+ ${[100, "apples", 200, "oranges"]} | ${"or"} | ${"100, apples, 200 or oranges"}
18
+ `('should return "$expected" for input $input with conjunction "$conjunction"', ({ input, conjunction, expected }) => {
19
+ const result = conjunction ? conjoin(input, conjunction) : conjoin(input);
20
+ expect(result).toBe(expected);
21
+ });
22
+ });
@@ -0,0 +1,8 @@
1
+ export declare function isValidEmail(value: string): true | string;
2
+ export declare function isValidCommand(value: string): Promise<true | string>;
3
+ export declare function isValidBranch(value: string): Promise<true | string>;
4
+ export declare function isValidBranchName(value: string): true | string;
5
+ export declare function isValidConfigValue(configName: string, value: string): Promise<true | string>;
6
+ export declare class InvalidConfigValueError extends Error {
7
+ }
8
+ export declare function validateConfigValue(configName: string, value: string): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { commandExists } from "./cli.js";
2
+ import { gitGetLocalBranches, gitGetRemoteBranches } from "./git.js";
3
+ export function isValidEmail(value) {
4
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Invalid email address";
5
+ }
6
+ export async function isValidCommand(value) {
7
+ if (!(await commandExists(value))) {
8
+ return `Command not found: ${value}`;
9
+ }
10
+ return true;
11
+ }
12
+ export async function isValidBranch(value) {
13
+ const branches = value.startsWith("origin/")
14
+ ? await gitGetRemoteBranches()
15
+ : await gitGetLocalBranches();
16
+ return branches.includes(value) || `Branch not found: ${value}`;
17
+ }
18
+ export function isValidBranchName(value) {
19
+ if (!value || value.trim() === "") {
20
+ return "Branch name cannot be empty";
21
+ }
22
+ // Git branch naming rules
23
+ const invalidPatterns = [
24
+ { regex: /^\./, message: "Branch name cannot start with a dot" },
25
+ { regex: /\.\.$/, message: "Branch name cannot end with double dots" },
26
+ { regex: /\.\./, message: "Branch name cannot contain double dots" },
27
+ { regex: /\s/, message: "Branch name cannot contain spaces" },
28
+ {
29
+ regex: /[~^:\\*?[\]@{]/,
30
+ message: "Branch name contains invalid characters",
31
+ },
32
+ { regex: /\.$/, message: "Branch name cannot end with a dot" },
33
+ { regex: /\/$/, message: "Branch name cannot end with a slash" },
34
+ {
35
+ regex: /\/\//,
36
+ message: "Branch name cannot contain consecutive slashes",
37
+ },
38
+ { regex: /^\//, message: "Branch name cannot start with a slash" },
39
+ { regex: /\.$/, message: "Branch name cannot end with a dot" },
40
+ { regex: /\.lock$/, message: "Branch name cannot end with '.lock'" },
41
+ ];
42
+ for (const { regex, message } of invalidPatterns) {
43
+ if (regex.test(value)) {
44
+ return message;
45
+ }
46
+ }
47
+ return true;
48
+ }
49
+ export async function isValidConfigValue(configName, value) {
50
+ switch (configName) {
51
+ case "jira.email":
52
+ return isValidEmail(value);
53
+ case "defaultSourceBranch":
54
+ return isValidBranch(value);
55
+ case "codeEditor":
56
+ return await isValidCommand(value);
57
+ default:
58
+ return true;
59
+ }
60
+ }
61
+ export class InvalidConfigValueError extends Error {
62
+ }
63
+ export async function validateConfigValue(configName, value) {
64
+ const validationResult = await isValidConfigValue(configName, value);
65
+ if (validationResult !== true) {
66
+ throw new InvalidConfigValueError(validationResult);
67
+ }
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,122 @@
1
+ import * as cli from "./cli.js";
2
+ import * as git from "./git.js";
3
+ import { isValidBranch, isValidBranchName, isValidCommand, isValidEmail, } from "./validators.js";
4
+ describe("isValidEmail", () => {
5
+ it.each `
6
+ email | expected | description
7
+ ${"user@example.com"} | ${true} | ${"valid basic email"}
8
+ ${"test.email@domain.co.uk"} | ${true} | ${"valid email with dots and co.uk"}
9
+ ${"user+tag@example.org"} | ${true} | ${"valid email with plus sign"}
10
+ ${"user_name@example.net"} | ${true} | ${"valid email with underscore"}
11
+ ${"123@example.com"} | ${true} | ${"valid email with numbers"}
12
+ ${"user-name@example-site.com"} | ${true} | ${"valid email with hyphens"}
13
+ ${"a@b.co"} | ${true} | ${"valid minimal email"}
14
+ ${"user@subdomain.example.com"} | ${true} | ${"valid email with subdomain"}
15
+ ${"user@example.info"} | ${true} | ${"valid email with .info tld"}
16
+ ${"plainaddress"} | ${"Invalid email address"} | ${"missing @ symbol"}
17
+ ${"@example.com"} | ${"Invalid email address"} | ${"missing local part"}
18
+ ${"user@"} | ${"Invalid email address"} | ${"missing domain"}
19
+ ${"user..double.dot@example.com"} | ${true} | ${"double dots in local part (allowed by current regex)"}
20
+ ${"user@.example.com"} | ${true} | ${"domain starts with dot (allowed by current regex)"}
21
+ ${"user@example."} | ${"Invalid email address"} | ${"domain ends with dot"}
22
+ ${"user@com"} | ${"Invalid email address"} | ${"missing top-level domain"}
23
+ ${"user name@example.com"} | ${"Invalid email address"} | ${"space in local part"}
24
+ ${"user@example com"} | ${"Invalid email address"} | ${"space in domain"}
25
+ ${""} | ${"Invalid email address"} | ${"empty string"}
26
+ ${"user@@example.com"} | ${"Invalid email address"} | ${"double @ symbol"}
27
+ ${"user@example..com"} | ${true} | ${"double dots in domain (allowed by current regex)"}
28
+ `('should return $expected for "$email" ($description)', ({ email, expected }) => {
29
+ expect(isValidEmail(email)).toBe(expected);
30
+ });
31
+ });
32
+ describe("isValidCommand", () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+ it.each `
37
+ command | commandExists | expected | description
38
+ ${"git"} | ${true} | ${true} | ${"existing command"}
39
+ ${"node"} | ${true} | ${true} | ${"another existing command"}
40
+ ${"git status"} | ${true} | ${true} | ${"command with arguments"}
41
+ ${"nonexistent"} | ${false} | ${"Command not found: nonexistent"} | ${"non-existing command"}
42
+ ${"fake-cmd"} | ${false} | ${"Command not found: fake-cmd"} | ${"another non-existing command"}
43
+ ${"bad command with args"} | ${false} | ${"Command not found: bad command with args"} | ${"non-existing command with args"}
44
+ `('should return $expected for "$command" when commandExists returns $commandExists ($description)', async ({ command, commandExists, expected }) => {
45
+ // Mock the commandExists function
46
+ vi.spyOn(cli, "commandExists").mockResolvedValue(commandExists);
47
+ expect(await isValidCommand(command)).toBe(expected);
48
+ expect(cli.commandExists).toHaveBeenCalledWith(command);
49
+ });
50
+ });
51
+ describe("isValidBranch", () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ // Mock console.log to avoid cluttering test output
55
+ vi.spyOn(console, "log").mockImplementation(() => { });
56
+ });
57
+ it.each `
58
+ branch | branchType | availableBranches | expected | description
59
+ ${"main"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${true} | ${"existing local branch"}
60
+ ${"develop"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${true} | ${"another existing local branch"}
61
+ ${"feature/test"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${true} | ${"local branch with slash"}
62
+ ${"nonexistent"} | ${"local"} | ${["main", "develop", "feature/test"]} | ${"Branch not found: nonexistent"} | ${"non-existing local branch"}
63
+ ${"origin/main"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${true} | ${"existing remote branch"}
64
+ ${"origin/develop"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${true} | ${"another existing remote branch"}
65
+ ${"origin/feature/test"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${true} | ${"remote branch with slash"}
66
+ ${"origin/nonexistent"} | ${"remote"} | ${["origin/main", "origin/develop", "origin/feature/test"]} | ${"Branch not found: origin/nonexistent"} | ${"non-existing remote branch"}
67
+ ${"upstream/main"} | ${"remote"} | ${["origin/main", "origin/develop"]} | ${"Branch not found: upstream/main"} | ${"different remote prefix"}
68
+ `('should return $expected for "$branch" when $branchType branches are $availableBranches ($description)', async ({ branch, branchType, availableBranches, expected }) => {
69
+ if (branchType === "local") {
70
+ vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue(availableBranches);
71
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue([]);
72
+ }
73
+ else {
74
+ vi.spyOn(git, "gitGetRemoteBranches").mockResolvedValue(availableBranches);
75
+ vi.spyOn(git, "gitGetLocalBranches").mockResolvedValue([]);
76
+ }
77
+ const result = await isValidBranch(branch);
78
+ expect(result).toBe(expected);
79
+ if (branch.startsWith("origin/")) {
80
+ expect(git.gitGetRemoteBranches).toHaveBeenCalled();
81
+ expect(git.gitGetLocalBranches).not.toHaveBeenCalled();
82
+ }
83
+ else {
84
+ expect(git.gitGetLocalBranches).toHaveBeenCalled();
85
+ expect(git.gitGetRemoteBranches).not.toHaveBeenCalled();
86
+ }
87
+ });
88
+ });
89
+ describe("isValidBranchName", () => {
90
+ it.each `
91
+ branchName | expected | description
92
+ ${"main"} | ${true} | ${"valid simple branch name"}
93
+ ${"feature/new-feature"} | ${true} | ${"valid branch with slash"}
94
+ ${"bugfix/fix-123"} | ${true} | ${"valid branch with numbers"}
95
+ ${"release/v1.0.0"} | ${true} | ${"valid release branch"}
96
+ ${"user/john-doe"} | ${true} | ${"valid branch with hyphens"}
97
+ ${"feature_branch"} | ${true} | ${"valid branch with underscores"}
98
+ ${"123-branch"} | ${true} | ${"valid branch starting with numbers"}
99
+ ${""} | ${"Branch name cannot be empty"} | ${"empty string"}
100
+ ${" "} | ${"Branch name cannot be empty"} | ${"whitespace only"}
101
+ ${".hidden"} | ${"Branch name cannot start with a dot"} | ${"starts with dot"}
102
+ ${"feature..branch"} | ${"Branch name cannot contain double dots"} | ${"contains double dots"}
103
+ ${"branch.."} | ${"Branch name cannot end with double dots"} | ${"ends with double dots"}
104
+ ${"feature branch"} | ${"Branch name cannot contain spaces"} | ${"contains spaces"}
105
+ ${"branch~1"} | ${"Branch name contains invalid characters"} | ${"contains tilde"}
106
+ ${"branch^master"} | ${"Branch name contains invalid characters"} | ${"contains caret"}
107
+ ${"branch:colon"} | ${"Branch name contains invalid characters"} | ${"contains colon"}
108
+ ${"branch\\backslash"} | ${"Branch name contains invalid characters"} | ${"contains backslash"}
109
+ ${"branch*star"} | ${"Branch name contains invalid characters"} | ${"contains asterisk"}
110
+ ${"branch?question"} | ${"Branch name contains invalid characters"} | ${"contains question mark"}
111
+ ${"branch[bracket"} | ${"Branch name contains invalid characters"} | ${"contains bracket"}
112
+ ${"branch@at"} | ${"Branch name contains invalid characters"} | ${"contains at sign"}
113
+ ${"branch."} | ${"Branch name cannot end with a dot"} | ${"ends with dot"}
114
+ ${"branch/"} | ${"Branch name cannot end with a slash"} | ${"ends with slash"}
115
+ ${"feature//double"} | ${"Branch name cannot contain consecutive slashes"} | ${"consecutive slashes"}
116
+ ${"/starts-slash"} | ${"Branch name cannot start with a slash"} | ${"starts with slash"}
117
+ ${"branch.lock"} | ${"Branch name cannot end with '.lock'"} | ${"ends with .lock"}
118
+ `('should return $expected for "$branchName" ($description)', ({ branchName, expected }) => {
119
+ const result = isValidBranchName(branchName);
120
+ expect(result).toBe(expected);
121
+ });
122
+ });
@@ -0,0 +1,3 @@
1
+ declare const mockCmd: ReturnType<typeof vi.fn>;
2
+ declare function expectCommands(...commands: string[]): void;
3
+ export { mockCmd, expectCommands };
@@ -0,0 +1,28 @@
1
+ // Global mock for the cmd function to prevent actual shell command execution
2
+ const mockCmd = vi.fn();
3
+ let expectedCommands = [];
4
+ vi.mock("./lib/cli.js", () => ({
5
+ cmd: mockCmd,
6
+ commandExists: vi.fn().mockResolvedValue(true),
7
+ }));
8
+ beforeEach(() => {
9
+ // Clear all mocks before each test
10
+ vi.clearAllMocks();
11
+ expectedCommands = [];
12
+ });
13
+ afterEach(() => {
14
+ // Assert that no unexpected commands were called
15
+ const actualCalls = mockCmd.mock.calls.map((call) => call[0]);
16
+ const unexpectedCalls = actualCalls.filter((call) => !expectedCommands.includes(call));
17
+ if (unexpectedCalls.length > 0) {
18
+ console.warn(`Unexpected cmd calls detected:\n${unexpectedCalls
19
+ .map((call) => ` - ${call}`)
20
+ .join("\n")}`);
21
+ }
22
+ });
23
+ // Helper function for tests to declare expected commands
24
+ function expectCommands(...commands) {
25
+ expectedCommands.push(...commands);
26
+ }
27
+ // Export the mock and helper for use in tests
28
+ export { mockCmd, expectCommands };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@burglekitt/worktree",
3
+ "version": "0.1.0",
4
+ "description": "A CLI tool for managing git worktrees with enhanced workflow features",
5
+ "main": "./bin/run.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "shx rm -rf dist && tsc -b",
9
+ "test": "vitest run",
10
+ "typecheck": "tsc --noEmit",
11
+ "format": "biome format",
12
+ "format:fix": "biome format --write",
13
+ "lint": "biome lint",
14
+ "lint:fix": "biome lint --write"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "bin",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "git",
23
+ "worktree",
24
+ "cli",
25
+ "workflow"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/burglekitt/worktree.git"
30
+ },
31
+ "homepage": "https://github.com/burglekitt/worktree",
32
+ "license": "MIT",
33
+ "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
34
+ "bin": {
35
+ "worktree": "./bin/run.js",
36
+ "@burglekitt/worktree": "./bin/run.js"
37
+ },
38
+ "oclif": {
39
+ "bin": "worktree",
40
+ "commands": "./dist/commands",
41
+ "dirname": "worktree",
42
+ "topicSeparator": " "
43
+ },
44
+ "dependencies": {
45
+ "@inquirer/prompts": "^8.3.0",
46
+ "@oclif/core": "^4.8.3",
47
+ "chalk": "^5.6.2",
48
+ "glob": "^13.0.6",
49
+ "ora": "^9.3.0"
50
+ },
51
+ "devDependencies": {
52
+ "@biomejs/biome": "2.4.6",
53
+ "@oclif/test": "^4.1.16",
54
+ "@types/node": "^18.19.130",
55
+ "shx": "^0.4.0",
56
+ "ts-node": "^10.9.2",
57
+ "typescript": "^5.9.3",
58
+ "vitest": "^4.0.18"
59
+ }
60
+ }