To run two Claude Code sessions on one repository without their edits colliding, give the second session its own git worktree: git worktree add ../proj-feature -b feature makes a second checkout of the repository in ../proj-feature on a new branch feature, and you run claude in that folder while the first session keeps working in the original one. Claude Code can also make the worktree for you: claude --worktree feature-auth (short form -w) creates .claude/worktrees/feature-auth in the repository on a new branch worktree-feature-auth and starts the session in it.

On this machine claude --version printed 2.1.296 (Claude Code), and claude --help describes the flag in two lines:

claude --version
2.1.296 (Claude Code)
claude --help | grep -A1 -e '-w, --worktree'
  -w, --worktree [name]                 Create a new git worktree for this
                                        session (optionally specify a name)

The CLI reference says more: “Start Claude in an isolated git worktree at <repo>/.claude/worktrees/<name>. If you don’t give a name, Claude Code generates one.” The two agree on what the flag does; only the docs say where the worktree goes, and the worktrees page adds the branch name: “By default, the worktree is created under .claude/worktrees/<name>/ at your repository root, on a new branch named worktree-<name>:”. The CLI reference also warns that “claude --help does not list every flag, so a flag’s absence from --help does not mean it is unavailable.” The docs pages were read on 2026-10-13.

Two sessions with git worktree add

Everything below ran in a scratch repository on this machine with git 2.34.1. The commit author’s name and email were fixed, and so were GIT_AUTHOR_DATE and GIT_COMMITTER_DATE (both 2026-10-13T12:00:00+00:00), so the commit hashes come out the same every time the commands run. The scratch directory was /home/loopdev/dankdev/node_modules/.scratch/wt, which is why that path is in the output.

First, a repository with one commit, plus the three things most projects keep out of git: a .env, a node_modules/ folder and Claude Code’s local settings file with one saved permission rule.

mkdir proj && cd proj
git init -q -b main
printf '%s\n' node_modules/ .env .claude/settings.local.json .claude/worktrees/ > .gitignore
echo '# demo' > README.md
git add .gitignore README.md
git commit -q -m 'First commit'
echo 'DATABASE_URL=postgres://localhost/dev' > .env
mkdir -p node_modules/left-pad .claude
echo '{}' > node_modules/left-pad/package.json
echo '{"permissions":{"allow":["Bash(npm run test *)"]}}' > .claude/settings.local.json
git status --short --ignored
git log --oneline
!! .claude/
!! .env
!! node_modules/
0254821 First commit

!! is how git status marks an ignored path. Now make the second checkout:

cd proj
git worktree add ../proj-feature -b feature
Preparing worktree (new branch 'feature')
HEAD is now at 0254821 First commit

Then start the second session in it:

cd ../proj-feature
claude

Not run here: it starts an interactive Claude Code session.

The first session keeps running in proj on main; the second edits proj-feature on feature. git worktree list shows both:

cd proj
git worktree list
/home/loopdev/dankdev/node_modules/.scratch/wt/proj          0254821 [main]
/home/loopdev/dankdev/node_modules/.scratch/wt/proj-feature  0254821 [feature]

What the worktree has, and what it lacks

The new folder is a checkout of the committed files and nothing else. Here are the two folders side by side:

ls -A proj proj-feature
ls -A proj/.claude proj-feature/.claude
cat proj-feature/.git
proj:
.claude
.env
.git
.gitignore
README.md
node_modules

proj-feature:
.git
.gitignore
README.md
proj/.claude:
settings.local.json
gitdir: /home/loopdev/dankdev/node_modules/.scratch/wt/proj/.git/worktrees/proj-feature

On stderr:

ls: cannot access 'proj-feature/.claude': No such file or directory

