worktree-add 1.2.1 → 1.3.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.
Files changed (49) hide show
  1. package/README.md +58 -22
  2. package/dist/app/get-unsafe-app-name-reason.d.ts +11 -0
  3. package/dist/app/get-unsafe-app-name-reason.js +21 -0
  4. package/dist/app/resolve-apps.d.ts +6 -0
  5. package/dist/app/resolve-apps.js +31 -0
  6. package/dist/cli/cleanup-worktree.d.ts +2 -0
  7. package/dist/cli/cleanup-worktree.js +13 -0
  8. package/dist/cli/open-worktree-apps.d.ts +5 -0
  9. package/dist/cli/open-worktree-apps.js +33 -0
  10. package/dist/cli/register-sigint-handler.d.ts +8 -0
  11. package/dist/cli/register-sigint-handler.js +19 -0
  12. package/dist/cli/resolve-worktree-context.d.ts +6 -0
  13. package/dist/cli/resolve-worktree-context.js +22 -0
  14. package/dist/cli/run-worktree-add.d.ts +9 -0
  15. package/dist/cli/run-worktree-add.js +120 -0
  16. package/dist/cli.d.ts +0 -6
  17. package/dist/cli.js +38 -76
  18. package/dist/git/create-worktree.d.ts +9 -0
  19. package/dist/git/create-worktree.js +39 -0
  20. package/dist/git/extract-diagnostic-line.d.ts +1 -0
  21. package/dist/git/extract-diagnostic-line.js +13 -0
  22. package/dist/git/fetch-remote-branch.d.ts +21 -0
  23. package/dist/git/fetch-remote-branch.js +106 -0
  24. package/dist/git/git.d.ts +11 -5
  25. package/dist/git/git.js +25 -17
  26. package/dist/git/remove-worktree.d.ts +1 -0
  27. package/dist/git/remove-worktree.js +4 -0
  28. package/dist/output/create-status-logger.d.ts +13 -0
  29. package/dist/output/create-status-logger.js +32 -0
  30. package/dist/output/get-status-logger.d.ts +2 -0
  31. package/dist/output/get-status-logger.js +12 -0
  32. package/dist/project/install-commands.js +2 -2
  33. package/dist/project/next.d.ts +4 -1
  34. package/dist/project/next.js +2 -2
  35. package/dist/project/package-manager.d.ts +7 -1
  36. package/dist/project/package-manager.js +12 -3
  37. package/dist/project/setup.d.ts +5 -1
  38. package/dist/project/setup.js +15 -7
  39. package/dist/worktree/destination-directory.d.ts +10 -3
  40. package/dist/worktree/destination-directory.js +94 -16
  41. package/dist/worktree/untracked-file-copy.d.ts +5 -1
  42. package/dist/worktree/untracked-file-copy.js +51 -9
  43. package/package.json +4 -2
  44. package/dist/git/parse-branch-head-mismatch-choice.d.ts +0 -7
  45. package/dist/git/parse-branch-head-mismatch-choice.js +0 -26
  46. package/dist/git/resolve-branch-head-mismatch.d.ts +0 -6
  47. package/dist/git/resolve-branch-head-mismatch.js +0 -79
  48. package/dist/git/worktree-creation.d.ts +0 -13
  49. package/dist/git/worktree-creation.js +0 -35
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # worktree-add
2
2
 
3
- Create a Git worktree next to your current repo for a branch, copy useful local files, install deps, and open it in your editor.
3
+ Create a Git worktree next to your current repo for a branch, copy useful local files, install deps, and open it in your apps.
4
4
 
5
5
  ## What it does
6
6
 
@@ -8,19 +8,22 @@ Running `worktree-add <branch>` from inside a repo:
8
8
 
9
9
  1. Normalizes `<branch>` (supports `origin/foo`, `refs/heads/foo`, etc.).
10
10
  2. Refuses if that branch is already checked out in any worktree.
