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.
- package/README.md +58 -22
- package/dist/app/get-unsafe-app-name-reason.d.ts +11 -0
- package/dist/app/get-unsafe-app-name-reason.js +21 -0
- package/dist/app/resolve-apps.d.ts +6 -0
- package/dist/app/resolve-apps.js +31 -0
- package/dist/cli/cleanup-worktree.d.ts +2 -0
- package/dist/cli/cleanup-worktree.js +13 -0
- package/dist/cli/open-worktree-apps.d.ts +5 -0
- package/dist/cli/open-worktree-apps.js +33 -0
- package/dist/cli/register-sigint-handler.d.ts +8 -0
- package/dist/cli/register-sigint-handler.js +19 -0
- package/dist/cli/resolve-worktree-context.d.ts +6 -0
- package/dist/cli/resolve-worktree-context.js +22 -0
- package/dist/cli/run-worktree-add.d.ts +9 -0
- package/dist/cli/run-worktree-add.js +120 -0
- package/dist/cli.d.ts +0 -6
- package/dist/cli.js +38 -76
- package/dist/git/create-worktree.d.ts +9 -0
- package/dist/git/create-worktree.js +39 -0
- package/dist/git/extract-diagnostic-line.d.ts +1 -0
- package/dist/git/extract-diagnostic-line.js +13 -0
- package/dist/git/fetch-remote-branch.d.ts +21 -0
- package/dist/git/fetch-remote-branch.js +106 -0
- package/dist/git/git.d.ts +11 -5
- package/dist/git/git.js +25 -17
- package/dist/git/remove-worktree.d.ts +1 -0
- package/dist/git/remove-worktree.js +4 -0
- package/dist/output/create-status-logger.d.ts +13 -0
- package/dist/output/create-status-logger.js +32 -0
- package/dist/output/get-status-logger.d.ts +2 -0
- package/dist/output/get-status-logger.js +12 -0
- package/dist/project/install-commands.js +2 -2
- package/dist/project/next.d.ts +4 -1
- package/dist/project/next.js +2 -2
- package/dist/project/package-manager.d.ts +7 -1
- package/dist/project/package-manager.js +12 -3
- package/dist/project/setup.d.ts +5 -1
- package/dist/project/setup.js +15 -7
- package/dist/worktree/destination-directory.d.ts +10 -3
- package/dist/worktree/destination-directory.js +94 -16
- package/dist/worktree/untracked-file-copy.d.ts +5 -1
- package/dist/worktree/untracked-file-copy.js +51 -9
- package/package.json +4 -2
- package/dist/git/parse-branch-head-mismatch-choice.d.ts +0 -7
- package/dist/git/parse-branch-head-mismatch-choice.js +0 -26
- package/dist/git/resolve-branch-head-mismatch.d.ts +0 -6
- package/dist/git/resolve-branch-head-mismatch.js +0 -79
- package/dist/git/worktree-creation.d.ts +0 -13
- 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
|
|
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.
|
|
12
|
-
4.
|
|
13
|
-
5.
|
|
14
|
-
|
|
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
|
|
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
|
-
##
|
|
90
|
+
## Pipeline examples
|
|
76
91
|
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
2. `WORKTREE_ADD_EDITOR` env var
|
|
81
|
-
3. otherwise `code`
|
|
105
|
+
## App control
|
|
82
106
|
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
worktree-add feature/foo
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
|
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,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,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,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,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 {
|
|
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 {
|
|
14
|
-
|
|
15
|
-
|
|
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("-
|
|
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
|
|
47
|
+
await runWorktreeAdd(branch, options);
|
|
92
48
|
}
|
|
93
49
|
catch (error) {
|
|
94
|
-
|
|
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.
|
|
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;
|