proj-feature has the two committed files and none of the ignored ones: no .env, no node_modules/, no .claude/. Its .git is not a folder but a one-line file pointing into the main repository’s .git directory, so there is one repository with two working folders. The docs put that first in their list of what a worktree shares: “The repository’s .git directory: git commands in a worktree write to the main repository’s shared .git directory”. Three things follow from it:

  • Set the project up again in the worktree. There is no node_modules/, so npm install, or whatever your project’s setup is, has to run in the new folder. The worktrees page says the same: “A worktree is a fresh checkout, so initialize your development environment there”.
  • Copy .env yourself. git does not copy ignored files. For worktrees that Claude Code creates there is a file for that, .worktreeinclude, covered below; a folder you make with git worktree add, like proj-feature, gets nothing copied.
  • The local settings file is missing, but the docs say the approvals still apply. .claude/settings.local.json is not in proj-feature (the ls error above). The settings docs say what Claude Code does about that: “In a worktree, it uses the file at the main checkout’s root.” The worktrees page adds that an approval saved during a worktree session goes to the main checkout’s .claude/settings.local.json, “so it applies in the main checkout and in every other worktree of the repository, and it survives the worktree’s removal.” The exceptions, where the file stays with the worktree instead, are “outside a git repository, when the repository root is your home directory, on Windows, or when the repository root or its .git or .claude entry isn’t owned by your user.” The committed .claude/settings.json is different: it is a tracked file, so it is checked out in every worktree, and “Claude Code reads the shared .claude/settings.json from the session’s primary working directory”. No session was started here, so which file a session in proj-feature loads was not tested; what was measured is only that the local file is not in the folder. (Where each settings file lives and which one wins is in Claude Code settings.json: where each file lives.)

A commit made in one worktree shows up in the other straight away, because both write to the same repository:

cd proj-feature
echo 'feature work' > feature.txt
git add feature.txt
git commit -q -m 'Add feature.txt'
cd ../proj
git log --all --oneline
git branch
bee06fc Add feature.txt
0254821 First commit
+ feature
* main

git log --all in proj lists bee06fc, which was made in proj-feature a moment before. In the git branch output, + marks a branch that is checked out in another worktree. That is the one thing git will not share: a branch can be checked out in only one worktree at a time.

cd proj
git checkout feature

Exit code 128:

On stderr:

fatal: 'feature' is already checked out at '/home/loopdev/dankdev/node_modules/.scratch/wt/proj-feature'

So each session gets its own branch, and neither can switch onto the other’s.

Let Claude Code make the worktree for you

claude --worktree feature-auth

Not run here: it starts an interactive Claude Code session.

The worktrees page says the rest of what this command does. You can run it again in a second terminal: “Run the command again with a different name in another terminal to start a second isolated session. If you omit the name, Claude generates one such as bright-running-fox.” The first run in a folder needs one ordinary start: “Interactive runs require workspace trust: if you haven’t run Claude in the directory before, run claude once there to accept the trust dialog, or --worktree exits with an error prompting you to.”

It does not branch from where you are. git worktree add -b feature above started the branch at the current HEAD (it printed HEAD is now at 0254821), but --worktree defaults to the remote’s default branch:

  • “"fresh" (default): branch from the repository’s default branch on the remote, usually main, so the worktree starts from a clean tree matching the remote.”
  • “"head": branch from your current local HEAD, so the worktree carries your unpushed commits and feature-branch state.”

“Set worktree.baseRef in settings to branch from your current work instead.” This is the docs’ example for that:

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

With no remote, the default already behaves like "head": “If no remote is configured, or origin/HEAD isn’t cached locally and can’t be fetched, the worktree falls back to your current local HEAD.”

Its worktrees live inside the repository, so the docs suggest ignoring them: “Add .claude/worktrees/ to your .gitignore so worktree contents don’t appear as untracked files in your main checkout.” The .gitignore in the setup above has that line.

To get .env into these worktrees, use .worktreeinclude: “A worktree is a fresh checkout, so untracked files like .env or .env.local from your main repository are not present. To copy them automatically when Claude creates a worktree, add a .worktreeinclude file to your project root.” It holds one pattern per line, for example just .env, and “The file uses .gitignore syntax. Only files that match a pattern and are also gitignored are copied, so tracked files are never duplicated.” It works only on worktrees Claude Code makes: “This applies to every worktree Claude Code creates with git: --worktree worktrees, subagent worktrees, and parallel sessions in the desktop app.”

