./ahmedhashim

Agent Worktrees

Coding agents have changed the way I work. I delegate a few scoped tasks and spend my attention on what comes back. The problem is that a git clone presupposes one person: one working tree, one branch checked out. Throw two agents into the same clone and they’ll clobber each other’s changes. Luckily, Git already solved this back in 2015, long before coding agents were common.

Worktrees

git worktree is how Git lets you attach additional working directories to an existing clone of a repository. Each working directory has its own checked-out branch and its own copy of files on disk, but history and branches are shared with the original clone. Adding one costs roughly as much as performing a checkout:

git worktree add -b fix-upload-limits /tmp/agents/critter/fix-upload-limits

This makes a new branch, fix-upload-limits, and checks it out into a new directory under /tmp. The original clone stays where it is. Commits made from within the worktree show up there without a push or pull.

Prior to worktrees, if you wanted to work on two branches at once, you needed a second clone that only stayed in sync through the remote. A worktree can’t get out of sync, because it’s part of the same repo. Git also won’t let you check out the same branch in two separate worktrees. Normally this seems like a limitation, but with multiple agents in a single repo, it ensures that two agents can never be on the same branch.

The setup

My repos live under ~/Workspace, checked out on whatever branch origin/HEAD is pointing at, and git sync keeps them up to date. I never edit those copies directly. They’re my clean base for every task.

Instead, I work in /tmp. A task is one self-contained unit of work. Each one gets a branch, and each branch gets a worktree:

cd ~/Workspace/critter
git sync
git worktree add -b fix-upload-limits /tmp/agents/critter/fix-upload-limits

I spawn an agent within that directory and give it the task. Then I do the same for the next one. Each agent has its own branch with a full copy of the repo checked out. Agents build and test in parallel and never modify each other’s files. Branches are shared with my main clone of the repo, so I can follow any agent’s commits with git log from ~/Workspace while it runs.

Once a branch is ready, it gets pushed and treated like any other pull request. After the merge, running git sync back in ~/Workspace pulls the new default branch and deletes the one that was merged.

Ephemeral

The downside is space. Each worktree is a complete checkout, and each agent piles more on top of that (dependencies, build output, etc.). I don’t prune any of it, either. /tmp on my machine is a tmpfs, which means it lives in memory and is erased on every reboot.

I’ve grown to appreciate what that forces. Anything valuable gets committed and pushed early, because its directory won’t survive the weekend. And since nothing in /tmp lasts, there’s no environment to maintain or migrate.

Two things persist across the reboot, both in the main clone. Git maintains records of worktrees whose directories no longer exist, and the branches themselves persist too, because branches are part of the repo rather than a worktree. Git refuses to delete a branch that it believes is checked out, even if its directory no longer exists, so git sync runs git worktree prune to clean up those records before deleting the merged branches.

Build caches

Any language with slow compiles tempts you into a shared build cache, and on my machine that means Rust. Cargo builds are slow enough that every checkout of a project points at the same target directory, so a fresh checkout reuses whatever something else already compiled:

# ~/.cargo/config.toml
[build]
target-dir = "/var/cache/cargo-target"

For a single human working on a single branch, a shared cache is an unmitigated good. For multiple agents on multiple branches, it can break in two ways.

The first is contention. Cargo takes an exclusive lock on the target directory, so concurrent builds can only happen one at a time. If an agent looks hung, it’s probably waiting on another agent’s compile, stuck on Blocking waiting for file lock on build directory.

The second is churn. Parts of the cache belong to whichever branch last triggered a build. Dependencies stay warm as long as their versions and features match across branches, but crates from the repo itself always get rebuilt when you switch branches, and debug/ always contains whatever binary most recently finished building. So if you have two agents running builds on different branches, they may spend all afternoon redoing each other’s work.

The fix is to keep the shared cache for human use and give each agent its own private one. Since Cargo’s environment variables take precedence over its config files, this only takes one line when the agent starts:

export CARGO_TARGET_DIR="$PWD/target"

The first build in each worktree runs cold. Everything after that is isolated, and now that the target directory lives inside the worktree, it gets cleaned up with everything else in /tmp. My rule of thumb for any cache you want to share: if its entries are keyed by content, like sccache, then it’s okay to share between branches. If it just holds whatever you built last, it needs a single owner.

The bottleneck

There’s definitely a speedup on the coding side. Instead of running tasks one after another, I have many going at once, and an afternoon can produce more branches than a week of coding everything by hand.

Review still happens at the old rate. I read every branch before it merges, as if it were a colleague’s, because merging code nobody understands is how a codebase decays. The same habits from Effective Code Review apply. Keeping tasks small helps agents finish reliably and keeps each diff small enough to reason about in my head.

So the bottleneck moved from writing code to understanding it, which I imagine is where it should have been all along. Worktrees had languished in the man pages for a decade before agents gave me a real reason to use them.