Two branches, two stacks, zero collisions: the bare repo + worktrees workspace
Since I started handing volume work to agents, I have a problem I didn’t have before: I never have just one branch in flight. There are two or three. One where an agent is finishing a feature, one where I’m debugging, one where I’m testing an idea I’ll throw away.
With a normal git clone, that doesn’t work. You stash, you checkout, the Docker stack restarts, dependencies reinstall, and ten minutes are gone before you’ve typed a line. So you don’t do it — you wait for the current branch to finish. And the agents’ parallelism becomes worthless, because I’m the bottleneck.
On a recent project I changed the layout. Here’s what it looks like.
The idea: a bare repo, one worktree per branch
The checkout isn’t a clone. It’s a workspace holding a bare repository plus one working directory per branch:
workspace/
├── .bare/ # the git directory: every branch, no files
├── .git # file: "gitdir: ./.bare"
└── worktrees/
├── feat-import/ # a full checkout of that branch
└── fix-auth/ # another one, beside it, at the same time
git worktree is not new — it’s been in git since 2015. What changes is treating it as the default mode rather than a workaround. Each branch gets its own directory, its own node_modules, its own venv. You never checkout again: you cd.
The real friction: shared state
The trap isn’t git, it’s everything that must not be duplicated. On any serious project, a freshly created worktree is useless until it has:
- secrets (
.env, and the ones belonging to sub-projects), - signing keys,
- the dev data corpus — often large, sometimes impossible to regenerate,
- runtime output you want to inspect across branches.
Copying those into every worktree means duplicating gigabytes and guaranteeing they drift. Regenerating them each time means paying back the setup cost you were trying to remove.
The answer is a shared overlay: those paths are stored once at the workspace root, and every worktree gets a symlink at the identical relative path.
An init script lays the links down, and a status command shows what’s linked, local, or broken:
worktree-init: ## Bootstrap this worktree: shared links, ports, dependencies
@echo "▸ linking shared state"
@./ops/link-shared.sh "$(SHARED)" "$(ROOT)" $(SHARED_PATHS)
@echo "▸ allocating free ports"
@./ops/alloc-ports.sh > "$(ROOT)/.ports.mk"
@echo "▸ installing dependencies"
@$(MAKE) install Not everything is shared, deliberately. Local configuration stays per worktree — it’s usually the very thing the branch is changing.
Running two stacks at once
Sharing files isn’t enough: two stacks starting on the same port will fail. So each worktree needs:
- a free port set, allocated at init and written to a gitignored file,
- its own Docker Compose project name, so
upanddownonly touch its own stack.
Without the second one, a make down in one worktree kills the neighbour’s stack — and it takes a while to work out why the agent next door suddenly started failing.
A status command printing branch, links, ports and Compose identity repays the time it takes to write. It’s what you read before wondering why nothing responds.
The trap I didn’t see coming
Python virtual environments are not relocatable. Scripts installed in .venv/bin carry an absolute shebang. Move or recreate the worktree somewhere else and pytest still points at the old path — with errors that look unrelated to the move.
The fix is trivial once diagnosed: detect that the shebang no longer matches, and recreate the venv.
venv-check: ## Recreate the venv if it was built at another path
@venv="$(ROOT)/backend/.venv"; \
if [ -f "$$venv/bin/pytest" ] && ! head -1 "$$venv/bin/pytest" | grep -qF "$$venv"; then \
echo "▸ venv built elsewhere — recreating."; \
cd "$(ROOT)/backend" && uv venv --clear; \
fi What it costs
I’m not going to sell this as free.
It’s machinery to maintain. A linking script, a port script, two Make targets, and a list of shared paths to keep current. The day a new directory needs sharing and nobody declares it, the symptom is indirect: it works in one worktree, not the other.
It assumes a project that deserves it. On a static site this is absurd — git checkout costs a second. The layout only pays off when starting the environment is expensive: a multi-service Docker stack, heavy dependencies, large dev data.
Newcomers pay an entry fee. Everyone knows how to clone. Here you have to read the README before typing anything, and git worktree add isn’t enough on its own — the init has to run too.
What it gave me back
The real win isn’t switching speed. It’s that I can let things run. An agent works on one branch while I review another, each with its own stack, neither breaking the other’s environment.
The question “do I start this agent now, or wait until I’m done?” used to come up every single time. It doesn’t any more. That’s the kind of friction you stop measuring because you stopped feeling it: you simply got used to not parallelising.
If you delegate work to agents and catch yourself waiting for one branch to finish before starting another, the bottleneck probably isn’t the model.