When you exit the session, Claude Code checks the worktree first: “The worktree is clean: for an unnamed session, Claude removes the worktree and its branch automatically.” and “The worktree has work in it: Claude prompts you to keep or remove the worktree. Keeping preserves the directory and branch.” To watch the session in tmux, the CLI reference lists --tmux: “Create a tmux session for the worktree. Requires --worktree. Uses iTerm2 native panes when available; pass --tmux=classic for traditional tmux”.

Merge the work back and remove the worktree

Merging back works as it does for any other branch: once the work in the second worktree is committed, merge its branch from the main checkout.

cd proj
git merge feature
Updating 0254821..bee06fc
Fast-forward
 feature.txt | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 feature.txt

main had no commits of its own since feature started, so git fast-forwarded. Then remove the worktree. git refuses while it holds files that are not committed:

echo 'scratch notes' > proj-feature/notes.txt
cd proj
git worktree remove ../proj-feature

Exit code 128:

On stderr:

fatal: '../proj-feature' contains modified or untracked files, use --force to delete it

--force would delete the folder anyway, uncommitted work included. Deleting the stray file lets a plain removal through. Watch what happens to ignored files, though: a .env is copied into the worktree first, and it does not stop the removal; it is deleted with the folder.

rm proj-feature/notes.txt
cp proj/.env proj-feature/.env
cd proj
git worktree remove ../proj-feature
git worktree list
git branch -d feature
ls -A ../proj-feature

Exit code 2:

/home/loopdev/dankdev/node_modules/.scratch/wt/proj  bee06fc [main]
Deleted branch feature (was bee06fc).

On stderr:

ls: cannot access '../proj-feature': No such file or directory

git worktree remove printed nothing, the folder is gone (hence ls failing with exit code 2), git worktree list is back to one line, and git branch -d deleted feature. -d only deletes a branch that is already merged, so it is the safe one to use here.

Copy .env into new worktrees with a WorktreeCreate hook

The hooks reference names two events for worktrees, WorktreeCreate and WorktreeRemove. Read what the first one replaces before using it: “By default Claude Code creates the isolated working copy with git worktree. Configuring a WorktreeCreate hook replaces that default git behavior”. So the hook has to create the worktree itself, and then “Because the hook replaces the default behavior entirely, .worktreeinclude is not processed. If you need to copy local configuration files like .env into the new worktree, do it inside your hook script.” If copying .env is all you need, .worktreeinclude does it without a script. The hook is worth it when you want to decide how the worktree is made. It does not fire for worktrees made by hand: “When Claude runs git worktree add through the Bash tool rather than calling EnterWorktree, your WorktreeCreate hook doesn’t run.”

The hook gets JSON on stdin. “WorktreeCreate hooks receive the name field. This is a slug identifier for the new worktree, either specified by the user or auto-generated”, next to common fields such as cwd, the “Current working directory when the hook is invoked”. Its answer is the path: “Command hooks (type: "command"): print the path as the last non-empty line of stdout.” This script makes the worktree where --worktree would, on the same branch name, copies .env and .env.local if they exist, and sends git’s messages to stderr so the path is the only thing on stdout. It uses python3 to read the JSON, so it does not need jq. Save it as .claude/hooks/worktree-create.sh:

#!/usr/bin/env bash
# worktree-create.sh: WorktreeCreate hook. Makes a git worktree and copies .env into it.
set -euo pipefail
input=$(cat)
field() { printf '%s' "$input" | python3 -c 'import json, sys; print(json.load(sys.stdin)[sys.argv[1]])' "$1"; }
name=$(field name)
root=$(git -C "$(field cwd)" rev-parse --show-toplevel)
dir="$root/.claude/worktrees/$name"
git -C "$root" worktree add "$dir" -b "worktree-$name" >&2
for f in .env .env.local; do
  if [ -f "$root/$f" ]; then cp "$root/$f" "$dir/$f"; fi
done
echo "$dir"

WorktreeRemove hooks receive the worktree_path field, “which is the absolute path to the worktree being removed.” The docs leave the branch to you: “Claude Code never deletes a branch belonging to a hook-created worktree, because it only knows the path your WorktreeCreate hook returned. If your WorktreeCreate hook creates a branch, delete it in your WorktreeRemove hook.” Save this as .claude/hooks/worktree-remove.sh:

