worktree-add 1.2.0 → 1.3.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.
Files changed (45) hide show
  1. package/README.md +59 -23
  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 +100 -0
  16. package/dist/cli.d.ts +0 -6
  17. package/dist/cli.js +38 -70
  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 +9 -0
  23. package/dist/git/fetch-remote-branch.js +102 -0
  24. package/dist/git/git.d.ts +22 -0
  25. package/dist/git/git.js +63 -9
  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 +24 -31
  44. package/dist/git/worktree-creation.d.ts +0 -13
  45. package/dist/git/worktree-creation.js +0 -38
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
 
@@ -9,17 +9,20 @@ Running `worktree-add <branch>` from inside a repo:
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
11
  3. Picks a destination next to your current repo: `../<repo>-<safe-branch>`.
12
- 4. If the destination exists, asks before moving it to the system trash.
13
- 5. Fetches `origin/<branch>` when needed and creates a git worktree:
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/diverged
16
+ 6. Creates a git worktree:
14
17
  - reuses an existing local branch
15
18
  - or creates a tracking branch from `origin/<branch>`
16
- - or creates a new branch from the current `HEAD`
17
- 6. Copies untracked / ignored files into the new worktree, skipping heavy stuff
19
+ - 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)
20
+ 7. Copies untracked / ignored files into the new worktree, skipping heavy stuff
18
21
  (`node_modules`, `dist`, `.next`, caches, virtualenvs, etc.).
19
- 7. Detects your package manager and installs dependencies with lockfile‑safe flags
22
+ 8. Detects your package manager and installs dependencies with lockfile‑safe flags
20
23
  (`npm ci`, `pnpm install --frozen-lockfile`, `yarn install --immutable`, etc.).
21
- 8. If the project uses Next.js and supports it, runs `next typegen`.
22
- 9. Opens the new worktree in your editor.
24
+ 9. If the project uses Next.js and supports it, runs `next typegen`.
25
+ 10. Opens the new worktree in your requested apps (if any).
23
26
 
24
27
  Your original checkout is left untouched.
25
28
 
@@ -27,6 +30,7 @@ Your original checkout is left untouched.
27
30
 
28
31
  - Node.js ≥ 22.14.0
29
32
  - Git with `git worktree` support
33
+ - Git credentials configured for `origin/` (the tool is non-interactive and won’t prompt for authentication)
30
34
 
31
35
  ## Install / run
32
36
 
@@ -48,7 +52,7 @@ Run it from anywhere inside an existing worktree of the repo.
48
52
 
49
53
  ```bash
50
54
  # inside /my/path/my-app
51
- worktree-add <branch>
55
+ worktree-add <branch> [options]
52
56
  ```
53
57
 
54
58
  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.
@@ -61,8 +65,19 @@ Example:
61
65
  worktree-add feature/login-form
62
66
  ```
63
67
 
68
+ Common options:
69
+
70
+ ```text
71
+ -a, --app <name> Open the worktree in an app (repeatable)
72
+ --offline Create from HEAD when origin can't be reached
73
+ -y, --yes Skip confirmation and replace existing destination
74
+ --interactive Allow confirmation prompts (requires a TTY)
75
+ --dry-run Show what would happen without making changes
76
+ --verbose Show progress messages on stderr
77
+ ```
78
+
64
79
  A new branch from the current HEAD is created only when the branch does not already
65
- exist locally or on `origin/`.
80
+ 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`.
66
81
 
67
82
  Destination directory (assuming repo named `my-app`):
68
83
 
@@ -71,27 +86,44 @@ Destination directory (assuming repo named `my-app`):
71
86
  /my/path/my-app-feature-login-form # new worktree for branch "feature/login-form"
