claude --worktree works. Then four agents fight over port 3000.

claude --worktree works. Then four agents fight over port 3000.

Claude Code's --worktree isolates files and leaves dependencies, ports, databases and cleanup to you. Five verified gotchas plus a WorktreeCreate hook that fixes them.

claude-codegitdeveloper-toolsmacos

claude --worktree feat-a gives every agent its own checkout and its own branch. Real isolation, one flag, no setup.

Then you start the second one, and both dev servers want port 3000.

TL;DR: --worktree isolates files. Dependencies, ports, databases, and cleanup are still yours. A WorktreeCreate hook replaces the default git logic and does all four, including cloning node_modules with APFS copy-on-write so it costs no disk. Script at the bottom.

One-liner

curl -fsSL https://simion.cv/scripts/worktree-create.sh -o ~/.claude/hooks/worktree-create.sh
chmod +x ~/.claude/hooks/worktree-create.sh

Then point Claude Code at it in ~/.claude/settings.json:

{
  "hooks": {
    "WorktreeCreate": [
      { "hooks": [ { "type": "command", "command": "~/.claude/hooks/worktree-create.sh" } ] }
    ]
  }
}

Everything below is why. All of it run on Claude Code 2.1.218 against a throwaway repo with a real origin.

What you get for free, and it’s a lot

Credit where it’s due. --worktree is not a thin wrapper:

$ claude --worktree feat-a
$ pwd
/tmp/wt/app/.claude/worktrees/feat-a
$ git branch
* main
+ worktree-feat-a

Named worktree under .claude/worktrees/, new branch, session cwd moved into it. --worktree "#1234" branches from a pull request. isolation: worktree in a subagent’s frontmatter gives each subagent its own. Exit an interactive session and Claude offers to clean up, checking first whether removal would eat uncommitted work. Resume puts you back inside. Permission approvals and project-scope plugins are shared with the main checkout so you don’t reconfigure per tree.

That’s most of the hard parts. The rest of this post is the parts it doesn’t claim to do, and the docs are upfront about the biggest one:

A worktree is a fresh checkout, so initialize your development environment there: ask Claude to install dependencies, or run your project’s setup yourself.

Gap 1: it’s a fresh checkout, so it’s empty

$ ls -a
.  ..  .git  .gitignore  index.js  package.json
$ ls node_modules
ls: node_modules: No such file or directory

Four agents, four installs. On a warm npm cache that’s less painful than people assume, I measured npm ci at 4.2s for a 199MB tree, but it’s still 199MB of real disk per worktree, and it needs a network the sandbox may not be letting you have.

The better move on macOS is cp -Rc, which asks APFS for clonefile(2). Copy on write. Same 513MB node_modules from a real project:

cp -R   node_modules ./a    9.4s   513 MB of disk
cp -Rc  node_modules ./b    4.4s   ~0 MB of disk

Free space after the clone was within noise of free space before it. Nothing is copied until something writes, and nothing writes to node_modules in a normal session. Works for .venv, vendor/bundle, target/ too. This is the single best trick in this whole area and almost nobody uses it.

Gap 2: every worktree gets the same port, and the docs hand it to you

There is a .worktreeinclude file, gitignore syntax, for carrying gitignored files into new worktrees. Put .env in it and every worktree gets your .env.

Which is the problem:

$ cat .env
PORT=3000
SECRET=main-secret

Verbatim. In all of them. The feature that solves your missing config is the same feature that guarantees three agents get EADDRINUSE and one gets confused. What happens next is the interesting part: the agent doesn’t know another worktree exists, so it does the reasonable local thing. Kills whatever holds :3000, which is your colleague’s session. Or edits the port in a tracked config file and commits it.

Nothing in --worktree allocates ports. It was never going to.

Gap 3: one database, four agents, no supervision

Also not mentioned, because it’s also not its job. Same DATABASE_URL in every copied .env. Agent A runs a destructive migration on the branch it’s working on. Agent B’s test suite starts failing and agent B will confidently misdiagnose that for twenty minutes before someone notices.

Gap 4: your unpushed work isn’t there

Default worktree.baseRef is "fresh", which means the repo’s default branch on the remote. Not your HEAD. I committed locally without pushing, then made a worktree:

$ git log --oneline -1          # main
329b1b2 unpushed wip

$ claude --worktree feat-c
$ git log --oneline -1          # inside the worktree
ba52996 init

Your work in progress is gone from the agent’s view. That’s a defensible default (clean tree matching the remote) and a genuine surprise the first time, especially when you asked the agent to continue something you just wrote.

{ "worktree": { "baseRef": "head" } }

"head" and "fresh" are the only values. You can’t name a branch. For that, git worktree add it yourself.

Gap 5: headless runs leave locked worktrees behind

The exit prompt is what cleans up, and -p has no exit prompt. Documented, easy to skim past, and worse than it sounds. After a single headless run:

$ git worktree list
/tmp/wt/app                            ba52996 [main]
/tmp/wt/app/.claude/worktrees/feat-a   ba52996 [worktree-feat-a] locked

$ git worktree remove .claude/worktrees/feat-a
fatal: cannot remove a locked working tree, lock reason: claude session feat-a
       (pid 20228 start Fri Jul 24 07:30:25 2026)

The lock outlives the process that took it. Claude locks a worktree while an agent runs so cleanup can’t yank it, and the periodic sweep releases locks for exited sessions later, but “later” is cleanupPeriodDays and right now git worktree remove just refuses. git worktree unlock <path> first, or remove -f -f.