#!/usr/bin/env bash
# worktree-remove.sh: WorktreeRemove hook. Removes the worktree, then its branch if merged.
set -euo pipefail
path=$(python3 -c 'import json, sys; print(json.load(sys.stdin)["worktree_path"])')
branch=$(git -C "$path" branch --show-current)
main=$(dirname "$(git -C "$path" rev-parse --path-format=absolute --git-common-dir)")
git -C "$main" worktree remove "$path" >&2
git -C "$main" branch -d "$branch" >&2

It uses plain git worktree remove, without --force, so a worktree with uncommitted files makes it exit 128 as in the section above, and then “Hook exits non-zero: the removal fails if the directory at worktree_path still exists afterward, and the worktree stays on disk with no git fallback.” git branch -d refuses a branch with unmerged commits; the folder is already gone by then, and the docs say “A hook that deleted the directory before exiting non-zero counts as removed.” So the branch and its commits stay for you to deal with.

Register both in .claude/settings.json. ${CLAUDE_PROJECT_DIR} is, in the reference’s words, “${CLAUDE_PROJECT_DIR}: the project root where the session started.”

{
  "hooks": {
    "WorktreeCreate": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/worktree-create.sh"
          }
        ]
      }
    ],
    "WorktreeRemove": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/worktree-remove.sh"
          }
        ]
      }
    ]
  }
}

Without a session, you test a hook by piping it a payload of the documented shape. This is the reference’s WorktreeCreate example with cwd set to the project:

cd proj
chmod +x .claude/hooks/worktree-create.sh .claude/hooks/worktree-remove.sh
printf '%s' '{"session_id":"abc123","transcript_path":"/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl","cwd":"'"$PWD"'","hook_event_name":"WorktreeCreate","name":"feature-auth"}' | .claude/hooks/worktree-create.sh
echo "exit $?"
ls -A .claude/worktrees/feature-auth
git worktree list
/home/loopdev/dankdev/node_modules/.scratch/wt/proj/.claude/worktrees/feature-auth
exit 0
.env
.git
.gitignore
README.md
feature.txt
/home/loopdev/dankdev/node_modules/.scratch/wt/proj                                 bee06fc [main]
/home/loopdev/dankdev/node_modules/.scratch/wt/proj/.claude/worktrees/feature-auth  bee06fc [worktree-feature-auth]

On stderr:

Preparing worktree (new branch 'worktree-feature-auth')
HEAD is now at bee06fc Add feature.txt

Exit code 0, the path as the hook’s only line on stdout, git’s two lines on stderr, and .env in the new worktree next to the checked-out files. Now the removal, with the documented WorktreeRemove fields:

cd proj
printf '%s' '{"session_id":"abc123","transcript_path":"/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl","cwd":"'"$PWD"'","hook_event_name":"WorktreeRemove","worktree_path":"'"$PWD"'/.claude/worktrees/feature-auth"}' | .claude/hooks/worktree-remove.sh
echo "exit $?"
git worktree list
git branch
exit 0
/home/loopdev/dankdev/node_modules/.scratch/wt/proj  bee06fc [main]
* main

On stderr:

Deleted branch worktree-feature-auth (was bee06fc).

The worktree is gone from git worktree list and its branch from git branch. Both scripts were run by piping these payloads in by hand. Claude Code did not call them, because no session was started.

What was run here, and what was not

Every block above with output under it ran, in the order shown, on Claude Code 2.1.296 and git 2.34.1, in one scratch directory with HOME and CLAUDE_CONFIG_DIR pointed inside it and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC and DISABLE_TELEMETRY set, with no network. claude itself ran only as claude --version and claude --help. The output under each block is what came out, stdout and stderr shown separately. A test in this site’s repository runs the git blocks again in a fresh scratch directory and fails if any output or exit code differs, and the md5 of this machine’s real .claude.json file was the same before and after. For the git side, the git worktree documentation has every option.

Not run: any Claude Code session. That covers claude in the worktree, claude --worktree feature-auth, the clean-up check when a session exits, .worktreeinclude, worktree.baseRef, and Claude Code calling the two hooks itself. Those are quoted from the docs above, not shown.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top