End-to-End Testing across Git Worktrees

As many developers, I am running several AI coding agents (Claude Code sessions in my case) in parallel on the same repository, each in its own git worktree. Git handles the code isolation beautifully: every worktree has its own files, its own branch, its own uncommitted changes. The breakdown comes at test time. My project’s end-to-end tests spin up a complete stack — a Solr index, a Spring Boot backend, a React frontend and a Nextflow data-load pipeline — and the moment two worktrees tried to test at the same time, they collided over ports, data directories and indexes. One agent’s test run wiped the index another agent was querying.

For those of you who have not solved this problem yet, I in this post give a step-by-step guide to fixing that: making every worktree able to run a complete, isolated end-to-end test stack on demand. The worked example is OxO2, the SSSOM-compliant ontology mapping service I work on at EMBL-EBI, but the recipe transfers to any repo — my Solr could be your Postgres, Neo4j or Redis.

The problem: git isolates code, not runtime

git worktree gives each checkout its own files. Ports, data directories, PID files and log directories, however, live on the machine, shared by everything running on it. Two Solr instances started with default settings both want port 8983, the same home directory and the same index files. This is not misuse — it is the designed behaviour. Git solves the code half; the runtime half is yours to solve.

The fix rests on one idea:

Derive every port and every state directory from a single per-worktree number.

Give the main checkout index 0 and each worktree the next free integer. Everything else — ports, data directories, config choice, service URLs — is computed from that index by one shared script. No registry, no negotiation, no remembering. Any terminal, and crucially any AI agent session, can compute the whole environment from the index.

The rest of this post builds that up in eight steps.

Step 1 — Inventory what your stack shares

Before writing any scripts, list every machine-global resource your stack and your tests touch. For OxO2 the list looks like this:

ResourceDefaultCollision symptom
Frontend dev server port8080“address already in use”
Backend port8081“address already in use”
Solr port8983second instance fails to start
Solr stop port7983 (port − 1000)mysterious failures with “different” ports
Test Solr port8984e2e suites collide
Data directory$OXO2_DATAone run overwrites another’s pipeline output
Solr home (cores + index)$SOLR_HOMEparallel end-to-end tests corrupts index
Test data / test Solr home*_TEST dirsintegration tests trample each other
Pipeline (Nextflow) work dir$NEXTFLOW_DIRinterleaved intermediate files
Solr logs directoryinstall defaultinterleaved, undebuggable logs

Two entries deserve emphasis, because they are the ones that have bitten me:

  • A service can claim more ports than you configured. Every Solr instance also binds a stop port at SOLR_PORT − 1000 and, with JMX enabled, SOLR_PORT + 10000. Two Solrs on “different” ports 8983 and 7983 still collide: the first’s stop port is the second’s listen port.
  • A service’s state is several directories, not one. For Solr that is its home (cores, configs, index), its PID directory (which bin/solr status and stop consult) and its logs directory. Share any one of them between two instances and they are entangled.

Step 2 — Make every resource configurable through the environment

Every item on the inventory must be settable per checkout, with the old value as the default so existing setups keep working. In practice this means touching each service once, in whichever way that service supports:

  • Native environment variable. Solr’s bin/solr reads SOLR_PORT and SOLR_HOME natively — nothing to change.
  • Build-tool config reads the environment. OxO2’s vite.config.ts gained one line: port: Number(process.env.OXO_FRONTEND_PORT) || 8080,
  • Launcher script forwards the variable. OxO2’s startBackend.sh forwards OXO2_BACKEND_PORT into Spring Boot as -Dserver.port=…, only when the variable is set, so the default stays untouched.
  • Pipelines key off a URL. The data-load pipeline reads SOLR_URL and derives the port to start/stop its managed Solr from it, so pointing the URL at the right port is enough.

This step is the actual work of the whole exercise, and it is also where an AI agent can help you: ask it to find every hard-coded port and path in the repo and thread an environment variable through each one.

Step 3 — Choose a directory layout

I keep the main checkout, the worktrees and their runtime state as siblings under one project root, with the state outside the worktrees:

oxo2/ # project root (not itself a git repo)
├── oxo2/ # main checkout — index 0
├── worktrees/
│ ├── fix-ranking/ # worktree — claims index 1
│ └── cleanup-facets/ # worktree — claims index 2
├── worktree-state/
│ ├── fix-ranking/ # that worktree's private runtime state
│ │ ├── data/ test-data/
│ │ ├── solr-data/ test-solr-data/
│ │ ├── nextflow/ solr-logs/
│ └── cleanup-facets/ …
├── data/ solr-data/ … # full-corpus state, main checkout only
└── oxo2-env.sh # the one shared environment script

Why keep state outside the worktree? Index and pipeline state can be large, it slows git’s untracked-file scanning, and — most importantly — git worktree remove refuses a dirty tree. Keeping state in a sibling directory keyed by the worktree’s name makes removing the code and removing the state two separate, deliberate acts.