11
- 3. Warns if local and `origin/<branch>` heads differ and asks how to resolve.
12
- 4. Picks a destination next to your current repo: `../<repo>-<safe-branch>`.
13
- 5. If the destination exists, asks before moving it to the system trash.
14
- 6. Fetches `origin/<branch>` when needed and creates a git worktree:
11
+ 3. Picks a destination next to your current repo: `../<repo>-<safe-branch>`.
12
+ 4. If the destination exists, pass `--interactive` to be prompted for confirmation, or `--yes` to move it to the system trash without prompting. Without either flag, the command exits.
13
+ 5. Fetches `origin/<branch>` and refreshes your local branch when safe:
14
+ - fast-forwards the local branch if it’s strictly behind `origin/<branch>`
15
+ - warns and keeps the local branch as-is if it’s ahead
16
+ - exits with recovery steps if local and remote have diverged
17
+ 6. Creates a git worktree:
15
18
  - reuses an existing local branch
16
19
  - or creates a tracking branch from `origin/<branch>`
17
- - or creates a new branch from the current `HEAD`
20
+ - or creates a new branch from the current `HEAD` (only when the branch does not exist on `origin/`, or when you pass `--offline` and `origin/` can’t be reached)
18
21
  7. Copies untracked / ignored files into the new worktree, skipping heavy stuff
19
22
  (`node_modules`, `dist`, `.next`, caches, virtualenvs, etc.).
20
23
  8. Detects your package manager and installs dependencies with lockfile‑safe flags
21
24
  (`npm ci`, `pnpm install --frozen-lockfile`, `yarn install --immutable`, etc.).
22
25
  9. If the project uses Next.js and supports it, runs `next typegen`.
23
- 10. Opens the new worktree in your editor.
26
+ 10. Opens the new worktree in your requested apps (if any).
24
27
 
25
28
  Your original checkout is left untouched.
26
29
 
@@ -28,6 +31,7 @@ Your original checkout is left untouched.
28
31
 
29
32
  - Node.js ≥ 22.14.0
30
33
  - Git with `git worktree` support
34
+ - Git credentials configured for `origin/` (the tool is non-interactive and won’t prompt for authentication)
31
35
 
32
36
  ## Install / run
33
37
 
@@ -49,7 +53,7 @@ Run it from anywhere inside an existing worktree of the repo.
49
53
 
50
54
  ```bash
51
55
  # inside /my/path/my-app
52
- worktree-add <branch>
56
+ worktree-add <branch> [options]
53
57
  ```
54
58
 
55
59
  Run this inside an existing worktree of your project—the tool discovers the repo root from your current directory and creates the sibling worktree next to it.
@@ -62,8 +66,19 @@ Example:
62
66
  worktree-add feature/login-form
63
67
  ```
64
68
 
69
+ Common options:
70
+
71
+ ```text
72
+ -a, --app <name> Open the worktree in an app (repeatable)
73
+ --offline Create from HEAD when origin can't be reached
74
+ -y, --yes Skip confirmation and replace existing destination
75
+ --interactive Allow confirmation prompts (requires a TTY)
76
+ --dry-run Show what would happen without making changes
77
+ --verbose Show progress messages on stderr
78
+ ```
79
+
65
80
  A new branch from the current HEAD is created only when the branch does not already
66
- exist locally or on `origin/`.
81
+ exist locally or on `origin/`. If `origin/` can’t be reached and the branch doesn’t exist locally, the tool aborts unless you pass `--offline`.
67
82
 
68
83
  Destination directory (assuming repo named `my-app`):
69
84
 
@@ -72,27 +87,44 @@ Destination directory (assuming repo named `my-app`):
72
87
  /my/path/my-app-feature-login-form # new worktree for branch "feature/login-form"
73
88
  ```
74
89
 
75
- ## Editor control
90
+ ## Pipeline examples
76
91
 
77
- By default the tool opens the new worktree in:
92
+ ```bash
93
+ # create a worktree for the newest local branch
94
+ git for-each-ref --sort=-committerdate --format="%(refname:short)" refs/heads \
95
+ | head -n1 \
96
+ | xargs worktree-add
97
+
98
+ # create a worktree for the first feature branch
99
+ git branch --format="%(refname:short)" \
100
+ | grep '^feature/' \
101
+ | head -n1 \
102
+ | xargs worktree-add
103
+ ```
78
104
 
79
- 1. `--editor <command>` if passed
80
- 2. `WORKTREE_ADD_EDITOR` env var
81
- 3. otherwise `code`
105
+ ## App control
82
106
 