72
87
  ```
73
88
 
74
- ## Editor control
89
+ ## Pipeline examples
75
90
 
76
- By default the tool opens the new worktree in:
91
+ ```bash
92
+ # create a worktree for the newest local branch
93
+ git for-each-ref --sort=-committerdate --format="%(refname:short)" refs/heads \
94
+ | head -n1 \
95
+ | xargs worktree-add
96
+
97
+ # create a worktree for the first feature branch
98
+ git branch --format="%(refname:short)" \
99
+ | grep '^feature/' \
100
+ | head -n1 \
101
+ | xargs worktree-add
102
+ ```
77
103
 
78
- 1. `--editor <command>` if passed
79
- 2. `WORKTREE_ADD_EDITOR` env var
80
- 3. otherwise `code`
104
+ ## App control
81
105
 
82
- Only simple command names are allowed (no `;`, `&`, pipes, etc.) to avoid shell injection. Examples:
106
+ After creating the worktree, the tool can open it in one or more apps. Nothing is opened by default.
107
+ 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.
108
+ Arguments are not parsed: values like `code -w` are treated as part of the app name and will likely fail.
83
109
 
84
- ```bash
85
- worktree-add feature/foo -e code
86
- WORKTREE_ADD_EDITOR=vim worktree-add feature/foo
87
- ```
110
+ 1. `--app <name>` flag (repeatable): `worktree-add feature/foo --app ghostty --app code`
111
+ 2. `WORKTREE_ADD_APP` env var (comma-separated): `WORKTREE_ADD_APP=ghostty,code worktree-add feature/foo`
112
+
113
+ CLI flags take priority over the env var.
114
+ To explicitly open nothing even when `WORKTREE_ADD_APP` is set, pass `--app ""`.
115
+ Whitespace-only values like `--app " "` are treated as absent and do not override `WORKTREE_ADD_APP`.
116
+
117
+ If launching an app fails to start, the worktree still stays created and ready.
88
118
 
89
- If launching the editor fails, the worktree still stays created and ready.
119
+ 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.
90
120
 
91
- Tip: add a shell helper with your preferred editor in your shell profile. Example snippet to add:
121
+ Terminal-based editors (vim/nvim/nano) are not supported via `--app` — apps are launched detached from the current terminal.
122
+
123
+ Tip: add a shell helper with your preferred apps in your shell profile:
92
124
 
93
125
  ```bash