Note the asymmetry: the full production corpus (for OxO2: 134 GB of data plus a 38 GB Solr index) exists once, in the main checkout’s shared directories. Worktree loads are test-scale by design — that is Step 6.

Step 4 — One environment script, one number

Now the heart of it: a single script at the project root, outside the repo, that every checkout sources. It does four things — identify the checkout, claim an index, derive ports, derive state directories. Here is the skeleton, condensed from OxO2’s oxo2-env.sh:

#!/bin/bash
PROJECT_ROOT=/home/you/projects/myproject
MAIN_CHECKOUT=$PROJECT_ROOT/myproject
# --- 1. Identify the checkout this shell is in ---------------------------
# Only a checkout of *this* repo counts: its git-common-dir resolves to the
# main checkout's .git. Sourced anywhere else, fall back to main defaults.
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
common_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
if [ "$common_dir" != "$MAIN_CHECKOUT/.git" ]; then
repo_root=$MAIN_CHECKOUT
fi
# --- 2. Claim a worktree index -------------------------------------------
# Main checkout = 0. A new worktree scans its siblings' claimed indices
# (via git worktree list) and persists the lowest free one in a
# gitignored .worktree-index file.
if [ "$repo_root" = "$MAIN_CHECKOUT" ]; then
WORKTREE_INDEX=0
else
if [ ! -f "$repo_root/.worktree-index" ]; then
taken=$(git -C "$repo_root" worktree list --porcelain \
| sed -n 's/^worktree //p' \
| while IFS= read -r wt; do
[ -f "$wt/.worktree-index" ] && cat "$wt/.worktree-index"
done)
candidate=1
while printf '%s\n' "$taken" | grep -qx "$candidate"; do
candidate=$((candidate + 1))
done
echo "$candidate" > "$repo_root/.worktree-index"
fi
WORKTREE_INDEX=$(cat "$repo_root/.worktree-index")
fi
export WORKTREE_INDEX
# --- 3. Derive the port block --------------------------------------------
if [ "$WORKTREE_INDEX" -eq 0 ]; then
FRONTEND_PORT=8080; BACKEND_PORT=8081; SOLR_PORT=8983; SOLR_PORT_TEST=8984
else
base=$((20000 + WORKTREE_INDEX * 100))
FRONTEND_PORT=$base
BACKEND_PORT=$((base + 10))
SOLR_PORT=$((base + 83))
SOLR_PORT_TEST=$((base + 84))
fi
export FRONTEND_PORT BACKEND_PORT SOLR_PORT SOLR_PORT_TEST
# --- 4. Derive state directories and config ------------------------------
if [ "$WORKTREE_INDEX" -eq 0 ]; then
export APP_DATA=$PROJECT_ROOT/data
export SOLR_HOME=$PROJECT_ROOT/solr-data
export APP_CONFIG=$MAIN_CHECKOUT/config.json # full corpus
else
state=$PROJECT_ROOT/worktree-state/$(basename "$repo_root")
export APP_DATA=$state/data
export SOLR_HOME=$state/solr-data
export SOLR_LOGS_DIR=$state/solr-logs
mkdir -p "$APP_DATA" "$SOLR_HOME" "$SOLR_LOGS_DIR"
export APP_CONFIG=$repo_root/config-test.json # test-scale
fi
# --- 5. Service URLs derive from the ports -------------------------------
export SOLR_URL=http://localhost:$SOLR_PORT/solr
export BACKEND_URL=http://localhost:$BACKEND_PORT

Two design decisions in the port scheme are load-bearing, and both come from traps I described in Step 1 plus one more:

  • Base 20000, spacing ×100. The spacing keeps each instance’s whole trio of ports (listen, stop at −1000, JMX at +10000) clear of every other instance’s trio. The base keeps everything the scheme can produce (roughly 19xxx–30xxx) above the privileged range (0–1023) and below 32768 — because Linux hands out ports 32768–60999 to outgoing connections (the ephemeral range), and a fixed dev port in that range works for weeks and then fails with “address already in use” when some outgoing connection happened to borrow it first. Advice you will find online to base your scheme at 40000 is subtly wrong for exactly this reason.
  • Index 0 keeps the legacy ports. My main OxO2 behaves exactly as it did before any of this existed, so nothing about my primary development flow changes.

The index-claiming logic deserves a note too: because the first source from a new worktree claims the lowest free index automatically and persists it in a gitignored .worktree-index file, there is no manual bookkeeping — and no way for a freshly copied environment file to silently reuse the donor worktree’s ports, which is the classic failure of copy-the-.env-around schemes.

Step 5 — Load it automatically with direnv

Each checkout gets a one-line, gitignored .envrc:

source /home/you/projects/myproject/myproject-env.sh

With direnv hooked into your shell, that file loads when you cd into the worktree and unloads when you leave. Two properties make direnv exactly right for this job:

  • Every session agrees. Any terminal — and any AI agent session started in that worktree — gets the same ports and paths without remembering to source anything.
  • The direnv allow gate. direnv refuses to run a new or changed .envrc until you approve it. A fresh worktree’s environment therefore cannot load “accidentally”; approving it is the natural moment at which the worktree claims its index.