83
- Only simple command names are allowed (no `;`, `&`, pipes, etc.) to avoid shell injection. Examples:
107
+ After creating the worktree, the tool can open it in one or more apps. Nothing is opened by default.
108
+ App names are executed on your machine, so only use values you trust. Validation is a UX guardrail (not a security boundary): it only blocks control characters to prevent common mistakes, while other punctuation is allowed because `open` uses platform launchers (macOS `open`, Windows PowerShell) and makes no security guarantees.
109
+ Arguments are not parsed: values like `code -w` are treated as part of the app name and will likely fail.
84
110
 
85
- ```bash
86
- worktree-add feature/foo -e code
87
- WORKTREE_ADD_EDITOR=vim worktree-add feature/foo
88
- ```
111
+ 1. `--app <name>` flag (repeatable): `worktree-add feature/foo --app ghostty --app code`
112
+ 2. `WORKTREE_ADD_APP` env var (comma-separated): `WORKTREE_ADD_APP=ghostty,code worktree-add feature/foo`
113
+
114
+ CLI flags take priority over the env var.
115
+ To explicitly open nothing even when `WORKTREE_ADD_APP` is set, pass `--app ""`.
116
+ Whitespace-only values like `--app " "` are treated as absent and do not override `WORKTREE_ADD_APP`.
117
+
118
+ If launching an app fails to start, the worktree still stays created and ready.
89
119
 
90
- If launching the editor fails, the worktree still stays created and ready.
120
+ Platform note: on macOS the app value is passed to `open -a`, so use an application name like `Visual Studio Code` (or an `.app` path). On Linux/Windows it’s typically treated as an executable name/path.
91
121
 
92
- Tip: add a shell helper with your preferred editor in your shell profile. Example snippet to add:
122
+ Terminal-based editors (vim/nvim/nano) are not supported via `--app` — apps are launched detached from the current terminal.
123
+
124
+ Tip: add a shell helper with your preferred apps in your shell profile:
93
125
 
94
126
  ```bash
95
- worktree-add() { WORKTREE_ADD_EDITOR=cursor command worktree-add "$@" }
127
+ worktree-add() { WORKTREE_ADD_APP=ghostty,code command worktree-add "$@" }
96
128
  ```
97
129
 
98
130
  Add it to your shell profile:
@@ -101,6 +133,10 @@ Add it to your shell profile:
101
133
  - bash: `~/.bashrc` or `~/.bash_profile`
102
134
  - fish: `~/.config/fish/config.fish`
103
135
 
136
+ ## Agent rule
137
+
138
+ If you are an automated agent, read and follow `AGENTS.md` (and `CLAUDE.md` if present) at the repo root before making changes.
139
+
104
140
  ## License
105
141
 