Run agents in CI and you accumulate these. Sweep them yourself:

git worktree list --porcelain | awk '/^worktree /{print $2}' \
  | grep '/.claude/worktrees/' | while read -r d; do
      git worktree unlock "$d" 2>/dev/null
      git worktree remove "$d" 2>/dev/null || echo "kept (has work): $d"
    done

The one that isn’t about worktrees at all

Since 2.1.211, choosing “Yes, don’t ask again” for a Bash command inside a worktree saves the rule to the main checkout’s .claude/settings.local.json. It then applies in the main checkout and in every other worktree, and it survives that worktree being deleted.

This is a real ergonomic fix (approvals used to evaporate with the worktree) and also means a throwaway tree you spun up to try something can permanently widen what’s allowed everywhere. I haven’t reproduced this one, it’s from the docs, but it’s worth knowing before you approve something inside a disposable checkout. Same reflex as the sandbox credential defaults: the thing labelled “isolated” is isolated along exactly the axis it says and no other.

The hook

WorktreeCreate replaces the default git logic entirely. Contract: JSON on stdin containing .name, and stdout must be the worktree directory and nothing else. Every diagnostic goes to stderr or Claude tries to cd into your log line.

Mine does six things:

$ echo '{"name":"feat-a"}' | ~/.claude/hooks/worktree-create.sh
  cloned node_modules (copy-on-write)
  copied .env
  PORT=3100  WORKTREE_DB=app_feat_a  ->  .env.local
  worktree ready: /tmp/wt/app/.claude/worktrees/feat-a
/tmp/wt/app/.claude/worktrees/feat-a

Branches from HEAD instead of origin. Clones dependency directories with cp -Rc, falling back to a plain copy off APFS. Copies .env files, because .worktreeinclude is not processed when a WorktreeCreate hook is set, so the hook owns that job now. Allocates a port nobody else has. Stamps a per-worktree DB name into .env.local, which your app config reads and which never gets committed. Registers that .env.local in .git/info/exclude, for a reason I found the hard way two sections down.

Wired up and driven by Claude for real:

$ claude --worktree via-hook
$ pwd
/tmp/wt/app/.claude/worktrees/via-hook
$ ls node_modules
left-pad
$ cat .env.local
PORT=3104
WORKTREE_DB=app_via_hook

The bug I shipped in my own port allocator

First version bound a test socket to find a free port. Obviously correct. Completely wrong.

Create three worktrees before starting any dev server and all three bind-test clean on 3100, so all three get 3100. The collision just moves from creation time to the moment somebody runs npm run dev, which is later and more confusing.

Fix is to also read the ports your siblings already claimed:

claimed = set()
for env in glob.glob(f"{wt_root}/*/.env.local"):
    for line in open(env):
        m = re.match(r"^PORT=(\d+)", line.strip())
        if m:
            claimed.add(int(m.group(1)))

Bind check for the rest of the machine, file scan for the worktrees that exist but aren’t running yet. Then:

.claude/worktrees/feat-a/.env.local:PORT=3100
.claude/worktrees/p1/.env.local:PORT=3101
.claude/worktrees/p2/.env.local:PORT=3102
.claude/worktrees/p3/.env.local:PORT=3103

And the second one, found by the cleanup snippet above

Ran my own sweep against worktrees my own hook had made. Every single one came back kept (has work).

The work was .env.local. The file the hook writes. It isn’t in .gitignore, so git counts it as untracked, so git worktree remove refuses and Claude’s periodic sweep skips the tree forever. My setup script had quietly made every worktree it touched permanently un-cleanable.

The fix is not to add it to .gitignore, which is tracked and belongs to everyone. It’s info/exclude, which is local, shared by every worktree of the repo, and exists for exactly this:

GIT_COMMON=$(git -C "$REPO" rev-parse --git-common-dir)
grep -qxF '.env.local' "$GIT_COMMON/info/exclude" \
  || printf '.env.local\n' >> "$GIT_COMMON/info/exclude"

After that, git status --porcelain inside a fresh worktree is empty and the sweep takes them.

Knobs

All environment variables, all optional:

VariableDefaultDoes
WT_CLONE_DIRSnode_modules .venv vendor/bundleDirectories to clone copy-on-write
WT_COPY_FILES.env .env.localGitignored files to copy verbatim
WT_PORT_BASE / WT_PORT_MAX3100 / 3200Port search range
WT_BASE_REFHEADWhat to branch from

Pair it with a WorktreeRemove hook if your setup creates anything outside the worktree directory, a database for instance, since removing the tree won’t drop it.

Two things that will bite you anyway

Worktree creation refuses to run if .claude, .claude/worktrees, or the target directory is a symlink, and the error names the path. Removing the symlink is the fix.

Add .claude/worktrees/ to .gitignore before any of this, or your main checkout fills up with untracked files and the agent starts helpfully offering to commit them.

Why I care about this specific problem

Termic is my open-source app for running claude, codex and agy side by side, and a worktree per task with its own port is most of what it does. I wrote the sibling-port allocator there first, hit the exact bug above, and fixed it the same way.

The flag is better than what I built for the single-agent case. What it doesn’t do is the boring part that decides whether four agents in parallel is actually faster than one agent in sequence, and the boring part is a fresh checkout with no dependencies, a port collision, and a shared database.