Add .envrc and .worktree-index to .gitignore. If you use Claude Code’s claude --worktree, you can list .envrc in .worktreeinclude so new worktrees arrive with the file already in place — since ours is one identical line everywhere, it can be copied verbatim safely.

Step 6 — Give worktrees a test-scale dataset that travels

An isolated stack is useless if loading the data takes hours and hundreds of gigabytes. The full OxO2 corpus — the whole of OLS plus every Mapping Commons registry — loads once, into the main checkout’s shared directories. Worktrees default to a different, committed config that loads a deliberately small dataset.

Getting that config to travel took one real change. Our first test config referenced fixture files by absolute path (file:///home/<user>/…) — machine-specific paths outside the repo, so the config worked in exactly one place. The fix was to commit trimmed fixture slices into the repo and get the data-load’s downloader to resolve a repo-relative path in the config against the config file’s own directory. Now every worktree — and CI — resolves the same fixtures.

The trimming is worth copying: each fixture keeps semantically important data. A small script regenerates the data from a full export whenever they need refreshing.

The principle I wanted to apply here is: a worktree dataload should be small enough to run quickly and real enough to catch real bugs, and its fixtures must live in the repo so every checkout resolves them identically.

Step 7 — Keep the test stack separate from the dev stack

There is a second collision axis I have encountered: within OxO2 we have integration tests that runs against Solr. These tests are somewhere between unit tests and end-to-end tests. Unlike end-to-end tests they test specific aspects of the dataload. OxO2’s integration tests therefore run against their own environment contract — OXO2_DATA_TEST, SOLR_HOME_TEST, OXO2_SOLR_HOST_TEST — never against the Solr used for end-to-end testing. The harness:

  • fails fast if any *_TEST variable is missing or blank, rather than falling back to the production values (a fallback here would be a data-destroying bug);
  • parses the test Solr port out of OXO2_SOLR_HOST_TEST and starts/stops an isolated Solr on it,
    once per suite;
  • injects the *_TEST values into the pipeline subprocess as the plain variables the pipeline
    expects, so the production pipeline code runs unmodified against the test workspace.

Notice how the two schemes compose: the env script derives both SOLR_PORT and SOLR_PORT_TEST from the worktree index. Worktree 1 end-to-end tests run on 20183 while its integration tests runs on 20184; worktree 2 uses 20283 and 20284. Every combination of (worktree, dev-vs-test) has its own port and its own state directory, so n agents can each run the full end-to-end suite simultaneously, while their dev stacks stay up.

Step 8 — The daily workflow

With all the pieces in place, here is the entire ritual for a new parallel workstream:

cd ~/projects/myproject/myproject # main checkout
git worktree add ../worktrees/fix-ranking -b fix-ranking
cd ../worktrees/fix-ranking
echo 'source /home/you/projects/myproject/myproject-env.sh' > .envrc
direnv allow # claims the next free index
# build, then run the integration tests — fully isolated
mvn clean install -DskipTests
mvn -pl myproject-integration-tests -am verify
# or bring up the dev stack for interactive work
./loadData.nextflow && ./startBackend.sh &
cd frontend && npm install && npm run dev

Then hand the worktree to an agent — every command it runs inherits the right ports and paths from direnv. Teardown is simply:

$SOLR_SCRIPT/solr stop # solr determines $STOP_PORT from $SOLR_PORT-1000
cd ../.. && git worktree remove worktrees/fix-ranking
rm -rf worktree-state/fix-ranking # state removal is deliberate
git worktree prune

When something collides …

Diagnostic help for the three failures you will still occasionally hit:

  • “Address already in use”ss -ltnp lists listening ports with owning processes;
    lsof -i :20183 names the culprit for one port. Also check the whole block (20100–20199) with ss -ltnp '( sport >= :20100 and sport <= :20199 )'.
  • $SOLR_SCRIPT/solr status shows the wrong instancestatus and stop find instances via PID files in SOLR_PID_DIR, which defaults to the install’s bin/ directory, shared by every instance started from that install. Per-worktree PID (and logs) directories fix this.
  • Stale worktree metadata — after deleting a worktree directory manually, git worktree list still shows it; git worktree prune cleans up.

What this bought me

The setup of this was reasonably simple as I already had the shell script I could direct Claude to reuse and set this up. The changes to the data loader and fixture took a bit of time to finetune and test. Disk cost per worktree is trivial because the full dataset exists once and worktrees load small test datasets.

The value I get from this is that I can run multiple AI agents in parallel, each on its own worktree with its own end-to-end test environment. As the complete environment is setup automatically for each worktree, I do not have to remember to assign ports and directories etc. Whenever I forget which ports are applicable to my current worktree direnv show_dump "$DIRENV_DIFF" gives a quick view on the relevant environment variables.

I hope this was useful. Or, if you already have a system in place for end-to-end testing across worktrees, let me know your approach in the comments.