Claude Code worktrees: what claude --worktree isolates, and the ports, databases and installs it doesn't
Tested on Claude Code 2.1.316: two worktrees collided on one port, Docker Compose planned a separate Postgres volume for each, scripted runs left locked checkouts and leftover branches, and the default base skipped the unpushed commit.
Contents
The Claude Code worktrees page spends one sentence on what happens after the checkout exists: “ask Claude to install dependencies, or run your project’s setup yourself in the worktree directory”. That sentence is where most of the trouble with parallel sessions lives. claude --worktree separates files. The port your dev server binds, the database it talks to and the install it needs are still shared, missing or duplicated, and the flag does nothing about any of them.
I ran the flag on a throwaway repository with Claude Code 2.1.316 and git 2.43.0 on Linux, against a local bare remote. All runs were non-interactive, claude -p with Haiku. What I ran is shown with its output. What comes from the worktrees documentation is marked as such, and so is my own reasoning about what a shared machine shares. Paths are shortened to ~/lab/shop.
What does Claude Code create with —worktree?
A linked Git worktree inside your repository, on a new branch, with the session started in it. claude --worktree alpha (or -w alpha) created .claude/worktrees/alpha/ at the repository root on a branch called worktree-alpha. A worktree is a second checkout of the same repository with its own files and index, which the Codex worktrees post covers in more detail. Two sessions in two worktrees edit two separate sets of files, and that is the whole promise.
Per the docs and --help: without a name, Claude Code makes one up, something like bright-running-fox. Interactive runs need workspace trust, so in a new directory you start claude once and accept the dialog, or --worktree exits and tells you to; claude -p --worktree skips that check, which matches my runs. --tmux opens the session in its own tmux session and requires --worktree. Inside a running session you can also ask Claude to “work in a worktree”, and it makes one with the EnterWorktree tool.
Add .claude/worktrees/ to .gitignore before the first run. Otherwise every worktree shows up as untracked files in your main checkout.
Which commit does a Claude Code worktree start from?
The remote default branch, not the commit you are on. The setting is worktree.baseRef, and its default is "fresh": branch from origin’s default branch. The other value, "head", branches from your current local HEAD. To make it permanent, put it in the project’s .claude/settings.json.
My checkout was on feature/discount, one commit ahead of origin/main, with an uncommitted edit to web/index.html. The prompt went in on stdin, which is why --allowedTools=Bash carries an equals sign (the cleanup section has the reason):
$ echo "Run exactly this one bash command and print its raw output only, nothing else: pwd; git status --short --branch; git log --oneline -3; ls -A" \
| claude -p --worktree beta --model haiku --allowedTools=Bash
~/lab/shop/.claude/worktrees/beta
## worktree-beta
40e5569 initial
.git
.gitignore
api
web40e5569 is origin/main. The unpushed commit is not there, and the worktree has nothing from my feature branch. Then the same prompt with the other base, passed through --settings:
$ echo "…" | claude -p --worktree gamma --model haiku --allowedTools=Bash --settings '{"worktree":{"baseRef":"head"}}'
## worktree-gamma
ef9e2fd add discount (local, not pushed)
40e5569 initial| Base | Worktree starts at | Unpushed commit | Uncommitted edit to a tracked file |
|---|---|---|---|
"fresh" (default) | origin/main | no | no |
"head" | your current commit | yes | no |
The last column is the easy one to miss. Neither setting carries the working tree: the "head" worktree had the unedited web/index.html. A worktree starts from a commit, so a tracked change you have not committed stays behind.
Per the docs, "fresh" fetches the default branch when the repository has not been fetched in the last 24 hours, gives up after five seconds and then uses the cached ref. With no remote, or no origin/HEAD cached and none fetchable, it falls back to your local HEAD. baseRef cannot name a branch. To start from a specific branch, make the worktree yourself with git worktree add and run claude inside it. claude --worktree "#1234" starts from a pull request instead, fetched from origin.
Which base to pick depends on the job. "fresh" suits “fix this bug on main” while you keep working on your branch. "head" suits a second session that has to build on what you just did, and then you commit first, because only committed work crosses over.
How do you get .env and node_modules into a Claude Code worktree?
With a .worktreeinclude file at the repository root, in .gitignore syntax. Claude Code copies files that match it, and only if they are gitignored. My source checkout had a .env and a node_modules/ directory holding one stand-in package, left-pad. The first worktree got neither. Then:
$ printf '.env\nnode_modules/\n' > .worktreeinclude
$ echo "…ls -A; ls node_modules; cat .env" | claude -p --worktree gamma …
.env
.git
.gitignore
api
node_modules
web
left-pad
SECRET=abcThe .worktreeinclude file was itself untracked, never committed, and it still worked. And node_modules/ came across as separate files, not links: they had different inode numbers from the originals. This disk is ext4, which has no reflinks, so each copy takes real space. On a JavaScript project with a large node_modules, every worktree pays for it, and a copied install can be wrong for the worktree if its lockfile differs from the source checkout’s. For dependencies, running the install inside the worktree is the safer default. .worktreeinclude fits small files such as .env, and each worktree then holds its own copy of the secrets for as long as it exists.
What does Claude Code’s worktree isolation actually block?
Edits and git commands aimed at the main checkout, judged from the path and the command text. Per the isolation section of the docs, while a session is isolated in a worktree, Claude Code refuses an Edit, Write or NotebookEdit that targets the main checkout and a command whose working directory is the main checkout. For Bash and Monitor it also refuses git redirected there through git -C, --git-dir, GIT_DIR, GIT_WORK_TREE or a cd, and any command whose git usage it cannot verify from the text. PowerShell gets only the working-directory check.
The docs are explicit about the gap: none of these checks tracks which files a shell command writes, so a cp or a shell redirect into the main checkout is not refused by them. That falls to your permission rules and the sandbox, if you run one.
A few things are shared on purpose, also per the docs. Git writes from a worktree go to the main repository’s .git, so commits work from inside it. “Yes, and don’t ask again” on a Bash command in a worktree session saves the rule to the main checkout’s .claude/, where it applies to every worktree and survives the removal of the one you approved it in. In hooks, ${CLAUDE_PROJECT_DIR} stays at the project root where the session started, and the worktree path arrives in the cwd field of the hook’s input, which also follows later cds.
So Claude Code’s isolation is file-level too. It keeps the session’s edits and git out of your checkout. It has no opinion about anything the session starts.
Which resources do parallel Claude Code worktrees still share?
Everything outside the repository’s files: ports, databases, containers, caches and API limits. This is the part that breaks first when two sessions both run the app.
The port is the simplest case. I started a server for one worktree and the same command for the other, on a spare port, 3107:
$ python3 -m http.server -d .claude/worktrees/beta 3107 &
$ python3 -m http.server -d .claude/worktrees/gamma 3107
OSError: [Errno 98] Address already in useA dev server with a fixed port fails the same way. next dev behaves differently when you give it no --port and no PORT: according to start-server.ts in the Next.js repository, it tries the next port, up to ten times, and logs that the original port is in use, “using available port” so-and-so instead. The second session then has a working server on a port it did not ask for. Whether the agent reads that warning, or goes on testing localhost:3000, which belongs to the first session, is the open question.
Docker Compose is less obvious, because it isolates part of the stack whether you want it to or not. By default Compose names a project after its directory, unless -p, COMPOSE_PROJECT_NAME or a top-level name: says otherwise, and a worktree’s directory is its name. I put the same compose.yaml, a Postgres service publishing 5432:5432 with a named volume, into the main checkout and two worktrees, and asked Compose what it would build (output trimmed to the relevant fields):
shop: name: shop published: "5432" volume: shop_pgdata
beta: name: beta published: "5432" volume: beta_pgdata
gamma: name: gamma published: "5432" volume: gamma_pgdataI stopped at docker compose config and did not start the containers. The plan already shows both halves. Each worktree gets its own volume name, so a session that brings the database up in a new worktree gets a new volume: empty, unless an init script or your setup fills it. And every project publishes the same host port, so only one of those databases can be up at a time. Compose isolates the data you probably wanted to share and shares the port you needed to split.
The rest is reasoning, not tested here. A Postgres running on the host, outside Docker, is shared by every worktree that keeps the same connection settings, so two sessions running migrations from two branches write into the same database. Package caches such as ~/.npm are shared too, which is harmless. Rate limits on an external API are shared and easy to forget: two sessions running the same test suite against one sandbox account draw from the same quota.
None of this is a flaw in --worktree. A worktree is a file-level tool, and the coordination around it has to live outside the agents. In practice each worktree needs its own values for whatever is machine-wide, and the copied .env is the wrong place for them, because it carries the main checkout’s port and database name into every worktree. What worked in my lab: read the port from a variable in compose.yaml, and write the per-worktree values into a second file that overrides the copied one.
services:
db:
image: postgres:16
ports:
- "${PGPORT:-5432}:5432"
environment:
POSTGRES_DB: ${PGDATABASE:-shop}$ printf 'PGPORT=5433\nPGDATABASE=shop_alpha\n' > .env.worktree
$ docker compose --env-file .env --env-file .env.worktree config
published: "5433"
POSTGRES_DB: shop_alphaThe later --env-file wins. Your app has to read the same two files for its own port and connection string. If you would rather have one shared database than one empty database per worktree, use one database on the host, or an external volume shared by every worktree, and accept that the branches then share data.
Do Claude Code background sessions and subagents get worktrees automatically?
Background sessions usually do, before their first edit; subagents only when you ask. Per the agent view docs, a session started with claude --bg begins in your working directory and moves into a worktree under .claude/worktrees/ before it edits files. It skips the move when you sent an already open session to the background, when the session or the file it is editing is already in a linked worktree, when the write is outside the working directory, or outside a git repository with no WorktreeCreate hook configured. worktree. in the project settings turns it off.
Subagents share the parent’s directory unless one has isolation: worktree in its frontmatter or you tell Claude to “use worktrees for your agents”. The catch is in the base. The docs say subagent worktrees use the same worktree.baseRef as --worktree, so with the default "fresh", a subagent asked to refactor the feature you are building starts from origin/main, without your unpushed commits. The docs recommend "head" for exactly that case.
Also per the docs: a subagent in its own worktree reads CLAUDE.md and .claude/rules/ from the main conversation’s checkout, not from the worktree, even if that branch has different ones. A subagent worktree with no changes is removed when the subagent finishes. The rest, along with background-session worktrees, are swept after cleanupPeriodDays, but only once they hold no changed files and no unpushed commits.
How do you clean up Claude Code worktrees, and what stays behind?
Interactively, Claude Code does most of it when you exit. Per the docs, an unnamed session in a clean worktree removes the worktree and its branch automatically, a named session asks first, and a worktree with changes or new commits always asks. claude -p has no exit prompt, so its worktrees stay, and they stay locked:
$ git worktree remove .claude/worktrees/beta
fatal: cannot remove a locked working tree, lock reason: claude session beta (pid 1153756 start 52741970)
use 'remove -f -f' to override or unlock firstBefore removing anything, get the work out. worktree-beta is an ordinary branch, so merge or cherry-pick it from the main checkout like any other. Then, once the session that owns the lock has ended:
$ git worktree unlock .claude/worktrees/beta
$ git worktree remove .claude/worktrees/beta
$ git branch -d worktree-beta
Deleted branch worktree-beta (was 40e5569).The third line is there because git worktree remove deletes the directory and keeps the worktree-beta branch. After removing two worktrees by hand I had two leftover branches. Use -d, which refuses a branch with unmerged commits, and keep -D for a branch you mean to throw away.
My run turned up two less obvious leftovers. A claude -p call that failed on its own arguments still created and locked its worktree. My first attempt put --allowedTools "Bash" before the prompt, the variadic flag took the prompt as a second tool name, Claude Code exited with “Input must be provided either through stdin or as a prompt argument”, and git worktree list showed alpha on 40e5569, locked. A script that calls claude -p --worktree in a loop needs a cleanup step that runs even when the call fails. And removing the gamma worktree, with the copied .env and node_modules inside, produced no warning at all. Git refuses to remove a worktree with modified or untracked files, but ignored files are deleted with the directory, including whatever an agent wrote into an ignored path such as a build or output folder.
The docs say a later session’s stale-lock sweep releases a lock whose process has exited. To see the locks yourself, git worktree list --verbose prints each lock reason with its pid:
$ git worktree list --verbose
~/lab/shop ef9e2fd [feature/discount]
~/lab/shop/.claude/worktrees/alpha 40e5569 [worktree-alpha]
locked: claude session alpha (pid 1153520 start 52741354)What to set up before running a second Claude Code session on one repository
Five things, and the last three are about what the worktree leaves alone. Add .claude/worktrees/ to .gitignore. Decide the base: keep "fresh" for work that starts from main, and set "head" when the second session or a subagent has to build on your branch, after committing what it needs. Put .env and other small ignored files in .worktreeinclude, and run the dependency install in the worktree instead of copying it. Give each worktree its own host port and database name, in a file that overrides the copied .env, before the second dev server or database starts. And after any claude -p run, merge what you want to keep, then unlock, remove and git branch -d, or the locked checkouts and worktree-* branches pile up.
If the branch is known in advance, the hand-made route is still shorter: git worktree add -b fix/login ../shop-login origin/main names the branch and the start point in one command, and claude inside it works there. That was also the conclusion for Codex CLI’s managed worktrees. The managed flag earns its place when the checkout is disposable and you want the cleanup done for you. Per the docs that happens on interactive exit; for scripted runs, my lab shows it does not happen at all.
01 / Discussion
No comment section here — all discussions happen on X.
02 / More articles
Claude Ultra replaces Max 20x: no 5-hour limit for verified accounts, and Anthropic says the weekly 10x never changed
The 20x in the old name described the 5-hour window, the friendlier of two meters. By Anthropic's account the weekly cap was 10x Pro from the day it was added. Ultra's pricing now leads with the weekly number and drops the 5-hour one, which is a better deal for some people and a correction for anyone who read 20x as weekly volume.
Prompt injection examples against coding agents: where the text got in, and what, if anything, stopped it
Nine public cases, sorted by the door the attacker's words came through. In most of them nothing held during the attack. The fixes that came after were all of one kind: a deterministic check re-aimed or a capability removed. A model's judgment that failed had nothing to re-aim.
Vibe coding vs agentic engineering: what still checks the code after the session ends
Simon Willison draws the line at a person who reviewed the code and can explain it. That review happens once, and the code keeps changing after the chat closes. If agentic engineering is a different thing and not a nicer name, the difference is a check that runs without anyone in the room, and that has been seen to fail for the right reason.