We run up to four AI coding agents against the same repository at the same time. One drafts a feature, another fixes a bug, a third writes marketing copy, a fourth churns through a backlog queue. For a while we ran them all in the same folder — the same git checkout — and it bit us in a way that took longer to untangle than the features were worth.

This is the setup we landed on, the failure that forced it, and the half-dozen sharp edges you only discover once the agents are actually running in parallel.

The failure, exactly

Two agents are working in the same checkout. Agent A stages and commits its three files. A few seconds later Agent B runs git commit — and because git's staging index is shared across everything in one checkout, B's commit swept in A's staged files too, under B's message. The doc-rename A had queued landed inside B's unrelated commit.

Nobody lost work. But the history no longer described what happened, and reconstructing "who actually changed what" cost more than the original task.

This isn't an AI problem. Two humans in one checkout collide the same way. But agents make it likely rather than rare, because they commit constantly and — unlike a colleague reaching across your keyboard — they don't notice another writer.

The reflex fix is "just commit by path, only your own files." That works until it doesn't. The real fix is to stop sharing the desk.

One worktree per agent

git worktree gives one repository several working directories, each on its own branch, each with its own index, all backed by the same .git. It's been in git since 2.5 (2015) and it's exactly the right tool.

# from your worktrees folder, off the latest integration branch:
git -C ../niptao worktree add "$PWD/feature-a" -b feature-a origin/dev

Now Agent A lives in feature-a/, Agent B in feature-b/. Separate directories, separate indexes. A's git commit physically cannot touch B's staged files. One folder, one agent, one branch — that rule makes the collision impossible rather than merely unlikely.

That's the headline. Everything below is the plumbing that makes it survivable day to day.

Catch #1: one canonical copy of your untracked state, symlinked everywhere

Worktrees share tracked files through git. But gitignored, untracked files are per-directory — each worktree gets its own. That's a footgun and a feature, and the trick is knowing which files want which treatment.

We split everything an agent touches into two piles.

Shared — one canonical copy in the main checkout, symlinked into each worktree. Our worktree-reset.sh links these on every reset:

SHARED=( .env .local workspaces .claude/settings.local.json )
for rel in "${SHARED[@]}"; do
  ln -s "$CANON/$rel" "$WORKTREE/$rel"
done
  • .env — secrets and config. One copy, or four copies drift apart the first time you rotate a key.
  • workspaces — the actual client data the product operates on. Every agent must see the same canonical data, not a per-branch fork of it.
  • .local — this one was the quiet realization. It started as a scratch dir and became the shared operational surface: generated task-board snapshots, the changelog views we regenerate from git, build logs, throwaway output. Because it's symlinked, an agent in one worktree regenerates the board and an agent in another reads the fresh copy — no per-worktree staleness. (The one cost: two agents regenerating the same file at the same instant can tear it, so the writers take a small lock — but a symlinked shared surface is still the right shape.)
  • .claude/settings.local.json — the agent's own permission allowlist. Shared so you configure trust once, not per worktree.

Never shared — build state, per worktree. .venv, node_modules, .wrangler, __pycache__. Different branches carry different dependencies, so these must be local; symlinking them is how you get an import error that only reproduces in one worktree. The rule we landed on: share config and data, isolate the build.

Catch #2: a reused branch name carries the last feature's baggage

Our first version gave each worktree slot one stable branch name — feature-a always lived on a branch called feature-a. That turned out to be a slow-acting poison. Every new feature inherited the previous one's life:

  • a stale base (you branched off wherever the last feature ended, not the latest trunk), which produced needless merge conflicts and — we're a Django shop — migration-number collisions;
  • a "gone" upstream once the previous PR merged and the remote branch was deleted.

The fix is to never reuse a branch name. Our worktree-reset.sh mints a fresh auto-numbered branch on each new feature — feature-a-7, feature-a-8 — always at the latest origin/dev:

# pick the smallest unused N across local AND remote <slot>-N branches
n=1
for ref in $(git for-each-ref --format='%(refname:short)' \
               "refs/heads/$SLOT-*" "refs/remotes/origin/$SLOT-*"); do
  suffix="${ref##*-}"
  case "$suffix" in ''|*[!0-9]*) continue ;; esac
  [ "$suffix" -ge "$n" ] && n=$((suffix + 1))
done
git switch --no-track -C "$SLOT-$n" origin/dev

Two details that matter more than they look:

  • --no-track is load-bearing. Without it the new branch tracks dev, and a bare git push from an agent targets dev directly instead of the feature branch. The feature branch gets its own upstream on the first explicit git push -u.
  • A fresh branch each time means an earlier feature's PR can stay open while the next one starts. More PRs in flight, no divergence, and correct migration ordering because the trunk already carries every merged migration.

