Five Quiet Failures: A Bare Repo and Worktrees for Parallel Coding Agents
I wanted to run several coding agents, Claude Code and Codex, in one repository at the same time, one task per agent. Two agents in one checkout share one index and one set of files, so they overwrite each other’s edits. The usual fix is git worktrees. This post shows the layout I use, a bare clone with one worktree per branch, and the five failures I hit on the way. None of the five gave an error. Each one looked like normal work until I found the wrong result.
The layout
Clone the repo bare into a hidden .bare/ folder inside a container directory. Each
branch, the default branch too, gets its own worktree beside it:
my-project/ <-- container; git commands work here too
├── .bare/ <-- the git database, no working files
├── .git <-- a file: "gitdir: ./.bare"
├── main/ <-- the default branch; no feature work here
├── me-feat-retry/ <-- one task = one branch = one worktree = one agent
└── me-fix-docs/
All worktrees share one object database, so one git fetch updates all of them. Each
worktree has its own index, HEAD, and files. I chose this over N full clones because N
clones means N fetches, N copies of the history, and branches that exist in one clone but
not in the others.
Failure 1: the bare clone has no origin/*
A bare clone does not set up remote-tracking refs. It copies every remote branch as a
local branch, and it writes no fetch refspec. So git fetch updates nothing that you can
branch from, and “start from the latest origin/main” is not possible.
Older bare-repo recipes fix this with the refspec +refs/heads/*:refs/heads/*. That is
worse. While any worktree has a branch checked out, every fetch fails with “refusing to
fetch into branch checked out”, and origin/* never moves. With nothing checked out, the
fetch force-overwrites your local branches.
The setup that works:
git clone --bare git@github.com:org/repo.git my-project/.bare
cd my-project
echo "gitdir: ./.bare" > .git
git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git fetch origin
git remote set-head origin -a
D=$(git symbolic-ref --short refs/remotes/origin/HEAD); D=${D#origin/}
git for-each-ref --format='%(refname:short)' refs/heads |
while read -r b; do [ "$b" = "$D" ] || git branch -D "$b"; done
git config push.autoSetupRemote true
git config fetch.prune true
git worktree add "$D" "$D"
git branch --set-upstream-to="origin/$D" "$D"
The loop deletes the local copies of remote branches that the clone made. Without it, you
get dozens of local branches that never update. push.autoSetupRemote removes the need for
-u origin <branch> on every first push. fetch.prune removes origin/* refs for branches
that GitHub deleted.
Failure 2: new work starts from an old commit
I use worktrunk (wt) to create, switch between, and remove worktrees by branch name.
This config puts each worktree beside main/, with / in the branch name changed to -:
# ~/.config/worktrunk/config.toml
worktree-path = "/../"
wt switch --create <branch> branches from the local main branch by default. A
git fetch moves origin/main, not main. So every new task starts from the commit
that main/ had the last time you pulled it, and nothing warns you.
The fix is to always pass the base:
git fetch origin
wt switch --create me/feat-retry --base origin/main
I wrap that in a shell function that also starts the agent in the new worktree:
wsc() { git fetch -q origin && wt switch --create "$1" --base origin/main -x claude -- "${@:2}"; }
wsc me/feat-retry 'Implement the retry change described in ...'
The -x flag takes any command. For Codex:
git fetch origin
wt switch --create me/feat-retry --base origin/main -x codex -- 'Implement the retry change ...'
The layout does not depend on the agent. Only Failure 5 below is specific to Claude Code.
Failure 3: the build compiles the wrong worktree
A new worktree is a clean checkout, with no build output. worktrunk has
wt step copy-ignored to copy gitignored files, such as target/, from main/ into the
new worktree. For an sbt project, that is a trap.
project/target/active.json records which sbt server the sbtn client connects to. A
copied active.json sends the new worktree’s sbtn to the server in main/. That server
compiles and tests the code in main/, not the agent’s changes. The output looks like a
normal build.
sbt 2 also makes the copy unnecessary. It keeps a compile cache in ~/.cache/sbt that all
worktrees share. In one run of each on an sbt 2.0.12 project, the first Test/compile in a
fresh worktree, server start included, took 10.4 s without the copy and 9.0 s with it.
For other build tools, a .worktreeinclude file (gitignore syntax) limits what gets
copied. Leave out anything that records an absolute path or a running process.
Failure 4: one agent pops another agent’s stash
Worktrees do not isolate everything. All state under .bare/ is common to all worktrees:
- The stash. A
git stashin one worktree shows ingit stash listin every worktree. An agent that runsgit stash poptakes the top entry, which can belong to a different agent. I tell agents to make a WIP commit. The other option isgit stash push -m <tag>and then an apply of that entry by SHA. - Config and hooks. A
git configwithout--global, in any worktree, changes.bare/configfor all of them. The same is true for.bare/hooks/. - Refs. Branches and tags are global. Git does refuse to delete a branch that another worktree has checked out.
Failure 5: Claude Code forgets half of what it learned
This failure applies to Claude Code only. Codex does not use autoMemoryDirectory, and the
rest of the layout works for it without this fix.
Claude Code keeps state under ~/.claude/projects/<path-with-dashes>/. In this layout that
state splits in two:
| Session started from | Transcripts | Auto memory |
|---|---|---|
| container root | …-my-project/ |
…-my-project/memory/ |
| any worktree | …-my-project-<worktree>/ |
…-my-project--bare/memory/ |
A session started at the root sees only the root memories. A session started in a worktree
sees only the .bare memories. A symlink from one memory/ directory to the other does
not fix this. The index loads, but every read and write of a memory file asks for
permission, because the file resolves outside the session’s own memory directory.
The fix is the autoMemoryDirectory setting, set to one directory, in each worktree’s
.claude/settings.local.json:
mkdir -p main/.claude
printf '{\n "autoMemoryDirectory": "%s"\n}\n' \
~/.claude/projects/-path-to-my-project/memory > main/.claude/settings.local.json
A worktrunk hook copies that file into each new worktree before the agent starts:
# ~/.config/worktrunk/config.toml
[pre-start]
claude-settings = "if [ -f '/.claude/settings.local.json' ]; then mkdir -p .claude && cp '/.claude/settings.local.json' .claude/; fi"
Keep settings.local.json gitignored. A global **/.claude/settings.local.json line in
~/.config/git/ignore covers every repo. The permissions you approve in main/ go to
each new worktree in the same file.
One loud failure: moving the container
This one does give an error. Git links each worktree to .bare/ with absolute paths, in
both directions. If you move or rename the container, every worktree fails with
fatal: gitfile does not point to a valid repository. Run this from the new path:
git worktree repair */
Do not run git worktree prune before the repair. It deletes the records of the broken
worktrees.
Rules I give the agents
- One agent per worktree, and start each session inside its worktree. Then relative paths, build output, and the build server all belong to that task.
- Create worktrees only with
wt. Do not useclaude -worcodex --worktreein this layout.claude -wputs its worktrees under.bare/.claude/worktrees/on branches namedworktree-<name>, and mixed tools scatter worktrees across the disk. - Commit the shared repository rules in
AGENTS.md, which is not specific to Codex. Keep Claude Code settings in.claude/. - No feature work in
main/. It is the reference copy that agents read and diff against. - Each worktree runs its own sbt server, so N active worktrees means N JVMs. Run
sbtn shutdownwhen a task goes idle.
Cleanup
git fetch origin
wt remove me/feat-retry # refuses if dirty; deletes the branch only if it is merged
wt remove -D me/feat-retry # also deletes an unmerged branch
git worktree prune # forgets worktrees whose directories you deleted by hand
wt remove recognizes squash merges. In my test it deleted a squash-merged branch after
other commits landed on origin/main, and it kept a branch with unmerged changes. It
compares against origin/main, so fetch first.
What I would check first
If you already have a bare-repo setup from an older recipe, check these four things:
git config --get-all remote.origin.fetch # want +refs/heads/*:refs/remotes/origin/*
git for-each-ref refs/heads | wc -l # dozens = copied branches never cleaned up
git worktree list # 0000000 = the worktree's branch is gone
ls ~/.claude/projects | grep my-project # two entries = split memory
| Finding | Fix |
|---|---|
refspec +refs/heads/*:refs/heads/*, or no refspec |
Set +refs/heads/*:refs/remotes/origin/*, then git fetch |
local branches that match their origin/* copy |
Compare the SHAs, then delete the local branches |
a worktree that shows 0000000 |
Compare its index with the merged PR head, then git worktree remove --force |
two .claude/projects entries |
Merge them into one memory/ directory and set autoMemoryDirectory |
Before you delete anything, save .bare/config and the output of git for-each-ref. If you
want to keep a local-only branch, make it a tag: git tag -a archive/<name> <branch>.
The result: several agents work in one repo at the same time. Each one starts from the
latest origin/main, builds its own code, and shares one memory with the others.