worktree-add 1.0.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) 2025 Łukasz Jerciński
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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/cli.js");
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+ /**
3
+ * worktree-add.ts
4
+ *
5
+ * Create or reuse a Git worktree for <branch> as a sibling of the current worktree.
6
+ * Note: the destination is placed next to whichever worktree you run this from,
7
+ * not necessarily the original "main" checkout.
8
+ */
9
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+ /**
3
+ * worktree-add.ts
4
+ *
5
+ * Create or reuse a Git worktree for <branch> as a sibling of the current worktree.
6
+ * Note: the destination is placed next to whichever worktree you run this from,
7
+ * not necessarily the original "main" checkout.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import path from "node:path";
11
+ import { Command } from "commander";
12
+ import packageJson from "../package.json" with { type: "json" };
13
+ import { git, getRepositoryName, normalizeBranchName, toSafePathSegment, findWorktreeByBranchName, exitWithMessage, } from "./git/git.js";
14
+ import { copyUntrackedFiles } from "./worktree/untracked-file-copy.js";
15
+ import { fetchRemoteBranch, createWorktree } from "./git/worktree-creation.js";
16
+ import { handleExistingDirectory } from "./worktree/destination-directory.js";
17
+ import { setupProject } from "./project/setup.js";
18
+ async function main(branchRaw) {
19
+ const branch = normalizeBranchName(branchRaw);
20
+ // Prevent attempting to add a worktree for a branch that is already checked out
21
+ const existingWorktree = findWorktreeByBranchName(branch);
22
+ if (existingWorktree) {
23
+ exitWithMessage(`Branch '${branch}' is already checked out in: ${existingWorktree}\n` +
24
+ "You cannot add another worktree for the same branch.\n" +
25
+ "Open that worktree instead or remove it before retrying.");
26
+ }
27
+ // Determine destination directory for the new worktree
28
+ const repoRoot = git("rev-parse", "--show-toplevel");
29
+ const repoName = getRepositoryName();
30
+ const branchDirectorySegment = toSafePathSegment(branch);
31
+ const destinationDirectory = path.join(path.dirname(repoRoot), `${repoName}-${branchDirectorySegment}`);
32
+ // Check if destination directory already exists
33
+ await handleExistingDirectory(destinationDirectory);
34
+ // Step 1: Fetch remote branch if it exists
35
+ fetchRemoteBranch(branch);
36
+ // Step 2: Create the worktree
37
+ createWorktree(branch, destinationDirectory);
38
+ // Step 3: Copy untracked files, excluding any on the denylist
39
+ await copyUntrackedFiles(repoRoot, destinationDirectory);
40
+ // Step 4: Install dependencies and run project-specific setup
41
+ await setupProject(destinationDirectory);
42
+ // Step 6: Open the new worktree in Cursor
43
+ console.log("➤ Opening Cursor …");
44
+ spawnSync("cursor", [destinationDirectory], { stdio: "inherit" });
45
+ }
46
+ const program = new Command()
47
+ .name(packageJson.name)
48
+ .description("Create or reuse a Git worktree for a branch as a sibling of the current worktree")
49
+ .version(packageJson.version)
50
+ .argument("<branch>", "branch name for the worktree")
51
+ .action(async (branch) => {
52
+ try {
53
+ await main(branch);
54
+ }
55
+ catch (error) {
56
+ console.error(error);
57
+ process.exitCode = 1;
58
+ }
59
+ });
60
+ program.parse();
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Run a git command synchronously and return its trimmed stdout.
3
+ * Any failure causes the script to throw.
4
+ * @param args Git command arguments. If the last argument is an object with a `cwd` property,
5
+ * it will be used as the working directory for the command.
6
+ */
7
+ export declare function git(...arguments_: [...string[], {
8
+ cwd?: string;
9
+ }] | string[]): string;
10
+ /**
11
+ * Normalize a Git branch reference to a simple branch name.
12
+ *
13
+ * This helper trims surrounding whitespace and removes common prefixes so the
14
+ * resulting string matches the short local branch name used by refs under
15
+ * `refs/heads/`.
16
+ */
17
+ export declare function normalizeBranchName(name: string): string;
18
+ /**
19
+ * Check if a local branch exists by verifying its ref.
20
+ */
21
+ export declare function localBranchExists(branch: string): boolean;
22
+ /**
23
+ * Check if a remote branch exists on origin.
24
+ */
25
+ export declare function remoteBranchExists(branch: string): boolean;
26
+ /**
27
+ * Convert an arbitrary string to a filesystem-safe single path segment.
28
+ *
29
+ * Use this helper when constructing directory names from branch names or other
30
+ * user-provided identifiers.
31
+ */
32
+ export declare function toSafePathSegment(input: string): string;
33
+ /**
34
+ * Check if a file exists at the given path.
35
+ */
36
+ export declare function fileExists(p: string): Promise<boolean>;
37
+ /**
38
+ * Print an error message and exit the process with status 1.
39
+ */
40
+ export declare function exitWithMessage(message: string): never;
41
+ /**
42
+ * Prompts user for yes/no confirmation
43
+ * @param message The message to display to the user
44
+ * @returns Promise<boolean> - true if user confirms, false otherwise
45
+ */
46
+ export declare function confirm(message: string): Promise<boolean>;
47
+ export { getRepositoryName, findWorktreeByBranchName, } from "./worktree-discovery.js";
@@ -0,0 +1,129 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs/promises";
3
+ import * as readline from "node:readline/promises";
4
+ /**
5
+ * Run a git command synchronously and return its trimmed stdout.
6
+ * Any failure causes the script to throw.
7
+ * @param args Git command arguments. If the last argument is an object with a `cwd` property,
8
+ * it will be used as the working directory for the command.
9
+ */
10
+ export function git(...arguments_) {
11
+ let cwd;
12
+ let gitArguments;
13
+ // Check if the last argument is an options object
14
+ const lastArgument = arguments_.at(-1);
15
+ if (lastArgument &&
16
+ typeof lastArgument === "object" &&
17
+ "cwd" in lastArgument) {
18
+ cwd = lastArgument.cwd;
19
+ gitArguments = arguments_.slice(0, -1);
20
+ }
21
+ else {
22
+ gitArguments = arguments_;
23
+ }
24
+ const result = spawnSync("git", gitArguments, {
25
+ encoding: "utf8",
26
+ maxBuffer: 100 * 1024 * 1024, // 100 MB to accommodate large outputs
27
+ ...(cwd && { cwd }),
28
+ });
29
+ if (result.error)
30
+ throw result.error;
31
+ if (result.status !== 0) {
32
+ throw new Error(result.stderr || `git ${gitArguments[0] ?? "command"} failed`);
33
+ }
34
+ return result.stdout.trim();
35
+ }
36
+ /**
37
+ * Normalize a Git branch reference to a simple branch name.
38
+ *
39
+ * This helper trims surrounding whitespace and removes common prefixes so the
40
+ * resulting string matches the short local branch name used by refs under
41
+ * `refs/heads/`.
42
+ */
43
+ export function normalizeBranchName(name) {
44
+ const trimmed = name.trim();
45
+ return trimmed
46
+ .replace(/^refs\/heads\//u, "")
47
+ .replace(/^refs\/remotes\/origin\//u, "")
48
+ .replace(/^remotes\/origin\//u, "")
49
+ .replace(/^origin\//u, "");
50
+ }
51
+ /**
52
+ * Check if a local branch exists by verifying its ref.
53
+ */
54
+ export function localBranchExists(branch) {
55
+ try {
56
+ const normalized = normalizeBranchName(branch);
57
+ git("show-ref", "--verify", "--quiet", `refs/heads/${normalized}`);
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * Check if a remote branch exists on origin.
66
+ */
67
+ export function remoteBranchExists(branch) {
68
+ try {
69
+ const normalized = normalizeBranchName(branch);
70
+ return !!git("ls-remote", "--heads", "origin", normalized);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /**
77
+ * Convert an arbitrary string to a filesystem-safe single path segment.
78
+ *
79
+ * Use this helper when constructing directory names from branch names or other
80
+ * user-provided identifiers.
81
+ */
82
+ export function toSafePathSegment(input) {
83
+ return input
84
+ .trim()
85
+ .replaceAll(/[\\/:*?"<>|]/gu, "-")
86
+ .replaceAll(/\s+/gu, "-")
87
+ .replaceAll(/\.+$/gu, "")
88
+ .replaceAll(/-+/gu, "-");
89
+ }
90
+ /**
91
+ * Check if a file exists at the given path.
92
+ */
93
+ export async function fileExists(p) {
94
+ try {
95
+ await fs.access(p);
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * Print an error message and exit the process with status 1.
104
+ */
105
+ export function exitWithMessage(message) {
106
+ console.error(message);
107
+ // eslint-disable-next-line unicorn/no-process-exit -- This is a CLI helper function
108
+ process.exit(1);
109
+ }
110
+ /**
111
+ * Prompts user for yes/no confirmation
112
+ * @param message The message to display to the user
113
+ * @returns Promise<boolean> - true if user confirms, false otherwise
114
+ */
115
+ export async function confirm(message) {
116
+ const rl = readline.createInterface({
117
+ input: process.stdin,
118
+ output: process.stdout,
119
+ });
120
+ try {
121
+ const answer = await rl.question(`${message} [y/N] `);
122
+ return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
123
+ }
124
+ finally {
125
+ rl.close();
126
+ }
127
+ }
128
+ // Re-export worktree parsing functions for backward compatibility
129
+ export { getRepositoryName, findWorktreeByBranchName, } from "./worktree-discovery.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * worktree-creation.ts
3
+ *
4
+ * Utilities for creating Git worktrees
5
+ */
6
+ /**
7
+ * Fetch a remote branch if it exists locally but not remotely
8
+ */
9
+ export declare function fetchRemoteBranch(branch: string): void;
10
+ /**
11
+ * Create a worktree for the given branch at the specified destination
12
+ */
13
+ export declare function createWorktree(branch: string, destinationDirectory: string): void;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * worktree-creation.ts
3
+ *
4
+ * Utilities for creating Git worktrees
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import { git, localBranchExists, remoteBranchExists } from "./git.js";
8
+ /**
9
+ * Fetch a remote branch if it exists locally but not remotely
10
+ */
11
+ export function fetchRemoteBranch(branch) {
12
+ if (!localBranchExists(branch) && remoteBranchExists(branch)) {
13
+ console.log(`➤ Fetching origin/${branch} …`);
14
+ spawnSync("git", ["fetch", "origin", branch], {
15
+ stdio: "inherit",
16
+ });
17
+ }
18
+ }
19
+ /**
20
+ * Create a worktree for the given branch at the specified destination
21
+ */
22
+ export function createWorktree(branch, destinationDirectory) {
23
+ if (localBranchExists(branch)) {
24
+ // Reuse existing local branch
25
+ console.log(`➤ git worktree add ${destinationDirectory} ${branch}`);
26
+ git("worktree", "add", destinationDirectory, branch);
27
+ }
28
+ else if (remoteBranchExists(branch)) {
29
+ // Create new local branch tracking the remote branch
30
+ console.log(`➤ git worktree add --track -b ${branch} ${destinationDirectory} origin/${branch}`);
31
+ git("worktree", "add", "--track", "-b", branch, destinationDirectory, `origin/${branch}`);
32
+ }
33
+ else {
34
+ // Create new branch in the worktree from current HEAD
35
+ console.log(`➤ git worktree add -b ${branch} ${destinationDirectory}`);
36
+ git("worktree", "add", "-b", branch, destinationDirectory);
37
+ }
38
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * worktree-discovery.ts
3
+ *
4
+ * Utilities for parsing Git worktree information
5
+ */
6
+ /**
7
+ * Get the repository name from the main repository directory
8
+ * Works universally across all Git setups (worktrees, bare repos, no remotes, etc.)
9
+ */
10
+ export declare function getRepositoryName(): string;
11
+ /**
12
+ * Find the worktree path where the given branch is currently checked out.
13
+ * Returns the worktree path if found, otherwise undefined.
14
+ */
15
+ export declare function findWorktreeByBranchName(branchName: string): string | undefined;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * worktree-discovery.ts
3
+ *
4
+ * Utilities for parsing Git worktree information
5
+ */
6
+ import path from "node:path";
7
+ import { git } from "./git.js";
8
+ /**
9
+ * Get the repository name from the main repository directory
10
+ * Works universally across all Git setups (worktrees, bare repos, no remotes, etc.)
11
+ */
12
+ export function getRepositoryName() {
13
+ // Get the main worktree path from git worktree list
14
+ const wtList = git("worktree", "list", "--porcelain");
15
+ const mainLine = wtList.split(/\n/u).find((l) => l.startsWith("worktree "));
16
+ if (mainLine) {
17
+ // Extract path from "worktree /path/to/main/repo"
18
+ const mainWorktreePath = mainLine.replace(/^worktree\s+/u, "").trim();
19
+ return path.basename(mainWorktreePath);
20
+ }
21
+ // Fallback to current directory if worktree list fails
22
+ const repoRoot = git("rev-parse", "--show-toplevel");
23
+ return path.basename(repoRoot);
24
+ }
25
+ /**
26
+ * Find the worktree path where the given branch is currently checked out.
27
+ * Returns the worktree path if found, otherwise undefined.
28
+ */
29
+ export function findWorktreeByBranchName(branchName) {
30
+ const wtList = git("worktree", "list", "--porcelain");
31
+ const wtLines = wtList.split(/\n/u);
32
+ let currentWorktreePath;
33
+ for (const line of wtLines) {
34
+ if (line.startsWith("worktree ")) {
35
+ currentWorktreePath = line.replace(/^worktree\s+/u, "").trim();
36
+ }
37
+ else if (line.startsWith("branch ") && currentWorktreePath) {
38
+ const referenceOrName = line.replace(/^branch\s+/u, "").trim();
39
+ const shortName = referenceOrName.startsWith("refs/heads/")
40
+ ? referenceOrName.slice("refs/heads/".length)
41
+ : referenceOrName;
42
+ if (shortName === branchName) {
43
+ return currentWorktreePath;
44
+ }
45
+ }
46
+ }
47
+ return undefined;
48
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * install-commands.ts
3
+ *
4
+ * Package manager-specific install command logic
5
+ */
6
+ type PackageManager = "npm" | "yarn" | "pnpm" | "bun" | "deno" | undefined;
7
+ interface InstallCommand {
8
+ readonly command: string;
9
+ readonly args: string[];
10
+ }
11
+ /**
12
+ * Get the install command for the given package manager
13
+ */
14
+ export declare function getInstallCommand(pm: PackageManager, cwd: string): Promise<InstallCommand>;
15
+ /**
16
+ * Get the display message for an install command
17
+ */
18
+ export declare function getInstallMessage(cmd: InstallCommand): string;
19
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * install-commands.ts
3
+ *
4
+ * Package manager-specific install command logic
5
+ */
6
+ import path from "node:path";
7
+ import { fileExists } from "../git/git.js";
8
+ import { formatCommand, getYarnInstallArguments, } from "./package-manager-commands.js";
9
+ /**
10
+ * Get the install command for the given package manager
11
+ */
12
+ export async function getInstallCommand(pm, cwd) {
13
+ switch (pm) {
14
+ case "pnpm": {
15
+ return { command: "pnpm", args: ["install", "--frozen-lockfile"] };
16
+ }
17
+ case "yarn": {
18
+ const yarnArguments = getYarnInstallArguments(cwd);
19
+ return { command: "yarn", args: yarnArguments };
20
+ }
21
+ case "bun": {
22
+ return { command: "bun", args: ["install", "--frozen-lockfile"] };
23
+ }
24
+ case "deno": {
25
+ return { command: "deno", args: ["install", "--frozen"] };
26
+ }
27
+ case "npm":
28
+ case undefined: {
29
+ // Use npm ci if lockfile exists, otherwise npm install
30
+ const hasLockfile = await fileExists(path.join(cwd, "package-lock.json"));
31
+ if (hasLockfile) {
32
+ return { command: "npm", args: ["ci"] };
33
+ }
34
+ return { command: "npm", args: ["install"] };
35
+ }
36
+ }
37
+ }
38
+ /**
39
+ * Get the display message for an install command
40
+ */
41
+ export function getInstallMessage(cmd) {
42
+ if (cmd.command === "npm" && cmd.args[0] === "ci") {
43
+ return "➤ Running npm ci (using package-lock.json) …";
44
+ }
45
+ return `➤ Running ${formatCommand(cmd.command, cmd.args)} …`;
46
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * next.ts
3
+ *
4
+ * Utilities for working with Next.js projects
5
+ */
6
+ export declare const isNextProject: (directory: string) => Promise<boolean>;
7
+ export declare const isNextTypegenSupported: (directory: string) => Promise<boolean>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * next.ts
3
+ *
4
+ * Utilities for working with Next.js projects
5
+ */
6
+ import * as fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { runPackageManagerBinary } from "./package-manager.js";
9
+ const loadPackageJson = async (directory) => {
10
+ const packageJsonPath = path.join(directory, "package.json");
11
+ try {
12
+ const content = await fs.readFile(packageJsonPath, "utf8");
13
+ return JSON.parse(content);
14
+ }
15
+ catch (error) {
16
+ if (error.code === "ENOENT") {
17
+ return undefined;
18
+ }
19
+ throw error;
20
+ }
21
+ };
22
+ export const isNextProject = async (directory) => {
23
+ const packageJson = await loadPackageJson(directory);
24
+ if (!packageJson) {
25
+ return false;
26
+ }
27
+ return Boolean(packageJson.dependencies?.next ?? packageJson.devDependencies?.next);
28
+ };
29
+ export const isNextTypegenSupported = async (directory) => {
30
+ try {
31
+ const result = await runPackageManagerBinary(directory, "next", ["--help"], { stdio: "pipe" });
32
+ if (!result) {
33
+ return false;
34
+ }
35
+ return result.stdout
36
+ .split("\n")
37
+ .some((line) => line.trimStart().startsWith("typegen "));
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * package-manager-commands.ts
3
+ *
4
+ * Internal utilities for package manager operations
5
+ */
6
+ export declare const formatCommand: (command: string, arguments_: string[]) => string;
7
+ export declare const getYarnInstallArguments: (cwd: string) => string[];
8
+ export declare const getBinaryRunCommand: (pm: "npm" | "yarn" | "pnpm" | "bun" | "deno" | undefined, binary: string, arguments_: string[]) => {
9
+ command: string;
10
+ args: string[];
11
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * package-manager-commands.ts
3
+ *
4
+ * Internal utilities for package manager operations
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ export const formatCommand = (command, arguments_) => {
8
+ return [command, ...arguments_].join(" ").trim();
9
+ };
10
+ const parseMajorVersion = (value) => {
11
+ const match = value.trim().match(/^(\d+)/u);
12
+ if (!match) {
13
+ return undefined;
14
+ }
15
+ const major = Number.parseInt(match[1] ?? "");
16
+ return Number.isNaN(major) ? undefined : major;
17
+ };
18
+ export const getYarnInstallArguments = (cwd) => {
19
+ const result = spawnSync("yarn", ["--version"], {
20
+ cwd,
21
+ encoding: "utf8",
22
+ });
23
+ if (!result.error && result.status === 0) {
24
+ const versionOutput = result.stdout;
25
+ const major = parseMajorVersion(versionOutput);
26
+ if (major !== undefined && major >= 2) {
27
+ return ["install", "--immutable"];
28
+ }
29
+ }
30
+ return ["install", "--frozen-lockfile"];
31
+ };
32
+ export const getBinaryRunCommand = (pm, binary, arguments_) => {
33
+ switch (pm) {
34
+ case "pnpm": {
35
+ return { command: "pnpm", args: ["exec", binary, ...arguments_] };
36
+ }
37
+ case "yarn": {
38
+ return { command: "yarn", args: [binary, ...arguments_] };
39
+ }
40
+ case "bun": {
41
+ return { command: "bun", args: ["x", binary, ...arguments_] };
42
+ }
43
+ case "npm":
44
+ case "deno":
45
+ case undefined: {
46
+ return { command: "npx", args: [binary, ...arguments_] };
47
+ }
48
+ }
49
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * package-manager.ts
3
+ *
4
+ * Helpers for detecting and running package managers
5
+ */
6
+ interface RunOptions {
7
+ readonly stdio?: "inherit" | "pipe";
8
+ }
9
+ interface RunBinaryResult {
10
+ readonly stdout: string;
11
+ readonly stderr: string;
12
+ }
13
+ /**
14
+ * Install dependencies for a project using the detected package manager.
15
+ */
16
+ export declare function installDependencies(cwd: string): Promise<void>;
17
+ export declare function runPackageManagerBinary(cwd: string, binary: string, arguments_?: string[], options?: RunOptions): Promise<RunBinaryResult | undefined>;
18
+ export {};
@@ -0,0 +1,63 @@
1
+ /**
2
+ * package-manager.ts
3
+ *
4
+ * Helpers for detecting and running package managers
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import { detect } from "package-manager-detector/detect";
8
+ import { formatCommand, getBinaryRunCommand, } from "./package-manager-commands.js";
9
+ import { getInstallCommand, getInstallMessage } from "./install-commands.js";
10
+ const runOrThrow = (command, arguments_, cwd, options = {}) => {
11
+ const result = spawnSync(command, arguments_, {
12
+ cwd,
13
+ stdio: options.stdio ?? "inherit",
14
+ encoding: "utf8",
15
+ });
16
+ if (result.error) {
17
+ throw result.error;
18
+ }
19
+ if (result.status === null) {
20
+ const signal = result.signal ? ` (signal: ${result.signal})` : "";
21
+ throw new Error(`Command "${formatCommand(command, arguments_)}" terminated unexpectedly${signal}.`);
22
+ }
23
+ if (result.status !== 0) {
24
+ throw new Error(`Command "${formatCommand(command, arguments_)}" exited with code ${String(result.status)}.`);
25
+ }
26
+ return result;
27
+ };
28
+ /**
29
+ * Detect the package manager for a given project directory.
30
+ * Checks the packageManager field in package.json first, then falls back to lockfiles.
31
+ *
32
+ * @param cwd The project directory to detect the package manager for
33
+ * @returns The detected package manager name, or undefined if none detected
34
+ */
35
+ async function detectPackageManager(cwd) {
36
+ const result = await detect({
37
+ cwd,
38
+ strategies: ["packageManager-field", "lockfile"],
39
+ });
40
+ return result?.name;
41
+ }
42
+ /**
43
+ * Install dependencies for a project using the detected package manager.
44
+ */
45
+ export async function installDependencies(cwd) {
46
+ const pm = await detectPackageManager(cwd);
47
+ const cmd = await getInstallCommand(pm, cwd);
48
+ console.log(getInstallMessage(cmd));
49
+ runOrThrow(cmd.command, cmd.args, cwd);
50
+ }
51
+ export async function runPackageManagerBinary(cwd, binary, arguments_ = [], options = {}) {
52
+ const pm = await detectPackageManager(cwd);
53
+ const { command, args: commandArguments } = getBinaryRunCommand(pm, binary, arguments_);
54
+ console.log(`➤ Running ${formatCommand(command, commandArguments)} …`);
55
+ const result = runOrThrow(command, commandArguments, cwd, options);
56
+ if (options.stdio === "pipe") {
57
+ return {
58
+ stdout: result.stdout,
59
+ stderr: result.stderr,
60
+ };
61
+ }
62
+ return undefined;
63
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * setup.ts
3
+ *
4
+ * Utilities for setting up project dependencies and build artifacts
5
+ */
6
+ /**
7
+ * Install dependencies and run Next.js typegen if applicable
8
+ */
9
+ export declare function setupProject(destinationDirectory: string): Promise<void>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * setup.ts
3
+ *
4
+ * Utilities for setting up project dependencies and build artifacts
5
+ */
6
+ import path from "node:path";
7
+ import { fileExists } from "../git/git.js";
8
+ import { installDependencies, runPackageManagerBinary, } from "./package-manager.js";
9
+ import { isNextProject, isNextTypegenSupported } from "./next.js";
10
+ /**
11
+ * Install dependencies and run Next.js typegen if applicable
12
+ */
13
+ export async function setupProject(destinationDirectory) {
14
+ const packageJsonPath = path.join(destinationDirectory, "package.json");
15
+ if (!(await fileExists(packageJsonPath))) {
16
+ return;
17
+ }
18
+ await installDependencies(destinationDirectory);
19
+ // Run Next.js type generation if applicable
20
+ if (await isNextProject(destinationDirectory)) {
21
+ console.log("➤ Checking Next.js CLI typegen support …");
22
+ const hasTypegenSupport = await isNextTypegenSupported(destinationDirectory);
23
+ if (hasTypegenSupport) {
24
+ console.log("➤ Running next typegen …");
25
+ await runPackageManagerBinary(destinationDirectory, "next", ["typegen"]);
26
+ }
27
+ else {
28
+ console.warn("! Skipping next typegen: installed Next.js CLI does not support this command.");
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * destination-directory.ts
3
+ *
4
+ * Utilities for managing worktree directories
5
+ */
6
+ /**
7
+ * Check if directory exists and prompt for removal if needed
8
+ * Returns true if the directory should be created
9
+ */
10
+ export declare function handleExistingDirectory(destinationDirectory: string): Promise<void>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * destination-directory.ts
3
+ *
4
+ * Utilities for managing worktree directories
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import path from "node:path";
8
+ import { fileExists, confirm, exitWithMessage } from "../git/git.js";
9
+ /**
10
+ * Check if directory exists and prompt for removal if needed
11
+ * Returns true if the directory should be created
12
+ */
13
+ export async function handleExistingDirectory(destinationDirectory) {
14
+ if (!(await fileExists(destinationDirectory))) {
15
+ return;
16
+ }
17
+ const proceed = await confirm(`Directory '${path.basename(destinationDirectory)}' already exists. Remove and recreate?`);
18
+ if (!proceed) {
19
+ console.log("Operation cancelled.");
20
+ // eslint-disable-next-line unicorn/no-process-exit -- User requested cancellation
21
+ process.exit(0);
22
+ }
23
+ // Remove the existing directory
24
+ console.log(`➤ Removing existing directory...`);
25
+ const result = spawnSync("rm", ["-rf", destinationDirectory], {
26
+ encoding: "utf8",
27
+ });
28
+ if (result.error)
29
+ throw result.error;
30
+ if (result.status !== 0) {
31
+ exitWithMessage(`Failed to remove existing directory: ${result.stderr}`);
32
+ }
33
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * file-patterns.ts
3
+ *
4
+ * Shared utilities for worktree file selection
5
+ */
6
+ export declare const EXTRA_IGNORED_PATTERNS: string[];
7
+ export declare const globToRegExp: (globPattern: string) => RegExp;
8
+ export declare const toPosixPath: (value: string) => string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * file-patterns.ts
3
+ *
4
+ * Shared utilities for worktree file selection
5
+ */
6
+ // Patterns for untracked files that should NOT be copied to new worktrees
7
+ export const EXTRA_IGNORED_PATTERNS = [
8
+ "node_modules/**",
9
+ ".next/**",
10
+ ".nuxt/**",
11
+ ".output/**",
12
+ "dist/**",
13
+ "build/**",
14
+ "**/*.tsbuildinfo",
15
+ ".turbo/**",
16
+ ".cache/**",
17
+ "coverage/**",
18
+ ".venv/**",
19
+ "venv/**",
20
+ "__pycache__/**",
21
+ ".pytest_cache/**",
22
+ "target/**", // Rust
23
+ ".dart_tool/**", // Dart
24
+ ];
25
+ const escapeForRegExp = (value) => value.replaceAll(/[\\^$*+?.()|[\]{}]/gu, String.raw `\$&`);
26
+ export const globToRegExp = (globPattern) => {
27
+ let regexBody = "";
28
+ for (let index = 0; index < globPattern.length; index += 1) {
29
+ const character = globPattern.charAt(index);
30
+ if (character === "*") {
31
+ const nextCharacter = globPattern.charAt(index + 1);
32
+ if (nextCharacter === "*") {
33
+ regexBody += ".*";
34
+ index += 1;
35
+ }
36
+ else {
37
+ regexBody += "[^/]*";
38
+ }
39
+ continue;
40
+ }
41
+ if (character === "?") {
42
+ regexBody += "[^/]";
43
+ continue;
44
+ }
45
+ if (character === "/") {
46
+ regexBody += String.raw `\/`;
47
+ continue;
48
+ }
49
+ regexBody += escapeForRegExp(character);
50
+ }
51
+ return new RegExp(`^${regexBody}$`, "u");
52
+ };
53
+ export const toPosixPath = (value) => value.replaceAll("\\", "/");
@@ -0,0 +1,9 @@
1
+ /**
2
+ * untracked-file-copy.ts
3
+ *
4
+ * Utilities for copying untracked files to worktrees
5
+ */
6
+ /**
7
+ * Copy untracked files from source to destination, excluding ignored patterns
8
+ */
9
+ export declare function copyUntrackedFiles(repoRoot: string, destinationDirectory: string): Promise<void>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * untracked-file-copy.ts
3
+ *
4
+ * Utilities for copying untracked files to worktrees
5
+ */
6
+ import * as fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { git } from "../git/git.js";
9
+ import { EXTRA_IGNORED_PATTERNS, globToRegExp, toPosixPath, } from "./file-patterns.js";
10
+ /**
11
+ * Copy untracked files from source to destination, excluding ignored patterns
12
+ */
13
+ export async function copyUntrackedFiles(repoRoot, destinationDirectory) {
14
+ const extraIgnoredRegexes = EXTRA_IGNORED_PATTERNS.map((pattern) => globToRegExp(pattern));
15
+ const untrackedEntries = git("ls-files", "--others", // untracked files
16
+ "--ignored", // ignored files
17
+ "--exclude-standard", // respect .gitignore rules
18
+ "-z")
19
+ .split("\0")
20
+ .filter(Boolean);
21
+ for (const relativePath of untrackedEntries) {
22
+ const posixPath = toPosixPath(relativePath);
23
+ if (extraIgnoredRegexes.some((regex) => regex.test(posixPath))) {
24
+ continue;
25
+ }
26
+ const sourcePath = path.resolve(repoRoot, relativePath);
27
+ const destinationPath = path.join(destinationDirectory, relativePath);
28
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
29
+ await fs.cp(sourcePath, destinationPath, {
30
+ recursive: true,
31
+ errorOnExist: false,
32
+ });
33
+ console.log(` • copied ${relativePath}`);
34
+ }
35
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "worktree-add",
3
+ "author": "Łukasz Jerciński",
4
+ "license": "MIT",
5
+ "version": "1.0.0",
6
+ "description": "",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Jercik/worktree-add.git"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "worktree-add": "bin/worktree-add"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "dist/",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "start": "pnpm run rebuild && node bin/worktree-add",
23
+ "build": "tsc -p tsconfig.app.json",
24
+ "clean": "rm -rf dist *.tsbuildinfo",
25
+ "rebuild": "pnpm run clean && pnpm run build",
26
+ "prepare": "husky",
27
+ "prepublishOnly": "pnpm run rebuild",
28
+ "typecheck": "tsc -b --noEmit",
29
+ "format": "prettier --write .",
30
+ "format:check": "prettier --check .",
31
+ "lint": "eslint",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "test:coverage": "vitest run --coverage",
35
+ "knip": "knip",
36
+ "fta:check": "fta-check --threshold 55"
37
+ },
38
+ "keywords": [],
39
+ "packageManager": "pnpm@10.22.0",
40
+ "engines": {
41
+ "node": ">=22.0.0"
42
+ },
43
+ "dependencies": {
44
+ "commander": "^14.0.2",
45
+ "package-manager-detector": "^1.5.0"
46
+ },
47
+ "devDependencies": {
48
+ "@commitlint/cli": "^20.1.0",
49
+ "@commitlint/config-conventional": "^20.0.0",
50
+ "@eslint/compat": "^1.3.2",
51
+ "@eslint/js": "^9.31.0",
52
+ "@total-typescript/ts-reset": "^0.6.1",
53
+ "@types/node": "^24.0.15",
54
+ "@vitest/coverage-v8": "^3.2.4",
55
+ "@vitest/eslint-plugin": "^1.3.4",
56
+ "eslint": "^9.31.0",
57
+ "eslint-config-prettier": "^10.1.8",
58
+ "eslint-plugin-unicorn": "^62.0.0",
59
+ "fta-check": "^1.1.0",
60
+ "fta-cli": "^3.0.0",
61
+ "globals": "^16.5.0",
62
+ "husky": "^9.1.7",
63
+ "knip": "^5.66.4",
64
+ "prettier": "3.6.2",
65
+ "semantic-release": "^25.0.2",
66
+ "typescript": "^5.8.3",
67
+ "typescript-eslint": "^8.37.0",
68
+ "vitest": "^3.2.4"
69
+ }
70
+ }