94
- worktree-add() { WORKTREE_ADD_EDITOR=cursor command worktree-add "$@" }
126
+ worktree-add() { WORKTREE_ADD_APP=ghostty,code command worktree-add "$@" }
95
127
  ```
96
128
 
97
129
  Add it to your shell profile:
@@ -100,6 +132,10 @@ Add it to your shell profile:
100
132
  - bash: `~/.bashrc` or `~/.bash_profile`
101
133
  - fish: `~/.config/fish/config.fish`
102
134
 
135
+ ## Agent rule
136
+
137
+ If you are an automated agent, read and follow `AGENTS.md` (and `CLAUDE.md` if present) at the repo root before making changes.
138
+
103
139
  ## License
104
140
 
105
141
  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,100 @@
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
+ export async function runWorktreeAdd(branchRaw, options) {
14
+ const dryRun = options.dryRun ?? false;
15
+ const verbose = options.verbose ?? false;
16
+ const logger = createStatusLogger({
17
+ dryRun,
18
+ verbose,
19
+ decorate: process.stderr.isTTY && !Object.hasOwn(process.env, "NO_COLOR"),
20
+ });
21
+ const interactive = options.interactive ?? false;
22
+ const assumeYes = options.yes ?? false;
23
+ const context = resolveWorktreeContext(branchRaw);
24
+ let worktreeCreated = false;
25
+ const cleanupIfNeeded = (reason) => {
26
+ if (!worktreeCreated || dryRun)
27
+ return;
28
+ cleanupWorktree(context.destinationDirectory, logger, reason);
29
+ };
30
+ const unregisterSigintHandler = registerSigintHandler({
31
+ destinationDirectory: context.destinationDirectory,
32
+ logger,
33
+ onCleanup: () => {
34
+ cleanupIfNeeded("after interruption");
35
+ },
36
+ });
37
+ try {
38
+ const shouldContinue = await handleExistingDirectory(context.destinationDirectory, {
39
+ dryRun,
40
+ assumeYes,
41
+ interactive,
42
+ logger,
43
+ });
44
+ if (!shouldContinue) {
45
+ return;
46
+ }
47
+ const remoteStatus = (() => {
48
+ try {
49
+ return fetchRemoteBranch(context.branch, { dryRun, logger });
50
+ }
51
+ catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ const prefix = `Failed to fetch origin/${context.branch}: `;
54
+ const failure = message.startsWith(prefix)
55
+ ? message
56
+ : `${prefix}${message}`;
57
+ exitWithMessage(`${failure}\n` +
58
+ "If you expected this to work, check your network/credentials and retry.\n" +
59
+ "If you want a new local branch from HEAD instead, pass --offline.");
60
+ }
61
+ })();
62
+ if (remoteStatus.status === "unknown" &&
63
+ !remoteStatus.localExists &&
64
+ !options.offline) {
65
+ exitWithMessage(`Could not reach origin to check whether '${context.branch}' exists, and the branch does not exist locally.\n` +
66
+ "Refusing to create a new branch from HEAD in this ambiguous state.\n" +
67
+ `Re-run with --offline to force creating a new local '${context.branch}' from the current HEAD.`);
68
+ }
69
+ // Only treat origin as existing when confirmed; "unknown" remains false.
70
+ const remoteBranchExists = remoteStatus.status === "exists";
71
+ createWorktree(context.branch, context.destinationDirectory, {
72
+ remoteBranchExists,
73
+ dryRun,
74
+ logger,
75
+ });
76
+ if (!dryRun) {
77
+ worktreeCreated = true;
78
+ }
79
+ await copyUntrackedFiles(context.repoRoot, context.destinationDirectory, {
80
+ dryRun,
81
+ logger,
82
+ });
83
+ await setupProject(context.destinationDirectory, { dryRun, logger });
84
+ const apps = resolveApps({
85
+ optionApps: options.app,
86
+ environmentApps: process.env.WORKTREE_ADD_APP,
87
+ });
88
+ await openWorktreeApps(context.destinationDirectory, apps, {
89
+ dryRun,
90
+ logger,
91
+ });
92
+ }
93
+ catch (error) {
94
+ cleanupIfNeeded("due to failure");
95
+ throw error;
96
+ }
97
+ finally {
98
+ unregisterSigintHandler();
99
+ }
100
+ }
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,89 +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 { handleExistingDirectory } from "./worktree/destination-directory.js";
17
- import { setupProject } from "./project/setup.js";
18
- export function resolveEditor({ optionEditor, environmentEditor, } = {}) {
19
- const normalizedOption = optionEditor?.trim();
20
- const normalizedEnvironment = environmentEditor?.trim();
21
- return normalizedOption || normalizedEnvironment || "code";
22
- }
23
- export function isEditorCommandSafe(editor) {
24
- const normalized = editor.normalize("NFKC");
25
- const trimmed = normalized.trim();
26
- // Reject empty or excessively long commands
27
- if (trimmed.length === 0 || trimmed.length > 256) {
28
- return false;
29
- }
30
- // Reject common shell metacharacters, whitespace, and control characters to prevent injection
31
- const unsafeCharacters = /[\\;&|`$(){}<>\s]|\p{Cc}/u;
32
- return !unsafeCharacters.test(trimmed);
33
- }
34
- async function main(branchRaw, options) {
35
- const branch = normalizeBranchName(branchRaw);
36
- // Prevent attempting to add a worktree for a branch that is already checked out
37
- const existingWorktree = findWorktreeByBranchName(branch);
38
- if (existingWorktree) {
39
- exitWithMessage(`Branch '${branch}' is already checked out in: ${existingWorktree}\n` +
40
- "You cannot add another worktree for the same branch.\n" +
41
- "Open that worktree instead or remove it before retrying.");
42
- }
43
- // Determine destination directory for the new worktree
44
- const repoRoot = git("rev-parse", "--show-toplevel");
45
- const repoName = getRepositoryName();
46
- const branchDirectorySegment = toSafePathSegment(branch);
47
- const destinationDirectory = path.join(path.dirname(repoRoot), `${repoName}-${branchDirectorySegment}`);
48
- // Check if destination directory already exists
49
- await handleExistingDirectory(destinationDirectory);
50
- // Step 1: Fetch remote branch if it exists
51
- fetchRemoteBranch(branch);
52
- // Step 2: Create the worktree
53
- createWorktree(branch, destinationDirectory);
54
- // Step 3: Copy untracked files, excluding any on the denylist
55
- await copyUntrackedFiles(repoRoot, destinationDirectory);
56
- // Step 4: Install dependencies and run project-specific setup
57
- await setupProject(destinationDirectory);
58
- // Step 5: Open the new worktree in the editor
59
- const editor = resolveEditor({
60
- optionEditor: options.editor,
61
- environmentEditor: process.env.WORKTREE_ADD_EDITOR,
62
- });
63
- // Validate editor command to prevent injection attacks
64
- if (!isEditorCommandSafe(editor)) {
65
- exitWithMessage("Invalid editor command: shell metacharacters not allowed.\n" +
66
- "Please use a simple editor name (e.g., 'code', 'cursor', 'vim').");
67
- }
68
- console.log(`➤ Opening ${editor} …`);
69
- const result = spawnSync(editor, [destinationDirectory], {
70
- stdio: "inherit",
71
- });
72
- if (result.error) {
73
- console.error(`Failed to open ${editor}: ${result.error.message}`);
74
- console.error("The worktree was created successfully, but the editor could not be opened.");
75
- }
11
+ import { runWorktreeAdd } from "./cli/run-worktree-add.js";
12
+ function collectApp(app, previous) {
13
+ return [...(previous ?? []), app];
76
14
  }
77
15
  const program = new Command()
78
16
  .name(packageJson.name)
79
17
  .description("Create or reuse a Git worktree for a branch as a sibling of the current worktree")
80
18
  .version(packageJson.version)
81
19
  .argument("<branch>", "branch name for the worktree")
82
- .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
+ `)
83
45
  .action(async (branch, options) => {
84
46
  try {
85
- await main(branch, options);
47
+ await runWorktreeAdd(branch, options);
86
48
  }
87
49
  catch (error) {
88
- 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
+ }
89
57
  process.exitCode = 1;
90
58
  }
91
59
  });
92
60
  if (!process.env.VITEST) {
93
- program.parse();
61
+ await program.parseAsync();
94
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;
@@ -0,0 +1,39 @@
1
+ import { getStatusLogger } from "../output/get-status-logger.js";
2
+ import { git, localBranchExists, normalizeBranchName, remoteBranchExists, } from "./git.js";
3
+ import { extractDiagnosticLine } from "./extract-diagnostic-line.js";
4
+ /**
5
+ * Create a worktree for the given branch at the specified destination.
6
+ */
7
+ export function createWorktree(branch, destinationDirectory, options) {
8
+ const logger = getStatusLogger(options?.logger);
9
+ const dryRun = options?.dryRun ?? false;
10
+ const normalized = normalizeBranchName(branch);
11
+ if (localBranchExists(normalized)) {
12
+ logger.step(`${dryRun ? "Would run " : ""}git worktree add -- ${destinationDirectory} refs/heads/${normalized}`);
13
+ if (dryRun)
14
+ return;
15
+ git("worktree", "add", "--", destinationDirectory, `refs/heads/${normalized}`);
16
+ return;
17
+ }
18
+ let branchExistsOnOrigin = options?.remoteBranchExists;
19
+ if (branchExistsOnOrigin === undefined) {
20
+ try {
21
+ branchExistsOnOrigin = remoteBranchExists(normalized);
22
+ }
23
+ catch (error) {
24
+ const diagnostic = extractDiagnosticLine(error);
25
+ throw new Error(`Failed to reach origin to check whether '${normalized}' exists: ${diagnostic}`);
26
+ }
27
+ }
28
+ if (branchExistsOnOrigin) {
29
+ logger.step(`${dryRun ? "Would run " : ""}git worktree add --track -b ${normalized} -- ${destinationDirectory} origin/${normalized}`);
30
+ if (dryRun)
31
+ return;
32
+ git("worktree", "add", "--track", "-b", normalized, "--", destinationDirectory, `origin/${normalized}`);
33
+ return;
34
+ }
35
+ logger.step(`${dryRun ? "Would run " : ""}git worktree add -b ${normalized} -- ${destinationDirectory}`);
36
+ if (dryRun)
37
+ return;
38
+ git("worktree", "add", "-b", normalized, "--", destinationDirectory);
39
+ }
@@ -0,0 +1 @@
1
+ export declare function extractDiagnosticLine(error: unknown): string;