106
142
  MIT
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Defense-in-depth filter for app names passed to the `open` package.
3
+ *
4
+ * This is not intended as shell-injection mitigation. In `open@11` (currently
5
+ * used by this repo), the implementation uses `child_process.spawn()` without
6
+ * `shell: true`, but the exact subprocess differs by platform (macOS: `open -a`,
7
+ * Linux: usually `xdg-open` unless an explicit app is provided, Windows:
8
+ * PowerShell). This comment is informational, not a guarantee — treat this
9
+ * filter as a UX/diagnostics guardrail rather than a security boundary.
10
+ */
11
+ export declare function getUnsafeAppNameReason(appName: string): string | undefined;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Defense-in-depth filter for app names passed to the `open` package.
3
+ *
4
+ * This is not intended as shell-injection mitigation. In `open@11` (currently
5
+ * used by this repo), the implementation uses `child_process.spawn()` without
6
+ * `shell: true`, but the exact subprocess differs by platform (macOS: `open -a`,
7
+ * Linux: usually `xdg-open` unless an explicit app is provided, Windows:
8
+ * PowerShell). This comment is informational, not a guarantee — treat this
9
+ * filter as a UX/diagnostics guardrail rather than a security boundary.
10
+ */
11
+ export function getUnsafeAppNameReason(appName) {
12
+ for (const character of appName) {
13
+ const codePoint = character.codePointAt(0);
14
+ if (codePoint === undefined)
15
+ continue;
16
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
17
+ return "contains control characters";
18
+ }
19
+ }
20
+ return undefined;
21
+ }
@@ -0,0 +1,6 @@
1
+ interface ResolveAppsInput {
2
+ optionApps?: string[];
3
+ environmentApps?: string;
4
+ }
5
+ export declare function resolveApps({ optionApps, environmentApps, }: ResolveAppsInput): string[];
6
+ export {};
@@ -0,0 +1,31 @@
1
+ function dedupePreserveOrder(apps) {
2
+ const seen = new Set();
3
+ const deduped = [];
4
+ for (const app of apps) {
5
+ if (seen.has(app))
6
+ continue;
7
+ seen.add(app);
8
+ deduped.push(app);
9
+ }
10
+ return deduped;
11
+ }
12
+ export function resolveApps({ optionApps, environmentApps, }) {
13
+ const hasExplicitEmptyOption = optionApps?.includes("") ?? false;
14
+ const normalizedOptionApps = optionApps?.map((app) => app.trim()).filter(Boolean) ?? [];
15
+ if (optionApps !== undefined) {
16
+ if (normalizedOptionApps.length > 0) {
17
+ return dedupePreserveOrder(normalizedOptionApps);
18
+ }
19
+ if (hasExplicitEmptyOption) {
20
+ return [];
21
+ }
22
+ }
23
+ const normalized = environmentApps?.trim();
24
+ if (normalized) {
25
+ return dedupePreserveOrder(normalized
26
+ .split(",")
27
+ .map((app) => app.trim())
28
+ .filter(Boolean));
29
+ }
30
+ return [];
31
+ }
@@ -0,0 +1,2 @@
1
+ import type { StatusLogger } from "../output/create-status-logger.js";
2
+ export declare function cleanupWorktree(destinationDirectory: string, logger: StatusLogger, reason: string): void;
@@ -0,0 +1,13 @@
1
+ import { removeWorktree } from "../git/remove-worktree.js";
2
+ export function cleanupWorktree(destinationDirectory, logger, reason) {
3
+ logger.warn(`Cleaning up worktree at ${JSON.stringify(destinationDirectory)} ${reason}.`);
4
+ try {
5
+ removeWorktree(destinationDirectory);
6
+ }
7
+ catch (cleanupError) {
8
+ const cleanupMessage = cleanupError instanceof Error
9
+ ? cleanupError.message
10
+ : String(cleanupError);
11
+ logger.warn(`Failed to clean up worktree at ${JSON.stringify(destinationDirectory)}: ${cleanupMessage}`);
12
+ }
13
+ }
@@ -0,0 +1,5 @@
1
+ import type { StatusLogger } from "../output/create-status-logger.js";
2
+ export declare function openWorktreeApps(destinationDirectory: string, apps: string[], options?: {
3
+ dryRun?: boolean;
4
+ logger?: StatusLogger;
5
+ }): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import open from "open";
2
+ import { getStatusLogger } from "../output/get-status-logger.js";
3
+ import { getUnsafeAppNameReason } from "../app/get-unsafe-app-name-reason.js";
4
+ export async function openWorktreeApps(destinationDirectory, apps, options = {}) {
5
+ const logger = getStatusLogger(options.logger);
6
+ const dryRun = options.dryRun ?? false;
7
+ await Promise.allSettled(apps.map(async (app) => {
8
+ const unsafeReason = getUnsafeAppNameReason(app);
9
+ if (unsafeReason) {
10
+ logger.warn(`Skipping app ${JSON.stringify(app)}: ${unsafeReason}.`);
11
+ return;
12
+ }
13
+ if (dryRun) {
14
+ logger.step(`Would open ${JSON.stringify(destinationDirectory)} in ${JSON.stringify(app)}`);
15
+ return;
16
+ }
17
+ logger.step(`Opening ${JSON.stringify(app)} …`);
18
+ try {
19
+ await open(destinationDirectory, {
20
+ app: { name: [app] },
21
+ wait: false,
22
+ });
23
+ }
24
+ catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ const looksLikeArguments = /\s--?\S/u.test(app);
27
+ const argumentHint = looksLikeArguments
28
+ ? ' Note: application arguments are not supported; pass only the application name (for example, "code" instead of "code --wait").'
29
+ : "";
30
+ logger.warn(`Failed to open ${JSON.stringify(app)}: ${message}.${argumentHint}`);
31
+ }
32
+ }));
33
+ }
@@ -0,0 +1,8 @@
1
+ import type { StatusLogger } from "../output/create-status-logger.js";
2
+ type SigintHandlerOptions = {
3
+ readonly destinationDirectory: string;
4
+ readonly logger: StatusLogger;
5
+ readonly onCleanup: () => void;
6
+ };
7
+ export declare function registerSigintHandler(options: SigintHandlerOptions): () => void;
8
+ export {};
@@ -0,0 +1,19 @@
1
+ export function registerSigintHandler(options) {
2
+ let handled = false;
3
+ const handler = () => {
4
+ if (handled) {
5
+ // eslint-disable-next-line unicorn/no-process-exit -- CLI exits on SIGINT
6
+ process.exit(130);
7
+ }
8
+ handled = true;
9
+ options.logger.warn("Received SIGINT. Aborting.");
10
+ options.onCleanup();
11
+ console.error(`Worktree creation aborted. If cleanup failed, the directory may be incomplete at ${JSON.stringify(options.destinationDirectory)}.`);
12
+ // eslint-disable-next-line unicorn/no-process-exit -- CLI exits on SIGINT
13
+ process.exit(130);
14
+ };
15
+ process.on("SIGINT", handler);
16
+ return () => {
17
+ process.off("SIGINT", handler);
18
+ };
19
+ }
@@ -0,0 +1,6 @@
1
+ export type WorktreeContext = {
2
+ readonly branch: string;
3
+ readonly repoRoot: string;
4
+ readonly destinationDirectory: string;
5
+ };
6
+ export declare function resolveWorktreeContext(branchRaw: string): WorktreeContext;
@@ -0,0 +1,22 @@
1
+ import path from "node:path";
2
+ import { exitWithMessage, findWorktreeByBranchName, getRepositoryName, git, normalizeBranchName, toSafePathSegment, } from "../git/git.js";
3
+ export function resolveWorktreeContext(branchRaw) {
4
+ const branch = normalizeBranchName(branchRaw);
5
+ if (branch.length === 0) {
6
+ exitWithMessage("Branch name is empty.\n" +
7
+ "Examples: `feature/foo`, `origin/feature/foo`, `refs/heads/feature/foo`.\n" +
8
+ "Note: `origin/` without a branch name is not valid.");
9
+ }
10
+ const existingWorktree = findWorktreeByBranchName(branch);
11
+ if (existingWorktree) {
12
+ exitWithMessage(`Branch '${branch}' is already checked out in: ${existingWorktree}\n` +
13
+ "You cannot add another worktree for the same branch.\n" +
14
+ "Open that worktree instead or remove it before retrying.\n" +
15
+ "If that path no longer exists, run `git worktree prune` and retry.");
16
+ }
17
+ const repoRoot = git("rev-parse", "--show-toplevel");
18
+ const repoName = getRepositoryName();
19
+ const branchDirectorySegment = toSafePathSegment(branch);
20
+ const destinationDirectory = path.join(path.dirname(repoRoot), `${repoName}-${branchDirectorySegment}`);
21
+ return { branch, repoRoot, destinationDirectory };
22
+ }
@@ -0,0 +1,9 @@
1
+ export type CliOptions = {
2
+ readonly app?: string[];
3
+ readonly offline?: boolean;
4
+ readonly yes?: boolean;
5
+ readonly interactive?: boolean;
6
+ readonly dryRun?: boolean;
7
+ readonly verbose?: boolean;
8
+ };
9
+ export declare function runWorktreeAdd(branchRaw: string, options: CliOptions): Promise<void>;
@@ -0,0 +1,120 @@
1
+ import { createStatusLogger } from "../output/create-status-logger.js";
2
+ import { resolveApps } from "../app/resolve-apps.js";
3
+ import { handleExistingDirectory } from "../worktree/destination-directory.js";
4
+ import { copyUntrackedFiles } from "../worktree/untracked-file-copy.js";
5
+ import { setupProject } from "../project/setup.js";
6
+ import { exitWithMessage } from "../git/git.js";
7
+ import { createWorktree } from "../git/create-worktree.js";
8
+ import { fetchRemoteBranch } from "../git/fetch-remote-branch.js";
9
+ import { cleanupWorktree } from "./cleanup-worktree.js";
10
+ import { openWorktreeApps } from "./open-worktree-apps.js";
11
+ import { registerSigintHandler } from "./register-sigint-handler.js";
12
+ import { resolveWorktreeContext } from "./resolve-worktree-context.js";
13
+ const quoteForShell = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
14
+ export async function runWorktreeAdd(branchRaw, options) {
15
+ const dryRun = options.dryRun ?? false;
16
+ const verbose = options.verbose ?? false;
17
+ const logger = createStatusLogger({
18
+ dryRun,
19
+ verbose,
20
+ decorate: process.stderr.isTTY && !Object.hasOwn(process.env, "NO_COLOR"),
21
+ });
22
+ const interactive = options.interactive ?? false;
23
+ const assumeYes = options.yes ?? false;
24
+ const context = resolveWorktreeContext(branchRaw);
25
+ let worktreeCreated = false;
26
+ const cleanupIfNeeded = (reason) => {
27
+ if (!worktreeCreated || dryRun)
28
+ return;
29
+ cleanupWorktree(context.destinationDirectory, logger, reason);
30
+ };
31
+ const unregisterSigintHandler = registerSigintHandler({
32
+ destinationDirectory: context.destinationDirectory,
33
+ logger,
34
+ onCleanup: () => {
35
+ cleanupIfNeeded("after interruption");
36
+ },
37
+ });
38
+ try {
39
+ const shouldContinue = await handleExistingDirectory(context.destinationDirectory, {
40
+ dryRun,
41
+ assumeYes,
42
+ interactive,
43
+ logger,
44
+ });
45
+ if (!shouldContinue) {
46
+ return;
47
+ }
48
+ const remoteStatus = (() => {
49
+ try {
50
+ return fetchRemoteBranch(context.branch, { dryRun, logger });
51
+ }
52
+ catch (error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ const prefix = `Failed to fetch origin/${context.branch}: `;
55
+ const failure = message.startsWith(prefix)
56
+ ? message
57
+ : `${prefix}${message}`;
58
+ exitWithMessage(`${failure}\n` +
59
+ "If you expected this to work, check your network/credentials and retry.\n" +
60
+ "If you want a new local branch from HEAD instead, pass --offline.");
61
+ }
62
+ })();
63
+ if (remoteStatus.status === "unknown" &&
64
+ !remoteStatus.localExists &&
65
+ !options.offline) {
66
+ exitWithMessage(`Could not reach origin to check whether '${context.branch}' exists, and the branch does not exist locally.\n` +
67
+ "Refusing to create a new branch from HEAD in this ambiguous state.\n" +
68
+ `Re-run with --offline to force creating a new local '${context.branch}' from the current HEAD.`);
69
+ }
70
+ if (remoteStatus.status === "diverged") {
71
+ const { ahead, behind } = remoteStatus.divergence;
72
+ const branchForShell = quoteForShell(context.branch);
73
+ const archivedBranchForShell = quoteForShell(`${context.branch}-old`);
74
+ exitWithMessage(`Local branch '${context.branch}' and origin/${context.branch} have diverged (ahead by ${ahead} and behind by ${behind}).\n` +
75
+ "This can mean either a stale local branch-name collision or legitimate local commits plus new remote commits.\n" +
76
+ "Refusing to reuse the local branch automatically.\n" +
77
+ "Note: commands below use POSIX shell quoting. On Windows cmd.exe/PowerShell, adapt quoting for your shell.\n" +
78
+ "If you want to keep local commits, use your local branch directly:\n" +
79
+ ` git worktree add -- <path> ${branchForShell}\n` +
80
+ ` # or merge/rebase '${context.branch}' with origin/${context.branch}, then retry.\n` +
81
+ "To work on the remote branch, run:\n" +
82
+ " git fetch origin --prune\n" +
83
+ " # if '<branch>-old' already exists, pick a different archive name.\n" +
84
+ ` git branch -m -- ${branchForShell} ${archivedBranchForShell}\n` +
85
+ " # or delete the stale local branch instead:\n" +
86
+ ` # git branch -D -- ${branchForShell}\n` +
87
+ ` worktree-add -- ${branchForShell}`);
88
+ }
89
+ // Only treat origin as existing when confirmed; "unknown" remains false.
90
+ const remoteBranchExists = remoteStatus.status === "exists";
91
+ createWorktree(context.branch, context.destinationDirectory, {
92
+ remoteBranchExists,
93
+ dryRun,
94
+ logger,
95
+ });
96
+ if (!dryRun) {
97
+ worktreeCreated = true;
98
+ }
99
+ await copyUntrackedFiles(context.repoRoot, context.destinationDirectory, {
100
+ dryRun,
101
+ logger,
102
+ });
103
+ await setupProject(context.destinationDirectory, { dryRun, logger });
104
+ const apps = resolveApps({
105
+ optionApps: options.app,
106
+ environmentApps: process.env.WORKTREE_ADD_APP,
107
+ });
108
+ await openWorktreeApps(context.destinationDirectory, apps, {
109
+ dryRun,
110
+ logger,
111
+ });
112
+ }
113
+ catch (error) {
114
+ cleanupIfNeeded("due to failure");
115
+ throw error;
116
+ }
117
+ finally {
118
+ unregisterSigintHandler();
119
+ }
120
+ }
package/dist/cli.d.ts CHANGED
@@ -6,10 +6,4 @@
6
6
  * Note: the destination is placed next to whichever worktree you run this from,
