worktree-add 1.2.1 → 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 (49) hide show
  1. package/README.md +57 -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 +100 -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 +9 -0
  23. package/dist/git/fetch-remote-branch.js +102 -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,21 @@ 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/diverged
16
+ 6. Creates a git worktree:
15
17
  - reuses an existing local branch
16
18
  - or creates a tracking branch from `origin/<branch>`
17
- - or creates a new branch from the current `HEAD`
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)
18
20
  7. Copies untracked / ignored files into the new worktree, skipping heavy stuff
19
21
  (`node_modules`, `dist`, `.next`, caches, virtualenvs, etc.).
20
22
  8. Detects your package manager and installs dependencies with lockfile‑safe flags
21
23
  (`npm ci`, `pnpm install --frozen-lockfile`, `yarn install --immutable`, etc.).
22
24
  9. If the project uses Next.js and supports it, runs `next typegen`.
23
- 10. Opens the new worktree in your editor.
25
+ 10. Opens the new worktree in your requested apps (if any).
24
26
 
25
27
  Your original checkout is left untouched.
26
28
 
@@ -28,6 +30,7 @@ Your original checkout is left untouched.
28
30
 
29
31
  - Node.js ≥ 22.14.0
30
32
  - Git with `git worktree` support
33
+ - Git credentials configured for `origin/` (the tool is non-interactive and won’t prompt for authentication)
31
34
 
32
35
  ## Install / run
33
36
 
@@ -49,7 +52,7 @@ Run it from anywhere inside an existing worktree of the repo.
49
52
 
50
53
  ```bash
51
54
  # inside /my/path/my-app
52
- worktree-add <branch>
55
+ worktree-add <branch> [options]
53
56
  ```
54
57
 
55
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.
@@ -62,8 +65,19 @@ Example:
62
65
  worktree-add feature/login-form
63
66
  ```
64
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
+
65
79
  A new branch from the current HEAD is created only when the branch does not already
66
- 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`.
67
81
 
68
82
  Destination directory (assuming repo named `my-app`):
69
83
 
@@ -72,27 +86,44 @@ Destination directory (assuming repo named `my-app`):
72
86
  /my/path/my-app-feature-login-form # new worktree for branch "feature/login-form"
73
87
  ```
74
88
 
75
- ## Editor control
89
+ ## Pipeline examples
76
90
 
77
- 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
+ ```
78
103
 
79
- 1. `--editor <command>` if passed
80
- 2. `WORKTREE_ADD_EDITOR` env var
81
- 3. otherwise `code`
104
+ ## App control
82
105
 
83
- 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.
84
109
 
85
- ```bash
86
- worktree-add feature/foo -e code
87
- WORKTREE_ADD_EDITOR=vim worktree-add feature/foo
88
- ```
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.
89
118
 
90
- 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.
91
120
 
92
- 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:
93
124
 
94
125
  ```bash
95
- worktree-add() { WORKTREE_ADD_EDITOR=cursor command worktree-add "$@" }
126
+ worktree-add() { WORKTREE_ADD_APP=ghostty,code command worktree-add "$@" }
96
127
  ```
97
128
 
98
129
  Add it to your shell profile:
@@ -101,6 +132,10 @@ Add it to your shell profile:
101
132
  - bash: `~/.bashrc` or `~/.bash_profile`
102
133
  - fish: `~/.config/fish/config.fish`
103
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
+
104
139
  ## License
105
140
 
106
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,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;
@@ -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;