One script does the whole start-of-feature dance: fetch the latest trunk, mint the branch, relink the shared symlinks, install deps. And because "starting fresh" implies "the last thing is finished," the same script sweeps every branch — local and remote — already merged into the trunk. git branch -d refuses unmerged branches and branches checked out in another worktree, so the sweep can't eat live work.

Catch #3: how the test suite got fast, and what fast broke

This is three realizations in sequence — each fix exposed the next problem.

Realization 1: the tests were slow because every run paid for a cold build. A single-process suite that replays the full migration history (~130 migrations for us) on every invocation spends almost all its wall-clock waiting — on the database, on I/O, on the migration replay — and almost none on CPU. That's the signature of a suite begging to be parallelized.

So we did two things. --keepdb keeps the test database between runs, skipping the migration replay that dominates a cold start. And --parallel auto forks one worker process per core. Because the suite is almost entirely DB/IO wait, the speedup is near-linear — the single biggest win on a multi-core dev machine. (CI still runs cold and serial: the authoritative gate must test a clean build every time, never reuse state, never hide a parallel-only flake.)

Realization 2: parallel made it fast and immediately introduced races. The symptom was maddening — a run that reported "0 tests collected" and exited green. Two causes, both shared state:

  • the forked workers raced on a single test database, one tearing down what another was creating;
  • across worktrees, two suites running at once raced on the same shared scratch filesystem, clobbering each other's working files.

Realization 3: the fix for a race is to stop sharing — give each worker and each worktree its own. Two separations, each killing one race:

# 1. Per-worktree DB name — two worktrees can't collide on one database.
_worktree_slug = BASE_DIR.parent.name.replace('-', '_')   # feature-a → feature_a
DATABASES['default'].setdefault('TEST', {})['NAME'] = (
    f'test_{db}' if _worktree_slug == db else f'test_{db}_{_worktree_slug}'
)

# 2. Per-process scratch root — forked workers can't collide on the filesystem.
WORKSPACE_ROOT = Path(tempfile.gettempdir()) / 'niptao-tests' / f'ws-{os.getpid()}' / 'workspaces'

The runner already gives each worker its own DB clone (..._1, ..._2); the per-worktree name stops different worktrees colliding on the base. Two axes of isolation, two classes of race gone.

The sting in the tail: keepdb + parallel cache the clones too — and never re-migrate them. The runner applies a new migration to the kept base DB, but reuses the per-worker clones as-is. So a fresh migration leaves every clone stale and the whole run dies with "undefined column" — it bit us twice, on two different schema changes, before we understood it. The fix: drop the clones before each run and let the runner re-clone from the freshly migrated base. CREATE DATABASE ... TEMPLATE is ~100ms per worker, while the expensive migration replay stays cached in the base. Fast and correct.

The throughline of all three: every race we hit was shared state, and every fix was the same move — give each worker, each worktree, its own copy. Slow → parallel → isolated. You can't skip the middle step; parallelism is what forces the isolation you should have had anyway.

The flow on top: orient → cleanup → release

Isolation is the foundation; a thin set of commands keeps four parallel streams from turning into chaos at the edges.

  • A trunk-based branch model. dev is the integration trunk — feature branches PR into it and do not deploy. main is the prod mirror; it only advances via a deliberate dev→main PR, and that merge is the deploy. So main always equals exactly what's in production.
  • An end-of-session "cleanup" that commits what's outstanding, syncs and pushes, opens (or updates) the feature→dev PR, and closes out the session's tickets. It trusts the commit-as-you-go discipline and lets CI run the full suite on the PR.
  • A heavyweight "release" that runs only in the canonical checkout on dev — never a worktree, because the version bump is a commit on dev. It absorbs any hotfixes (a main → dev back-merge), bumps the version, regenerates the changelog from commit messages, and opens the deploying PR.

A few invariants worth stealing:

  • A worktree may never sit on main/dev — those are long-lived; you branch off them, you don't work on them in a worktree. The scripts refuse.
  • The changelog is generated from commit messages, not hand-edited. With four agents committing, a hand-maintained changelog is a merge-conflict generator. Conventional commits (feat:, fix:, …) plus a generator means there's nothing to conflict on.
  • release runs in the canonical repo, cleanup runs in the worktree. Keeping the deploying action out of the parallel worktrees removes a whole class of "which checkout am I in" mistakes.

What it costs, and when it's worth it

The cost is real but small: each worktree is a full checkout plus its own dependency install, so a handful of them is a few gigabytes — mostly virtualenvs. That's cheap insurance against a corrupted history and a day spent reconstructing it.

You don't need this for one agent. You need it the moment you have two writers in one repo — two agents, or an agent plus you. The symptoms that say you've crossed the line: commits containing files you didn't change; a git log that "looks strange"; tests that intermittently collect zero cases; migration numbers that collide on every other branch.

Every one of those traces back to shared state — a shared index, a shared test DB, a shared scratch dir. The fix is the same each time: give each agent its own. One agent, one worktree, one branch, one database. Make the collision structurally impossible instead of asking everyone to be careful.