7
7
  * not necessarily the original "main" checkout.
8
8
  */
9
- interface ResolveEditorInput {
10
- optionEditor?: string;
11
- environmentEditor?: string;
12
- }
13
- export declare function resolveEditor({ optionEditor, environmentEditor, }?: ResolveEditorInput): string;
14
- export declare function isEditorCommandSafe(editor: string): boolean;
15
9
  export {};
package/dist/cli.js CHANGED
@@ -6,95 +6,57 @@
6
6
  * Note: the destination is placed next to whichever worktree you run this from,
7
7
  * not necessarily the original "main" checkout.
8
8
  */
9
- import { spawnSync } from "node:child_process";
10
- import path from "node:path";
11
- import { Command } from "commander";
9
+ import { Command } from "@commander-js/extra-typings";
12
10
  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 { resolveBranchHeadMismatch } from "./git/resolve-branch-head-mismatch.js";
17
- import { handleExistingDirectory } from "./worktree/destination-directory.js";
18
- import { setupProject } from "./project/setup.js";
19
- export function resolveEditor({ optionEditor, environmentEditor, } = {}) {
20
- const normalizedOption = optionEditor?.trim();
21
- const normalizedEnvironment = environmentEditor?.trim();
22
- return normalizedOption || normalizedEnvironment || "code";
23
- }
24
- export function isEditorCommandSafe(editor) {
25
- const normalized = editor.normalize("NFKC");
26
- const trimmed = normalized.trim();
27
- // Reject empty or excessively long commands
28
- if (trimmed.length === 0 || trimmed.length > 256) {
29
- return false;
30
- }
31
- // Reject common shell metacharacters, whitespace, and control characters to prevent injection
32
- const unsafeCharacters = /[\\;&|`$(){}<>\s]|\p{Cc}/u;
33
- return !unsafeCharacters.test(trimmed);
34
- }
35
- async function main(branchRaw, options) {
36
- const branch = normalizeBranchName(branchRaw);
37
- // Prevent attempting to add a worktree for a branch that is already checked out
38
- const existingWorktree = findWorktreeByBranchName(branch);
39
- if (existingWorktree) {
40
- exitWithMessage(`Branch '${branch}' is already checked out in: ${existingWorktree}\n` +
41
- "You cannot add another worktree for the same branch.\n" +
42
- "Open that worktree instead or remove it before retrying.");
43
- }
44
- const shouldContinue = await resolveBranchHeadMismatch(branch);
45
- if (!shouldContinue) {
46
- process.exitCode = 1;
47
- return;
48
- }
49
- // Determine destination directory for the new worktree
50
- const repoRoot = git("rev-parse", "--show-toplevel");
51
- const repoName = getRepositoryName();
52
- const branchDirectorySegment = toSafePathSegment(branch);
53
- const destinationDirectory = path.join(path.dirname(repoRoot), `${repoName}-${branchDirectorySegment}`);
54
- // Check if destination directory already exists
55
- await handleExistingDirectory(destinationDirectory);
56
- // Step 1: Fetch remote branch if it exists
57
- fetchRemoteBranch(branch);
58
- // Step 2: Create the worktree
59
- createWorktree(branch, destinationDirectory);
60
- // Step 3: Copy untracked files, excluding any on the denylist
61
- await copyUntrackedFiles(repoRoot, destinationDirectory);
62
- // Step 4: Install dependencies and run project-specific setup
63
- await setupProject(destinationDirectory);
64
- // Step 5: Open the new worktree in the editor
65
- const editor = resolveEditor({
66
- optionEditor: options.editor,
67
- environmentEditor: process.env.WORKTREE_ADD_EDITOR,
68
- });
69
- // Validate editor command to prevent injection attacks
70
- if (!isEditorCommandSafe(editor)) {
71
- exitWithMessage("Invalid editor command: shell metacharacters not allowed.\n" +
72
- "Please use a simple editor name (e.g., 'code', 'cursor', 'vim').");
73
- }
74
- console.log(`➤ Opening ${editor} …`);
75
- const result = spawnSync(editor, [destinationDirectory], {
76
- stdio: "inherit",
77
- });
78
- if (result.error) {
79
- console.error(`Failed to open ${editor}: ${result.error.message}`);
80
- console.error("The worktree was created successfully, but the editor could not be opened.");
81
- }
11
+ import { runWorktreeAdd } from "./cli/run-worktree-add.js";
12
+ function collectApp(app, previous) {
13
+ return [...(previous ?? []), app];
82
14
  }
83
15
  const program = new Command()
84
16
  .name(packageJson.name)
85
17
  .description("Create or reuse a Git worktree for a branch as a sibling of the current worktree")
86
18
  .version(packageJson.version)
87
19
  .argument("<branch>", "branch name for the worktree")
88
- .option("-e, --editor <command>", "Editor to open the worktree with (default: WORKTREE_ADD_EDITOR env var or 'code')")
20
+ .option("-a, --app <name>", "Open the worktree in an app (repeatable)", collectApp)
21
+ .option("--offline", "Allow creating a new local branch from HEAD when origin cannot be reached and the branch does not exist locally")
22
+ .option("-y, --yes", "Skip confirmation and replace existing destination")
23
+ .option("--dry-run", "Show what would happen without making changes")
24
+ .option("--interactive", "Allow confirmation prompts (requires a TTY)")
25
+ .option("--verbose", "Show progress messages on stderr")
26
+ .showHelpAfterError("(add --help for additional information)")
27
+ .showSuggestionAfterError()
28
+ .addHelpText("after", `
29
+ Examples:
30
+ $ worktree-add feature/login-form
31
+ $ worktree-add feature/api --app code
32
+ $ WORKTREE_ADD_APP=ghostty,code worktree-add feature/new-branch
33
+ $ git branch --format="%(refname:short)" | head -n1 | xargs worktree-add
34
+
35
+ App notes:
36
+ - App names are executed on your machine; only use values you trust.
37
+ - Arguments are not parsed; pass only the app name (e.g., "code", not "code --wait").
38
+ - WORKTREE_ADD_APP is a comma-separated list of apps (alternative to repeating --app).
39
+ - To explicitly open nothing when WORKTREE_ADD_APP is set, pass --app "".
40
+
41
+ Dependencies:
42
+ - git (with worktree support)
43
+ - a package manager when package.json is present (npm, pnpm, yarn, bun, deno)
44
+ `)
89
45
  .action(async (branch, options) => {
90
46
  try {
91
- await main(branch, options);
47
+ await runWorktreeAdd(branch, options);
92
48
  }
93
49
  catch (error) {
94
- console.error(error);
50
+ if (options.verbose) {
51
+ console.error(error);
52
+ }
53
+ else {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ console.error(message);
56
+ }
95
57
  process.exitCode = 1;
96
58
  }
97
59
  });
98
60
  if (!process.env.VITEST) {
99
- program.parse();
61
+ await program.parseAsync();
100
62
  }
@@ -0,0 +1,9 @@
1
+ import type { StatusLogger } from "../output/create-status-logger.js";
2
+ /**
3
+ * Create a worktree for the given branch at the specified destination.
4
+ */
5
+ export declare function createWorktree(branch: string, destinationDirectory: string, options?: {
6
+ remoteBranchExists?: boolean;
7
+ dryRun?: boolean;
8
+ logger?: StatusLogger;
9
+ }): void;