TRENDING
A vintage glass syringe with a metal plunger and orange measurement marks lying on a plain white background
November 9, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
A green valet key and a black ignition key with a remote fob on a white background, the brand emblems blurred
November 9, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
Sepia 1900 postcard photograph of two riders on horses galloping neck and neck at the front of a field on a Hannover racecourse, with the rest of the pack behind them
November 9, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected
Two yellow commuter trains stand side by side on separate tracks next to a platform in a railway yard, with more tracks running in parallel on both sides and trees in the background
November 9, 2026
How to Run Parallel AI Coding Agents With git worktree and Check Their Branches for Conflicts Before You Merge
Car speedometer dial with a red needle pointing at about 60 miles per hour, or about 100 kilometers per hour on the inner scale
November 9, 2026
METR’s Anthropic Report Turns AI-Led Acceleration Into a 1.9x Slope Ratio That Reproduces From Its Charts, but the Ratio Runs From 1.1x to 2.9x With the Fit Function, and a Second METR Report Says Anthropic’s Public Risk Report Understated the Pace
09 Nov 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Tall orange and white container cranes rising against a clear blue sky at a container terminal
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
The Alcatraz lighthouse at night with beams of light sweeping across the sky above the ruined warden’s house
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Stepped concrete headquarters building of Crédit Mutuel Arkéa at Le Relecq-Kerhuon in Brittany, seen across a lawn with bare trees and flowering shrubs
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 290 Posts
News 294 Posts
Learning Hub 263 Posts
Home/Learning Hub/How to Run Parallel AI Coding Agents With git worktree and Check Their Branches for Conflicts Before You Merge
Learning Hub

How to Run Parallel AI Coding Agents With git worktree and Check Their Branches for Conflicts Before You Merge

Give each AI coding agent its own git worktree so parallel edits stop colliding, then use git merge-tree and a short Python gate to catch conflicts and clean merges that break the tests.

November 7, 2026 37 Min Read
16

Sooner or later you run two coding agents at once. One is adding coupon codes to a shop, another is changing the shipping rules, a third is renaming a function that everybody uses. Each agent edits files, runs the tests and makes commits, and each one assumes that the folder it works in belongs to it alone. If they share one folder, it does not. Edits from one agent turn up inside another agent’s commit, Git refuses to continue because two processes want the same lock, and afterwards nobody can say which change belonged to which task.

Table Of Content

  • The terms used in this tutorial
  • Prerequisites
  • Step 1: Check your tools
  • Step 2: Create the practice project
  • Step 3: See what goes wrong when two agents share one folder
  • Step 4: Give every agent its own worktree
  • Look inside a worktree
  • Step 5: Measure the collision, then rerun it in worktrees
  • Step 6: What a worktree costs on disk
  • Step 7: Let the four stand-in agents work
  • Step 8: Know what is shared and what is not
  • Step 9: Learn the safety rules and the cleanup trap
  • One branch, one folder
  • Remove worktrees with Git, not with the file manager
  • Step 10: Make every worktree test its own code
  • Step 11: Check for conflicts before merging with git merge-tree
  • Step 12: Merge in a scratch worktree and run the tests
  • Step 13: Automate the checks with a gate script
  • Test the gate itself
  • Step 14: Merge for real and clean up
  • Common mistakes and how to avoid them
  • Check that everything works end to end
  • Where AI agent tools fit in
  • Next steps
  • Sources

Git has a built-in answer: git worktree. This tutorial teaches it from the beginning. You will build a small practice project, watch four stand-in agents collide in a single folder, and then give every agent its own worktree, a separate folder with its own files and its own branch that still shares one repository history with all the others. You will measure what that costs and what it fixes, learn which parts of Git are shared between worktrees and which are not, step around the traps that bite on day one, and finish with a Python script that checks every agent branch for conflicts before anything is merged, merges the branches in a scratch worktree, and runs the tests after each merge.

By the end you will be able to:

  • explain why two agents in one folder damage each other’s work, and measure the damage;
  • create, list, inspect and remove linked worktrees, and say why deleting a worktree folder by hand is a mistake;
  • name what worktrees share (branches, tags, configuration, the stash) and what they keep apart (HEAD, the index, bisect state, untracked files);
  • avoid the silent wrong-folder trap, where a shared Python environment imports the main checkout instead of the worktree you are testing;
  • ask git merge-tree whether two branches would conflict without touching any working folder;
  • catch the merge that Git accepts cleanly but that breaks the tests;
  • automate those checks in a short script that has its own tests.

Everything below ran on Windows 11 with Git for Windows 2.55.0 (Git Bash), Python 3.13.14 and pytest 9.1.1 on November 7, 2026. Every output block is copied from those runs, with my home folder shown as ~. Commit hashes and timings will differ on your machine. The long hash that git merge-tree prints first is a tree ID, which depends only on file contents, so yours should match mine if you type the files exactly. I did not run the steps on Linux or macOS. The one thing I expect to differ there is the path of the virtual environment’s Python, which the text points out.

The terms used in this tutorial

  • Repository. The project’s complete history, stored in the hidden .git folder.
  • Working tree (checkout). The ordinary folder of files you edit. A repository created with git init or git clone has one, which the Git manual calls the main worktree.
  • Branch. A movable name that points at a commit.
  • HEAD. The record of which branch (or commit) is checked out in a working tree.
  • Index (staging area). Where git add puts changes before a commit. There is one index per working tree.
  • Linked worktree. An extra working tree attached to the same repository with git worktree add. It has its own files, HEAD and index, and it shares the history, branches and tags with every other worktree of that repository.
  • Merge conflict. Two branches changed the same lines, so Git cannot decide which version to keep. A textual conflict is the kind Git reports. A semantic conflict is a merge Git accepts cleanly even though the result no longer works, for example when one branch renames a function that another branch has just started to call.
  • Stand-in agent. A short command that edits files and commits them, playing the part of an AI coding agent so that you need no account or API key to follow along.

Prerequisites

  • Git 2.38 or newer. The conflict check in Step 11 uses git merge-tree --write-tree, which arrived in 2.38. Its release notes say: “‘git merge-tree’ learned a new mode where it takes two commits and computes a tree that would result in the merge commit, if the histories leading to these two commits were to be merged.”
  • A POSIX shell. Terminal on macOS or Linux, or Git Bash on Windows. Do not use PowerShell for this tutorial; the commands use here-documents and perl, both of which Git Bash provides.
  • Python 3.10 or newer with pytest. Install pytest with python -m pip install pytest. If your system calls the interpreter python3, use that name everywhere below.
  • About 50 MB of free disk space. Step 6 reports measurements from a much bigger repository, but you do not need to repeat them.
  • Basic Git. You should know what git add, git commit and git branch do. Nothing else is assumed.

Step 1: Check your tools

Start by confirming the three versions. The first line must show Git 2.38 or newer; on Windows it ends in .windows.3 or similar, which is fine.

git --version
python --version
python -m pytest --version
git version 2.55.0.windows.3
Python 3.13.14
pytest 9.1.1

Step 2: Create the practice project

The practice project is a tiny shopping cart with a pricing function and tests. It is deliberately small, but it has the three ingredients every real project has: a few functions, a few tests, and code in more than one file. Everything lives in a folder called ~/worktree-lab, so you can delete it when you are done.

The first block creates the folder and the repository. Three details are worth a sentence each. The two export lines give Git an author name and address for this terminal session only, so your own Git configuration stays untouched. The .gitattributes line (* text=auto eol=lf) makes Git store and check out text files with Unix line endings on every operating system, which keeps your files, and later your merge results, identical to mine. And pythonpath = ["src"] in pyproject.toml tells pytest to import the project from the src folder of whichever folder it runs in. That line looks like housekeeping now, and it becomes important in Step 10.

export GIT_AUTHOR_NAME="Ada Example" GIT_AUTHOR_EMAIL="[email protected]"
export GIT_COMMITTER_NAME="Ada Example" GIT_COMMITTER_EMAIL="[email protected]"
mkdir -p ~/worktree-lab && cd ~/worktree-lab
git init -b main shop
cd shop
printf '* text=auto eol=lf\n' > .gitattributes
printf '__pycache__/\n.pytest_cache/\n*.egg-info/\n' > .gitignore
mkdir -p src/shop tests
touch src/shop/__init__.py
cat > pyproject.toml <<'PYEOF'
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "shop"
version = "0.1.0"
requires-python = ">=3.10"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
PYEOF
Initialized empty Git repository in ~/worktree-lab/shop/.git/

The second block adds the code and the tests, runs the tests, and makes the first commit. Prices are whole cents. A cart ships free from 50.00 and pays a flat 4.99 below that.

cat > src/shop/cart.py <<'PYEOF'
"""Cart arithmetic for the practice shop. Prices are in cents."""

FREE_SHIPPING_FROM = 5000
FLAT_SHIPPING = 499


def subtotal(items):
    """Sum of price * quantity for (price, quantity) pairs."""
    return sum(price * qty for price, qty in items)


def shipping(cents):
    """Shipping for a cart worth this many cents."""
    if cents >= FREE_SHIPPING_FROM:
        return 0
    return FLAT_SHIPPING


def total(items):
    """Goods plus shipping."""
    sub = subtotal(items)
    return sub + shipping(sub)
PYEOF
cat > tests/test_cart.py <<'PYEOF'
from shop.cart import shipping, subtotal, total


def test_subtotal():
    assert subtotal([(1000, 2), (250, 1)]) == 2250


def test_small_cart_pays_shipping():
    assert shipping(2250) == 499


def test_big_cart_ships_free():
    assert shipping(5000) == 0


def test_total_adds_shipping():
    assert total([(1000, 2), (250, 1)]) == 2749
PYEOF
python -m pytest -q
git add -A
git commit -q -m "Add cart arithmetic and tests"
git log --oneline
....                                                                     [100%]
4 passed in 0.03s
1d3ed5a Add cart arithmetic and tests

The repository now has one commit on main, and all four tests pass. That commit matters: a new worktree is created from an existing commit, so git worktree add cannot work in a repository that has no commit yet. The Claude Code documentation says the same about its worktree feature: “Each git worktree is a separate checkout on its own branch, created from an existing commit, so the repository needs at least one commit first.”

Step 3: See what goes wrong when two agents share one folder

Before fixing the problem, look at it. The block below clones the project into a scratch folder called shop-shared and plays out a very ordinary afternoon. Agent 1 starts on coupons and has written a file but not committed it. You then start agent 2 on a shipping change, in the same folder, on its own branch. Agent 2 does what many scripts and agents do when they finish: it stages everything with git add -A and commits. The last three lines create a lock file by hand to show a message that we will come to in a moment.

cd ~/worktree-lab
git clone -q shop shop-shared
cd shop-shared
# Agent 1 starts on coupons. It has written a file but has not committed it yet.
cat > src/shop/coupons.py <<'PYEOF'
"""Coupon codes (unfinished)."""
from shop.cart import subtotal
PYEOF
# You now start agent 2 on shipping, in the same folder, on its own branch.
git switch -c agent/free-shipping
perl -pi -e 's/^FREE_SHIPPING_FROM = 5000$/FREE_SHIPPING_FROM = 3500/' src/shop/cart.py
git add -A
git commit -q -m "Lower the free-shipping threshold to 35.00"
git show --stat --format=%s
# Pretend another Git process is in the middle of writing the index.
touch .git/index.lock
git add -A
rm .git/index.lock
Switched to a new branch 'agent/free-shipping'
Lower the free-shipping threshold to 35.00

 src/shop/cart.py    | 2 +-
 src/shop/coupons.py | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)
fatal: Unable to create '~/worktree-lab/shop-shared/.git/index.lock': File exists.

Another git process seems to be running in this repository, or the lock file may be stale

Look at the file list: the commit titled “Lower the free-shipping threshold to 35.00” contains src/shop/coupons.py, agent 1’s unfinished file. Git did exactly what it was told. git switch -c creates a branch at the current commit and moves to it, and uncommitted files come along because they live in the folder, not on a branch. Then git add -A staged every change in the folder, whoever made it. There is one folder, one HEAD and one index, so there is no way to tell the two agents’ work apart.

The second problem is the lock. When Git changes the index it first creates .git/index.lock, writes the new index into it, and then renames it over .git/index. If the lock file exists, another process may be halfway through writing, so Git stops and says so. In the demonstration we created the file ourselves to show the message. Two agents that run Git at the same moment in the same folder produce the same message for real, as you will measure in Step 5.

Step 4: Give every agent its own worktree

The Git manual describes the idea in its first sentence: “A git repository can support multiple working trees, allowing you to check out more than one branch at a time.” The command is git worktree add <path> -b <new-branch>. It creates the folder, creates the branch at the current commit, and checks the branch out into the folder.

Run it once per agent, from the main folder. We put the new folders next to the repository, as siblings, and not inside it. A worktree inside the main folder can show up as untracked content in the main folder’s git status; the Claude Code documentation tells you to add .claude/worktrees/ to your .gitignore “so worktree contents don’t appear as untracked files in your main checkout.” Each branch name starts with agent/ so that the agents’ branches are easy to list and to delete later.

cd ~/worktree-lab/shop
git worktree add ../shop-coupons -b agent/coupons
git worktree add ../shop-free-shipping -b agent/free-shipping
git worktree add ../shop-shipping-rates -b agent/shipping-rates
git worktree add ../shop-rename -b agent/rename-subtotal
git worktree list
Preparing worktree (new branch 'agent/coupons')
HEAD is now at 1d3ed5a Add cart arithmetic and tests
Preparing worktree (new branch 'agent/free-shipping')
HEAD is now at 1d3ed5a Add cart arithmetic and tests
Preparing worktree (new branch 'agent/shipping-rates')
HEAD is now at 1d3ed5a Add cart arithmetic and tests
Preparing worktree (new branch 'agent/rename-subtotal')
HEAD is now at 1d3ed5a Add cart arithmetic and tests
~/worktree-lab/shop                1d3ed5a [main]
~/worktree-lab/shop-coupons        1d3ed5a [agent/coupons]
~/worktree-lab/shop-free-shipping  1d3ed5a [agent/free-shipping]
~/worktree-lab/shop-rename         1d3ed5a [agent/rename-subtotal]
~/worktree-lab/shop-shipping-rates 1d3ed5a [agent/shipping-rates]

The last command, git worktree list, shows one line per worktree: the folder, the commit it is on, and the branch in square brackets. The main worktree comes first. All five folders are on the same commit, because every branch was created from it.

Look inside a worktree

A linked worktree is not magic, and seeing how it is wired removes most of the mystery. The commands below print the pointer file inside one worktree, list the bookkeeping folders Git keeps for linked worktrees, and ask Git where it stores two different files.

cat ../shop-coupons/.git
ls .git/worktrees
cat .git/worktrees/shop-coupons/gitdir
git -C ../shop-coupons rev-parse --git-dir --git-common-dir
git -C ../shop-coupons rev-parse --git-path HEAD
git -C ../shop-coupons rev-parse --git-path refs/heads/main
gitdir: ~/worktree-lab/shop/.git/worktrees/shop-coupons
shop-coupons
shop-free-shipping
shop-rename
shop-shipping-rates
~/worktree-lab/shop-coupons/.git
~/worktree-lab/shop/.git/worktrees/shop-coupons
~/worktree-lab/shop/.git
~/worktree-lab/shop/.git/worktrees/shop-coupons/HEAD
~/worktree-lab/shop/.git/refs/heads/main

In the worktree, .git is not a folder but a one-line file that says gitdir: and names a private folder inside the main repository’s .git/worktrees. That private folder holds the worktree’s own HEAD and index. The next lines show the two halves: --git-dir is the private folder, while --git-common-dir is the main repository’s .git. The last two lines are the important ones. HEAD lives in the private folder, so every worktree has its own. The branch refs/heads/main lives in the common folder, so every worktree sees the same branches. The manual puts it this way: the new worktree is “sharing everything except per-worktree files such as HEAD, index, etc.”

Step 5: Measure the collision, then rerun it in worktrees

Now measure what sharing a folder costs. The script below is a stress test, not a model of a real agent. It starts one thread per folder you give it, and each thread plays an agent that, fifteen times in a row, writes a file, stages it with git add -A and commits it. That is 15 rounds of 2 Git commands per agent, so six agents run 180 commands. It counts the errors and then checks how many committed files carry another agent’s name.

"""stress.py: one stand-in agent per folder argument, all committing at the same moment."""
import subprocess
import sys
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

ROUNDS = 15


def git(folder, *args):
    return subprocess.run(["git", *args], cwd=folder, capture_output=True, text=True)


def agent(job):
    number, folder = job
    seen = Counter()
    for i in range(ROUNDS):
        (Path(folder) / f"agent{number}-{i}.txt").write_text(f"agent {number} round {i}\n")
        for result in (git(folder, "add", "-A"), git(folder, "commit", "-q", "-m", f"agent{number} round {i}")):
            text = result.stdout + result.stderr
            if "index.lock" in text:
                seen["index.lock errors"] += 1
            elif "cannot lock ref" in text:
                seen["ref lock errors"] += 1
            elif result.returncode != 0 and ("nothing" in text or "no changes added" in text):
                seen["nothing left to commit"] += 1
            elif result.returncode != 0:
                seen["other errors"] += 1
    return seen


def foreign_files(folder):
    """Count files committed under another agent's name (the subject says agentN)."""
    log = git(folder, "log", "--name-only", "--format=@@%s").stdout
    count = 0
    for block in log.split("@@")[1:]:
        subject, *files = block.strip().splitlines()
        if not subject.startswith("agent"):
            continue  # skip the commit the repository started from
        owner = subject.split()[0]
        count += sum(1 for name in files if name and not name.startswith(owner + "-"))
    return count


if __name__ == "__main__":
    folders = sys.argv[1:]
    with ThreadPoolExecutor(len(folders)) as pool:
        totals = sum(pool.map(agent, enumerate(folders, start=1)), Counter())
    print(f"{len(set(folders))} folder(s), {len(folders)} agents, {ROUNDS * len(folders)} commit attempts")
    for label in ("index.lock errors", "ref lock errors", "nothing left to commit", "other errors"):
        print(f"{label}: {totals[label]}")
    print(f"files committed under the wrong agent: {sum(foreign_files(f) for f in set(folders))}")

The block below runs it twice. First the six agents all work in the scratch clone from Step 3. Then each agent gets a worktree of its own: the block creates six throw-away worktrees, runs the same script on them, and removes them again with git worktree remove --force and git branch -D. The final lines delete the scratch clone and list the worktrees that are left.

cd ~/worktree-lab/shop
for i in 1 2 3 4 5 6; do git worktree add -q ../stress-$i -b stress-$i; done
cd ~/worktree-lab
python stress.py shop-shared shop-shared shop-shared shop-shared shop-shared shop-shared
python stress.py stress-1 stress-2 stress-3 stress-4 stress-5 stress-6
cd ~/worktree-lab/shop
for i in 1 2 3 4 5 6; do git worktree remove --force ../stress-$i; git branch -D -q stress-$i; done
rm -rf ../shop-shared
git worktree list
1 folder(s), 6 agents, 90 commit attempts
index.lock errors: 138
ref lock errors: 2
nothing left to commit: 5
other errors: 0
files committed under the wrong agent: 75
6 folder(s), 6 agents, 90 commit attempts
index.lock errors: 0
ref lock errors: 0
nothing left to commit: 0
other errors: 0
files committed under the wrong agent: 0
~/worktree-lab/shop                1d3ed5a [main]
~/worktree-lab/shop-coupons        1d3ed5a [agent/coupons]
~/worktree-lab/shop-free-shipping  1d3ed5a [agent/free-shipping]
~/worktree-lab/shop-rename         1d3ed5a [agent/rename-subtotal]
~/worktree-lab/shop-shipping-rates 1d3ed5a [agent/shipping-rates]

In the shared folder, 138 of the 180 Git commands died with an index.lock error and 2 more with a cannot lock ref error, where two commits tried to move the same branch at once. Another 5 found nothing left to commit because a neighbour’s git add -A had already swept their file into its own commit. And 75 of the 90 files ended up in a commit labelled with a different agent’s name. In six worktrees the same script produced no errors and no misattributed files.

Treat the numbers as proof of the mechanism, not as a forecast. A real agent runs Git far less often than a loop that does nothing else, so it will collide less often, but the collisions have the same cause and the same fix. Sharing the folder is also dangerous even when no error appears, as Step 3 showed.

Step 6: What a worktree costs on disk

Worktrees are often described as a cheap alternative to cloning the repository again. I measured it on a real project instead of taking that on trust. The test repository was django/django, cloned from GitHub on the same Windows machine: 35,105 commits, with a 297.0 MB .git folder (the history, packed into 28 files) and 45.2 MB of working files in 7,097 files. I then made three linked worktrees and, for comparison, three local clones. I used git clone --no-hardlinks for the clones. By default a local clone shares object files with the original through hardlinks when it can, but the manual notes that --no-hardlinks forces it “to copy the files under the .git/objects directory instead of using hardlinks”, which is what a clone on another drive or from a remote would cost.

Layout Disk used Time to create
Main repository, cloned from GitHub 297.0 MB history + 45.2 MB files 16.1 s
Each extra linked worktree 45.2 MB (the .git pointer file is 121 bytes) 20.8 s, 21.2 s, 22.1 s
Each extra clone with --no-hardlinks 342.2 MB 8.7 s, 8.8 s, 9.4 s
Four checkouts as main + 3 worktrees 477.7 MB
Four checkouts as main + 3 clones 1,368.9 MB

A worktree adds only the working files, so four checkouts took about a third of the space. It did not make the checkout faster: on this machine creating a worktree took about 21 seconds against about 9 for a copying local clone, and I did not investigate why. Do not choose worktrees for speed. Choose them because every agent gets a private folder while the history exists once. To repeat the size comparison on your own repository, run du -sh .git in the main folder and du -sh on a worktree.

Step 7: Let the four stand-in agents work

Back in the practice project, each agent gets one task. Two of the tasks are built to cause trouble later, which is the point of the exercise.

Agent Branch Task
coupons agent/coupons Add a new file with discount codes. It imports subtotal from the cart module.
free-shipping agent/free-shipping Lower the free-shipping threshold from 50.00 to 35.00.
shipping-rates agent/shipping-rates Raise the threshold to 75.00 and the flat rate to 5.99.
rename agent/rename-subtotal Rename subtotal to items_total everywhere.

The free-shipping and shipping-rates agents will change the same line, so they will conflict. The coupons and rename agents touch different files, so Git will merge them without complaint, and yet the merged code will not work. We will find both problems before anything reaches main.

The first agent writes two new files and commits them. Open a second terminal for it if you like; the folders are independent, so the agents really can run at the same time.

cd ~/worktree-lab/shop-coupons
cat > src/shop/coupons.py <<'PYEOF'
"""Coupon codes."""
from shop.cart import subtotal

COUPONS = {"SAVE10": 10, "SAVE20": 20}


def discount(items, code):
    """Discount in cents for a coupon code (0 when the code is unknown)."""
    return subtotal(items) * COUPONS.get(code, 0) // 100
PYEOF
cat > tests/test_coupons.py <<'PYEOF'
from shop.coupons import discount


def test_known_code():
    assert discount([(1000, 2)], "SAVE10") == 200


def test_unknown_code():
    assert discount([(1000, 2)], "NOPE") == 0
PYEOF
python -m pytest -q
git add -A
git commit -q -m "Add coupon discounts"
......                                                                   [100%]
6 passed in 0.04s

The next two agents change existing files. perl -pi -e 's/old/new/' file replaces text inside a file; we use it because it behaves the same on Linux, macOS and Git Bash, while a real agent would use its own editing tool. Before committing, the block asks each folder for its git status --short.

cd ~/worktree-lab/shop-free-shipping
perl -pi -e 's/^FREE_SHIPPING_FROM = 5000$/FREE_SHIPPING_FROM = 3500/' src/shop/cart.py
perl -pi -e 's/shipping\(5000\) == 0/shipping(3500) == 0/' tests/test_cart.py
cd ../shop-shipping-rates
perl -pi -e 's/^FREE_SHIPPING_FROM = 5000$/FREE_SHIPPING_FROM = 7500/; s/^FLAT_SHIPPING = 499$/FLAT_SHIPPING = 599/' src/shop/cart.py
perl -pi -e 's/shipping\(5000\) == 0/shipping(7500) == 0/; s/shipping\(2250\) == 499/shipping(2250) == 599/; s/== 2749/== 2849/' tests/test_cart.py
cd ../shop
echo "main folder:"; git status --short
echo "free-shipping folder:"; git -C ../shop-free-shipping status --short
echo "shipping-rates folder:"; git -C ../shop-shipping-rates status --short
main folder:
free-shipping folder:
 M src/shop/cart.py
 M tests/test_cart.py
shipping-rates folder:
 M src/shop/cart.py
 M tests/test_cart.py

The main folder reports nothing, and each agent folder lists only its own two modified files. Each worktree has its own index and its own copy of the files, so an edit in one folder cannot appear in another. Now commit the work, let the rename agent do its edit, run the tests in every folder, and look at the history.

cd ~/worktree-lab/shop-free-shipping
python -m pytest -q
git commit -q -am "Lower the free-shipping threshold to 35.00"
cd ../shop-shipping-rates
python -m pytest -q
git commit -q -am "Raise the free-shipping threshold to 75.00 and the flat rate to 5.99"
cd ../shop-rename
perl -pi -e 's/\bsubtotal\b/items_total/g' src/shop/cart.py tests/test_cart.py
python -m pytest -q
git commit -q -am "Rename subtotal to items_total"
cd ../shop
git log --oneline --graph --all
....                                                                     [100%]
4 passed in 0.03s
....                                                                     [100%]
4 passed in 0.02s
....                                                                     [100%]
4 passed in 0.03s
* 636abaf Rename subtotal to items_total
| * ecb3189 Lower the free-shipping threshold to 35.00
|/
| * d1a29c6 Raise the free-shipping threshold to 75.00 and the flat rate to 5.99
|/
| * 9f7c353 Add coupon discounts
|/
* 1d3ed5a Add cart arithmetic and tests

Every agent passes its own tests, and the graph shows four branches growing from the same commit while main has not moved. This is the good news, and the dangerous part: four green test runs say nothing about whether the four branches work together.

The order in which the branches appear in the graph can differ on your machine when commits were made within the same second.

Step 8: Know what is shared and what is not

Before you trust worktrees with real work, learn the sharing rules. The manual states them: “In general, all pseudo refs are per-worktree and all refs starting with refs/ are shared.” Pseudo refs are names such as HEAD that sit directly in the Git folder. The manual adds: “There are exceptions, however: refs inside refs/bisect, refs/worktree and refs/rewritten are not shared.” Configuration is shared too: “By default, the repository config file is shared across all worktrees.”

That is the theory. The block below checks it. It creates a tag and a configuration setting in one worktree and reads them from another, compares the HEAD history of two worktrees, stashes a change in one worktree and lists stashes in another, and starts a bisect in one worktree and asks another for its bisect log.

cd ~/worktree-lab/shop-coupons
git tag demo-tag
git config demo.setting yes
echo "tags seen from shop-rename:"; git -C ../shop-rename tag --list
echo "setting seen from shop-rename:"; git -C ../shop-rename config --get demo.setting
echo "last HEAD reflog entry in shop-coupons:"; git -C ../shop-coupons reflog -n 1
echo "last HEAD reflog entry in shop-rename:"; git -C ../shop-rename reflog -n 1
echo "# half-finished idea" >> src/shop/cart.py
git stash
echo "stashes seen from shop-rename:"; git -C ../shop-rename stash list
git stash drop -q
git bisect start
echo "bisect log in shop-coupons:"; git bisect log
echo "bisect log in shop-rename:"; git -C ../shop-rename bisect log
git bisect reset
git tag -d demo-tag
git config --unset demo.setting
tags seen from shop-rename:
demo-tag
setting seen from shop-rename:
yes
last HEAD reflog entry in shop-coupons:
9f7c353 HEAD@{0}: commit: Add coupon discounts
last HEAD reflog entry in shop-rename:
636abaf HEAD@{0}: commit: Rename subtotal to items_total
Saved working directory and index state WIP on agent/coupons: 9f7c353 Add coupon discounts
stashes seen from shop-rename:
stash@{0}: WIP on agent/coupons: 9f7c353 Add coupon discounts
status: waiting for both 'good' and 'bad' commits
bisect log in shop-coupons:
git bisect start
# status: waiting for both 'good' and 'bad' commits
bisect log in shop-rename:
error: We are not bisecting.
Already on 'agent/coupons'
Deleted tag 'demo-tag' (was 9f7c353)
Thing Shared or separate Evidence
Branches and tags Shared The tag made in shop-coupons was listed from shop-rename.
Configuration Shared The setting made in one folder was read from the other.
Stash Shared The stash made in shop-coupons was listed from shop-rename.
HEAD and its history Separate Each folder reported its own last commit.
Index and working files Separate Step 7: each folder listed only its own modified files.
Bisect state Separate The second folder answered “We are not bisecting.”
Untracked and ignored files (.venv, node_modules, .env) Separate Not part of this run: Git does not track these files, so each folder has its own, or none. Step 10 shows the effect.

Two of these are worth remembering. The stash list is shared, so git stash pop takes the newest stash, whichever folder created it. I checked that in a scratch repository: a stash made in one worktree was applied by git stash pop in another, and it was gone from the first worktree’s list afterwards. Tell your agents not to use the stash at all; a commit on their own branch is safer. And bisect state is separate, which is good news: two agents can hunt for different bugs at the same time. If you need configuration that differs per worktree, the manual describes git config extensions.worktreeConfig true followed by git config --worktree. For bisecting itself, see How to Find the Commit That Broke Your Code With git bisect.

Step 9: Learn the safety rules and the cleanup trap

One branch, one folder

Git refuses to check out the same branch in two folders at once, which protects two agents from editing the same branch. The block below tries three things in the main folder: switching to a branch another worktree has checked out, adding a second worktree for it, and deleting it.

cd ~/worktree-lab/shop
git switch agent/coupons
git worktree add ../shop-second agent/coupons
git branch -d agent/coupons
fatal: 'agent/coupons' is already used by worktree at '~/worktree-lab/shop-coupons'
Preparing worktree (checking out 'agent/coupons')
fatal: 'agent/coupons' is already used by worktree at '~/worktree-lab/shop-coupons'
error: cannot delete branch 'agent/coupons' used by worktree at '~/worktree-lab/shop-coupons'

All three are refused, with messages that name the folder that holds the branch. The manual confirms the rule for add: it “refuses to create a new worktree when <commit-ish> is a branch name and is already checked out by another worktree”. The same rule shows up in agent products: the Codex documentation explains that “Git only allows a branch to be checked out in one place at a time”, so a branch checked out in a Codex-managed worktree cannot also be checked out in your main folder. If you want a throw-away worktree that is not tied to any branch, use git worktree add --detach <path>.

Remove worktrees with Git, not with the file manager

The last rule is about cleanup. The block creates a scratch worktree with an untracked file in it, tries to remove it the safe way, deletes the folder by hand instead, and then shows what Git thinks about that.

cd ~/worktree-lab/shop
git worktree add -q ../shop-scratch -b scratch
echo "work in progress" > ../shop-scratch/notes.txt
git worktree remove ../shop-scratch
rm -rf ../shop-scratch
git worktree list
git worktree add ../shop-scratch scratch
git worktree prune --dry-run --verbose
git worktree prune
git worktree list
git branch -d scratch
fatal: '../shop-scratch' contains modified or untracked files, use --force to delete it
~/worktree-lab/shop                1d3ed5a [main]
~/worktree-lab/shop-coupons        9f7c353 [agent/coupons]
~/worktree-lab/shop-free-shipping  ecb3189 [agent/free-shipping]
~/worktree-lab/shop-rename         636abaf [agent/rename-subtotal]
~/worktree-lab/shop-scratch        1d3ed5a [scratch] prunable
~/worktree-lab/shop-shipping-rates d1a29c6 [agent/shipping-rates]
Preparing worktree (checking out 'scratch')
fatal: '../shop-scratch' is a missing but already registered worktree;
use 'add -f' to override, or 'prune' or 'remove' to clear
Removing worktrees/shop-scratch: gitdir file points to non-existent location
~/worktree-lab/shop                1d3ed5a [main]
~/worktree-lab/shop-coupons        9f7c353 [agent/coupons]
~/worktree-lab/shop-free-shipping  ecb3189 [agent/free-shipping]
~/worktree-lab/shop-rename         636abaf [agent/rename-subtotal]
~/worktree-lab/shop-shipping-rates d1a29c6 [agent/shipping-rates]
Deleted branch scratch (was 1d3ed5a).

Read the output from the top. git worktree remove refused because the scratch folder held an untracked file. The manual says: “Only clean worktrees (no untracked files and no modification in tracked files) can be removed.” That refusal is a feature; it stops you from throwing away an agent’s uncommitted work. After the folder was deleted by hand, git worktree list marked the entry prunable, and git worktree add refused to reuse the path because it was “a missing but already registered worktree”. git worktree prune (the --dry-run --verbose run only reports) cleared the leftover bookkeeping, and then the branch could be deleted.

Git would eventually clean up on its own: the manual says the administrative files of a deleted worktree “will eventually be removed automatically”, and the configuration reference says that when git gc runs, “it calls git worktree prune --expire 3.months.ago”. Until then the stale entry keeps its branch locked. One more safeguard is worth knowing about. If a worktree lives on a portable drive or network share that is not always mounted, git worktree lock stops the entry from being pruned while the drive is away.

Step 10: Make every worktree test its own code

This is the trap that does the most quiet damage in Python projects. A worktree is a fresh checkout, and the Claude Code documentation says so plainly: “A worktree is a fresh checkout, so initialize your development environment there”. A virtual environment, node_modules or a .env file is not part of the Git history, so none of them appears in a new worktree. (Claude Code can copy ignored files such as .env into each new worktree if you list them in a .worktreeinclude file.)

The tempting shortcut is to create one virtual environment, install the project into it in editable mode with pip install -e, and point every worktree at it. An editable install records the folder that held the code when you ran it, which is the main folder. The block below builds that shared environment, then asks it from inside the rename agent’s worktree to import the function that agent just created. The variable VPY holds the path of the environment’s Python.

cd ~/worktree-lab
python -m venv venv
VPY=venv/bin/python   # Windows (Git Bash): VPY=venv/Scripts/python
$VPY -m pip install -q --disable-pip-version-check -e shop pytest
cd shop-rename
../$VPY -c "from shop.cart import items_total; print('ok')"
PYTHONPATH=src ../$VPY -c "from shop.cart import items_total; print('ok')"
cat > tests/test_tree.py <<'PYEOF'
from pathlib import Path

import shop


def test_imports_this_checkout():
    checkout = Path(__file__).resolve().parents[1]
    assert Path(shop.__file__).resolve().is_relative_to(checkout)
PYEOF
../$VPY -m pytest -q tests/test_tree.py
../$VPY -m pytest -q -o pythonpath= tests/test_tree.py 2>&1 | tail -n 2
rm tests/test_tree.py
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    from shop.cart import items_total; print('ok')
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ImportError: cannot import name 'items_total' from 'shop.cart' (~/worktree-lab/shop/src/shop/cart.py)
ok
.                                                                        [100%]
1 passed in 0.01s
FAILED tests/test_tree.py::test_imports_this_checkout - AssertionError: asser...
1 failed in 0.04s

The first command fails: cannot import name 'items_total', and the path in the error message shows the problem. Python imported the project from ~/worktree-lab/shop, the main folder, and not from the worktree where the function exists. Anything that imports your package this way, such as a script, python -m or a development server, runs the wrong code without any warning. The second command repeats the call with PYTHONPATH=src, which puts the current folder’s src first, and succeeds.

For tests there is a permanent fix, and you already installed it in Step 2. The pytest reference describes pythonpath this way: “Sets list of directories that should be added to the python search path. Directories will be added to the head of sys.path.” Paths are “relative to the rootdir directory”. Because pythonpath = ["src"] is committed to the repository, every worktree’s pytest puts its own src first, even when an editable install of the main folder exists. The test file in the block checks it. The test test_imports_this_checkout asks where shop was imported from and fails if the answer is not inside the folder that holds the test. It passes in the first run. The second run switches the setting off with -o pythonpath= and the test fails, which proves that the test catches the mistake it was written for. Keep a guard like this in every project that agents test in worktrees.

Step 11: Check for conflicts before merging with git merge-tree

Now to the merge. Git’s merge-tree command can answer “would these two branches conflict?” without merging anything. The manual says it “performs a merge, but does not make any new commits and does not read from or write to either the working tree or index.” That makes it safe to run from any folder, at any time, while agents are working.

The block runs it five times. First on a pair that should merge cleanly (coupons and free-shipping). Then on the pair that should conflict (free-shipping and shipping-rates) in three forms: the default output, a script-friendly form with --name-only --no-messages, and --quiet, which prints nothing and leaves only the exit status. A git status and the current branch come next, and the last command names a branch that does not exist.

cd ~/worktree-lab/shop
git merge-tree --write-tree agent/coupons agent/free-shipping; echo "exit status: $?"
git merge-tree --write-tree agent/free-shipping agent/shipping-rates; echo "exit status: $?"
git merge-tree --write-tree --name-only --no-messages agent/free-shipping agent/shipping-rates; echo "exit status: $?"
git merge-tree --write-tree --quiet agent/free-shipping agent/shipping-rates; echo "exit status: $?"
git status --short; git branch --show-current
git merge-tree --write-tree agent/coupons agent/typo; echo "exit status: $?"
ead26380f9bfcd660c1a5d9211698de15f4cb943
exit status: 0
7590a1e86d8b7994bd2745af9fc0f23232d59dcb
100644 3fa94b0331f48aa1b7f2330bf2db983c653a5498 1	src/shop/cart.py
100644 fed7f094a114715eaeb154fdaed833b112e4e109 2	src/shop/cart.py
100644 547a0460fb277aa4e9aaec72941870455acbc235 3	src/shop/cart.py
100644 af0660f033d41cd3f8f40a37b994cde26e06e01a 1	tests/test_cart.py
100644 2bb9f0f4bc93605450c3c993edec80d3249eb709 2	tests/test_cart.py
100644 5df6291f30cb8569a277b12318901d77eae5cddd 3	tests/test_cart.py

Auto-merging src/shop/cart.py
CONFLICT (content): Merge conflict in src/shop/cart.py
Auto-merging tests/test_cart.py
CONFLICT (content): Merge conflict in tests/test_cart.py
exit status: 1
7590a1e86d8b7994bd2745af9fc0f23232d59dcb
src/shop/cart.py
tests/test_cart.py
exit status: 1
exit status: 1
main
merge-tree: agent/typo - not something we can merge
exit status: 1

The first run printed one line, a tree ID, and exit status 0: a clean merge. The second printed a tree ID and then conflict details, and exit status 1. The conflict section has one line per file and stage: stage 1 is the common ancestor, stage 2 is the first branch you named, and stage 3 is the second. After a blank line come the messages Git would have printed during a real merge. With --name-only --no-messages the answer shrinks to the tree ID and the two conflicting file names. With --quiet only the exit status remains. The status and the current branch confirm that nothing in the working folder changed.

Three details matter when you build tooling on it.

  • Check the exit status, not the list of files. The manual warns: “Do NOT interpret an empty Conflicted file info list as a clean merge; check the exit status.”
  • A typo exits with 1 as well. The manual says that when the merge cannot complete “the exit status is something other than 0 or 1”. In my run, naming a branch that does not exist printed not something we can merge on standard error and exited with 1, the same status as a conflict. A script that trusts status 1 would report a mistyped branch name as a conflict. The gate in Step 13 therefore verifies that every name is a commit before it calls merge-tree.
  • Batch mode reverses the numbers. With --stdin, the manual lists the leading merge status as “0: merge had conflicts” and “1: merge was clean”. I confirmed it with one conflicting and one clean pair: the first record began with 0 and the second with 1. Reading that number as an exit status gets it backwards.

It is also fast. On the Django repository from Step 6 I made six branches, each changing one line of a different file, and ran the check for all 15 pairs: 0.72 seconds in total, about 0.05 seconds per pair, all clean. Pairwise checking stays cheap for a handful of agents on a large project.

Step 12: Merge in a scratch worktree and run the tests

A pairwise check finds textual conflicts. It cannot find the semantic kind, so the next defence is to merge the branches for real in a place where it does no harm and run the tests after every merge. That place is one more worktree, with an integration branch. The block merges the four agent branches into it one at a time, in order. --no-ff forces a real merge commit, so each merge shows up in the history. The one failing test run is piped through tail to keep only the last five lines.

cd ~/worktree-lab/shop
git worktree add -q ../shop-integration -b integration main
cd ../shop-integration
git merge --no-ff -m "Merge agent/coupons" agent/coupons
python -m pytest -q
git merge --no-ff -m "Merge agent/free-shipping" agent/free-shipping
python -m pytest -q
git merge --no-ff -m "Merge agent/shipping-rates" agent/shipping-rates
git status --short
git merge --abort
git merge --no-ff -m "Merge agent/rename-subtotal" agent/rename-subtotal
python -m pytest -q 2>&1 | tail -n 5
Merge made by the 'ort' strategy.
 src/shop/coupons.py   | 9 +++++++++
 tests/test_coupons.py | 9 +++++++++
 2 files changed, 18 insertions(+)
 create mode 100644 src/shop/coupons.py
 create mode 100644 tests/test_coupons.py
......                                                                   [100%]
6 passed in 0.04s
Merge made by the 'ort' strategy.
 src/shop/cart.py   | 2 +-
 tests/test_cart.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)
......                                                                   [100%]
6 passed in 0.02s
Auto-merging src/shop/cart.py
CONFLICT (content): Merge conflict in src/shop/cart.py
Auto-merging tests/test_cart.py
CONFLICT (content): Merge conflict in tests/test_cart.py
Automatic merge failed; fix conflicts and then commit the result.
UU src/shop/cart.py
UU tests/test_cart.py
Auto-merging src/shop/cart.py
Auto-merging tests/test_cart.py
Merge made by the 'ort' strategy.
 src/shop/cart.py   | 4 ++--
 tests/test_cart.py | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)
E   ImportError: cannot import name 'subtotal' from 'shop.cart' (~/worktree-lab/shop-integration/src/shop/cart.py)
=========================== short test summary info ===========================
ERROR tests/test_coupons.py
!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.11s

Follow the sequence. Coupons merged cleanly and the tests passed. Free-shipping merged cleanly and the tests passed. Shipping-rates stopped with a conflict in src/shop/cart.py and tests/test_cart.py, git status --short showed both files as UU (unmerged on both sides), and git merge --abort put the worktree back the way it was. That one needs a person: should free shipping start at 35.00 or at 75.00? Then the rename branch merged without any conflict, and the tests failed.

The failing test is the semantic conflict. tests/test_coupons.py could not even be collected, because src/shop/coupons.py still imports subtotal from shop.cart, and the rename agent had just removed that name. Git had no way to see it: one branch changed cart.py, the other added coupons.py, and no line was touched by both. Only running the code could expose it. The fix is one more commit on the integration branch, which a person, or the coupons agent after a rebase, makes. This block applies the same rename to the coupons file, runs the tests, and shows the history.

perl -pi -e 's/\bsubtotal\b/items_total/g' src/shop/coupons.py
git commit -q -am "Use items_total in coupons"
python -m pytest -q
git log --oneline --graph
......                                                                   [100%]
6 passed in 0.02s
* aa5c92f Use items_total in coupons
*   9a31441 Merge agent/rename-subtotal
|\
| * 636abaf Rename subtotal to items_total
* |   46d3894 Merge agent/free-shipping
|\ \
| * | ecb3189 Lower the free-shipping threshold to 35.00
| |/
* |   a7d8b0b Merge agent/coupons
|\ \
| |/
|/|
| * 9f7c353 Add coupon discounts
|/
* 1d3ed5a Add cart arithmetic and tests

Step 13: Automate the checks with a gate script

Doing Steps 11 and 12 by hand for every batch of agent branches would be tedious and easy to get wrong, so put them in a script. The script is called gate.py because it acts as a gate between the agents’ branches and main. It does what you just did: it asks merge-tree about every pair of branches, then merges them in order in a scratch worktree and runs your test command after each merge. A branch that conflicts is skipped. A branch whose merge breaks the tests is thrown out again, so it cannot poison the branches after it.

Save this next to the shop folder as ~/worktree-lab/gate.py.

"""gate.py: check agent branches before you merge them (needs Git 2.38 or newer)."""
import argparse
import itertools
import subprocess
import sys
import tempfile
from pathlib import Path


def git(*args, cwd=".", check=True):
    return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=check)


def require_commit(repo, name):
    """git merge-tree also exits with 1 for a name it cannot find, so check the names first."""
    found = git("rev-parse", "--verify", "--quiet", f"{name}^{{commit}}", cwd=repo, check=False)
    if found.returncode != 0:
        raise RuntimeError(f"{name} is not a commit in this repository")


def conflicting_files(repo, a, b):
    """Return the files that conflict when a and b are merged; [] means a clean merge."""
    require_commit(repo, a)
    require_commit(repo, b)
    result = git("merge-tree", "--write-tree", "--name-only", "--no-messages", a, b, cwd=repo, check=False)
    if result.returncode == 0:
        return []
    if result.returncode == 1:
        names = sorted(set(result.stdout.splitlines()[1:]))  # line 1 is the tree id
        return names or ["(conflict without a file name)"]
    raise RuntimeError(result.stderr.strip() or f"git merge-tree failed with status {result.returncode}")


def run_tests(worktree, command):
    """Return None when the tests pass, otherwise a short description of the first failure."""
    done = subprocess.run(command, cwd=worktree, shell=True, capture_output=True, text=True)
    if done.returncode == 0:
        return None
    lines = [line for line in (done.stdout + done.stderr).splitlines() if line.strip()]
    first = next((line for line in lines if line.startswith(("FAILED", "ERROR"))), lines[-1] if lines else "no output")
    return first.split(" - ")[0][:80]


def integrate(repo, base, branches, test_command):
    """Merge branches one by one into a throw-away worktree and run the tests after each merge."""
    for name in (base, *branches):
        require_commit(repo, name)
    report = []
    with tempfile.TemporaryDirectory() as scratch:
        worktree = str(Path(scratch) / "integration")
        git("worktree", "add", "--detach", worktree, base, cwd=repo)
        try:
            problem = run_tests(worktree, test_command)
            if problem:
                return [(base, "tests fail before any merge", problem)]
            for branch in branches:
                before = git("rev-parse", "HEAD", cwd=worktree).stdout.strip()
                merged = git("merge", "--no-ff", "--no-edit", branch, cwd=worktree, check=False)
                if merged.returncode != 0:
                    git("merge", "--abort", cwd=worktree, check=False)
                    report.append((branch, "conflict", ""))
                    continue
                problem = run_tests(worktree, test_command)
                if problem:
                    git("reset", "--hard", before, cwd=worktree)  # drop the merge that broke the tests
                    report.append((branch, "tests fail", problem))
                else:
                    report.append((branch, "ok", ""))
        finally:
            git("worktree", "remove", "--force", worktree, cwd=repo, check=False)
    return report


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("branches", nargs="+")
    parser.add_argument("--base", default="main")
    parser.add_argument("--repo", default=".")
    parser.add_argument("--test", default="python -m pytest -q")
    args = parser.parse_args(argv)

    problems = 0
    try:
        print("pairwise conflict check (git merge-tree)")
        for a, b in itertools.combinations(args.branches, 2):
            files = conflicting_files(args.repo, a, b)
            problems += bool(files)
            print(f"  {a} x {b}: " + ("CONFLICT in " + ", ".join(files) if files else "clean"))

        print("integration (merge in the order given, test after each merge)")
        for branch, status, detail in integrate(args.repo, args.base, args.branches, args.test):
            problems += status != "ok"
            print(f"  {branch}: {status}" + (f" ({detail})" if detail else ""))
    except RuntimeError as error:
        print(f"error: {error}", file=sys.stderr)
        return 2
    return 1 if problems else 0


if __name__ == "__main__":
    raise SystemExit(main())

Here is how it works, function by function.

  • require_commit asks git rev-parse --verify --quiet name^{commit} whether a name is a commit and raises an error if not. This is the guard against the typo behaviour from Step 11.
  • conflicting_files runs git merge-tree --write-tree --name-only --no-messages and reads the exit status: 0 returns an empty list, 1 returns the file names after the tree ID on the first line, and anything else raises an error. If status 1 comes with no file names, it still reports a conflict, following the manual’s warning about empty lists.
  • run_tests runs your test command inside a worktree and returns None when it passes. When it fails it returns the first line that starts with FAILED or ERROR, cut before the explanation, or the last line of output if there is none.
  • integrate creates a detached scratch worktree at the base branch, runs the tests once before merging anything (so a branch is never blamed for a problem the base already had), and then merges each branch. It remembers the commit before each merge so that it can go back with git reset --hard. A finally clause removes the scratch worktree whatever happens.
  • main prints the pairwise report and the integration report, and returns 0 when everything is clean, 1 when there is a conflict or a test failure, and 2 when a branch name is not a commit, so it can run in a script or in CI.

Test the gate itself

A gate that can be wrong is worse than no gate, so it has tests. They build throw-away repositories in pytest’s temporary folder, with branches that conflict, branches that merge cleanly but break a check script, and a mistyped branch name. Save the file as ~/worktree-lab/test_gate.py, next to gate.py.

"""test_gate.py: tests for gate.py, using real throw-away Git repositories."""
import subprocess
import sys

import pytest

import gate

CHECK = f'"{sys.executable}" check.py'


def run(repo, *args):
    subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True)


def branch(repo, name, files):
    run(repo, "switch", "-q", "-c", name, "main")
    for filename, text in files.items():
        (repo / filename).write_text(text)
    run(repo, "add", "-A")
    run(repo, "commit", "-q", "-m", name)
    run(repo, "switch", "-q", "main")


@pytest.fixture
def repo(tmp_path):
    root = tmp_path / "repo"
    root.mkdir()
    run(root, "init", "-q", "-b", "main")
    run(root, "config", "user.name", "Test")
    run(root, "config", "user.email", "[email protected]")
    (root / "app.py").write_text("RATE = 10\n\n\ndef price():\n    return RATE\n")
    (root / "check.py").write_text(
        "import importlib, pathlib\n"
        "for path in pathlib.Path('.').glob('*.py'):\n"
        "    if path.name != 'check.py':\n"
        "        importlib.import_module(path.stem)\n"
    )
    run(root, "add", "-A")
    run(root, "commit", "-q", "-m", "base")
    app = (root / "app.py").read_text()
    branch(root, "rate-20", {"app.py": app.replace("RATE = 10", "RATE = 20")})
    branch(root, "rate-30", {"app.py": app.replace("RATE = 10", "RATE = 30")})
    branch(root, "rename", {"app.py": app.replace("def price", "def cost")})
    branch(root, "uses-price", {"report.py": "from app import price\n"})
    return root


def test_clean_pair_has_no_conflicting_files(repo):
    assert gate.conflicting_files(repo, "uses-price", "rate-20") == []


def test_conflicting_pair_names_the_file(repo):
    assert gate.conflicting_files(repo, "rate-20", "rate-30") == ["app.py"]


def test_unknown_branch_is_an_error_not_a_clean_merge(repo):
    with pytest.raises(RuntimeError):
        gate.conflicting_files(repo, "rate-20", "no-such-branch")


def test_integrate_rejects_an_unknown_branch(repo):
    with pytest.raises(RuntimeError):
        gate.integrate(repo, "main", ["rate-20", "no-such-branch"], CHECK)


def test_main_reports_a_typo_with_status_2(repo, capsys):
    assert gate.main(["--repo", str(repo), "--test", CHECK, "rate-20", "rate-2o"]) == 2
    assert "rate-2o is not a commit" in capsys.readouterr().err


def test_conflicting_branch_is_skipped(repo):
    report = gate.integrate(repo, "main", ["rate-20", "rate-30", "uses-price"], CHECK)
    assert report == [("rate-20", "ok", ""), ("rate-30", "conflict", ""), ("uses-price", "ok", "")]


def test_clean_merge_that_breaks_the_tests_is_caught(repo):
    report = gate.integrate(repo, "main", ["uses-price", "rename"], CHECK)
    assert report[0] == ("uses-price", "ok", "")
    assert report[1][:2] == ("rename", "tests fail")


def test_a_broken_branch_does_not_poison_the_next_one(repo):
    report = gate.integrate(repo, "main", ["uses-price", "rename", "rate-20"], CHECK)
    assert [status for _, status, _ in report] == ["ok", "tests fail", "ok"]


def test_broken_base_is_reported_before_any_merge(repo):
    (repo / "broken.py").write_text("raise SystemExit(1)\n")
    run(repo, "add", "-A")
    run(repo, "commit", "-q", "-m", "break the base")
    report = gate.integrate(repo, "main", ["rate-20"], CHECK)
    assert report[0][:2] == ("main", "tests fail before any merge")


def test_the_scratch_worktree_is_removed(repo):
    gate.integrate(repo, "main", ["rate-20"], CHECK)
    listing = subprocess.run(["git", "worktree", "list"], cwd=repo, capture_output=True, text=True).stdout
    assert len(listing.strip().splitlines()) == 1


def test_exit_status(repo):
    args = ["--repo", str(repo), "--test", CHECK]
    assert gate.main(args + ["rate-20", "uses-price"]) == 0
    assert gate.main(args + ["rate-20", "rate-30"]) == 1
    assert gate.main(args + ["uses-price", "rename"]) == 1

Now run the gate from the main folder with the four branches, in the order you want them merged. The block then runs the gate’s own tests and lists the worktrees afterwards.

cd ~/worktree-lab/shop
python ../gate.py --base main agent/coupons agent/free-shipping agent/shipping-rates agent/rename-subtotal; echo "exit status: $?"
cd ~/worktree-lab
python -m pytest -q test_gate.py
cd ~/worktree-lab/shop
git worktree list
pairwise conflict check (git merge-tree)
  agent/coupons x agent/free-shipping: clean
  agent/coupons x agent/shipping-rates: clean
  agent/coupons x agent/rename-subtotal: clean
  agent/free-shipping x agent/shipping-rates: CONFLICT in src/shop/cart.py, tests/test_cart.py
  agent/free-shipping x agent/rename-subtotal: clean
  agent/shipping-rates x agent/rename-subtotal: clean
integration (merge in the order given, test after each merge)
  agent/coupons: ok
  agent/free-shipping: ok
  agent/shipping-rates: conflict
  agent/rename-subtotal: tests fail (ERROR tests/test_coupons.py)
exit status: 1
...........                                                              [100%]
11 passed in 22.84s
~/worktree-lab/shop                1d3ed5a [main]
~/worktree-lab/shop-coupons        9f7c353 [agent/coupons]
~/worktree-lab/shop-free-shipping  ecb3189 [agent/free-shipping]
~/worktree-lab/shop-integration    aa5c92f [integration]
~/worktree-lab/shop-rename         636abaf [agent/rename-subtotal]
~/worktree-lab/shop-shipping-rates d1a29c6 [agent/shipping-rates]

The pairwise matrix finds the one conflicting pair, free-shipping and shipping-rates, and names both files. The integration report finds the second problem that the matrix cannot see: shipping-rates conflicts when it is merged in order, and rename merges cleanly and then fails the tests, so the gate drops it and reports ERROR tests/test_coupons.py. The exit status 1 would fail a CI job. The scratch worktree the gate made is gone from the final list, and the main folder never changed.

The middle of that run executed the tests with python -m pytest -q test_gate.py: 11 passed. To see whether the tests would notice a broken gate, I planted 11 bugs one at a time, such as treating exit status 1 as a clean merge, not aborting a failed merge, keeping a merge that broke the tests, skipping the baseline run, leaving the scratch worktree behind and skipping the commit check. The tests caught 10 of 11. The one that survived replaces the saved commit with HEAD^. Because --no-ff always makes a merge commit whose first parent is the commit the script saved, and the script only resets after a merge that changed something, the two behave the same in every case I could construct. I kept the explicit version because it stays correct even for a merge that changes nothing, which Git reports as already up to date and which makes no commit.

Step 14: Merge for real and clean up

The last step puts the work on main. The integration branch already holds the three good branches and the fix, and main has not moved, so git merge --ff-only integration moves it forward in a straight line, and refuses if that is not possible. Then remove every worktree with Git, delete the branches that were merged, and try to delete the branch that was not.

cd ~/worktree-lab/shop
git merge --ff-only integration
for name in coupons free-shipping rename shipping-rates integration; do git worktree remove ../shop-$name; done
git branch -d agent/coupons agent/free-shipping agent/rename-subtotal integration
git branch -d agent/shipping-rates
git worktree list
python -m pytest -q
git log --oneline --graph --all
Updating 1d3ed5a..aa5c92f
Fast-forward
 src/shop/cart.py      | 6 +++---
 src/shop/coupons.py   | 9 +++++++++
 tests/test_cart.py    | 6 +++---
 tests/test_coupons.py | 9 +++++++++
 4 files changed, 24 insertions(+), 6 deletions(-)
 create mode 100644 src/shop/coupons.py
 create mode 100644 tests/test_coupons.py
Deleted branch agent/coupons (was 9f7c353).
Deleted branch agent/free-shipping (was ecb3189).
Deleted branch agent/rename-subtotal (was 636abaf).
Deleted branch integration (was aa5c92f).
error: the branch 'agent/shipping-rates' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D agent/shipping-rates'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
~/worktree-lab/shop aa5c92f [main]
......                                                                   [100%]
6 passed in 0.03s
* aa5c92f Use items_total in coupons
*   9a31441 Merge agent/rename-subtotal
|\
| * 636abaf Rename subtotal to items_total
* |   46d3894 Merge agent/free-shipping
|\ \
| * | ecb3189 Lower the free-shipping threshold to 35.00
| |/
* |   a7d8b0b Merge agent/coupons
|\ \
| |/
|/|
| * 9f7c353 Add coupon discounts
|/
| * d1a29c6 Raise the free-shipping threshold to 75.00 and the flat rate to 5.99
|/
* 1d3ed5a Add cart arithmetic and tests

Git deleted the four merged branches and refused to delete agent/shipping-rates: “the branch ‘agent/shipping-rates’ is not fully merged”. That refusal protects the work that still needs a human decision. The worktree list shows only the main folder, the tests pass on main, and the history shows the three merges, the fix, and the shipping-rates commit waiting on its own branch.

Common mistakes and how to avoid them

  • Putting worktrees inside the main folder. They show up as untracked content. Use sibling folders, or add the folder to .gitignore.
  • Deleting a worktree folder by hand. The registration stays behind, marks the branch as busy and blocks re-creating the folder. Use git worktree remove, and git worktree prune if the folder is already gone.
  • Sharing one environment between worktrees. Editable installs and global tools can import the main checkout. Keep pythonpath or an equivalent setting in the repository and a guard test, as in Step 10.
  • Forgetting the files Git does not track. A new worktree has no .venv, no node_modules and no .env. Script the setup, or use your agent tool’s include file.
  • Using --force to put one branch in two folders. The refusal is there to protect you. Use --detach or a new branch instead.
  • Trusting a clean merge. Git sees lines, not meaning. Run the tests after every merge.
  • Trusting the exit status of merge-tree blindly. Check that both names are real commits, as gate.py does.
  • Using the stash for agent work. The stash is shared, so one agent can pick up another’s changes. Commit on the agent’s own branch.
  • Using worktrees with submodules. The manual is direct: “Multiple checkout in general is still experimental, and the support for submodules is incomplete. It is NOT recommended to make multiple checkouts of a superproject.” I did not test submodules here.

Check that everything works end to end

The final outputs give you a checklist. If your runs match, the whole workflow works.

  • Step 7: every agent’s tests passed in its own folder, and git status in the main folder stayed empty while the agents worked.
  • Step 11: git merge-tree returned 0 for coupons and free-shipping and 1 for free-shipping and shipping-rates, and the working folder was untouched.
  • Step 13: the gate printed one conflicting pair, one skipped branch and one branch that failed its tests, and exited with 1.
  • Step 14: git worktree list shows only the main folder, git branch lists only the unmerged branch, and the tests pass on main.

To start over, delete the ~/worktree-lab folder. Nothing else on your machine was changed, apart from the virtual environment, which also lives inside that folder.

Where AI agent tools fit in

Several agent products now manage worktrees for you. In Claude Code, claude --worktree feature-auth starts a session in a new worktree, and the documentation describes the result like this: “A git worktree is a separate working directory with its own files and branch, sharing the same repository history and remote as your main checkout.” It tells you to add .claude/worktrees/ to your .gitignore and to initialize the environment in the new worktree, and it offers a .worktreeinclude file for carrying files such as .env across. The Codex documentation says that Codex creates its worktrees in $CODEX_HOME/worktrees. Whatever tool starts the worktree, the mechanics are the ones in this tutorial, and so are the risks: the shared stash, the per-folder environment, and the merge that looks clean and is not. The gate in Step 13 works on branches, so it does not care which tool made them.

Next steps

  • Pass a --test command that matches your project, for example a linter followed by the unit tests (the default is python -m pytest -q), and run the gate in CI on every batch of agent branches.
  • Give each agent a short instruction file that says which folder it owns and what it may not touch. How to Write an AGENTS.md File to Guide AI Coding Agents and How to Write and Organize CLAUDE.md Files for Claude Code show how.
  • Review what the agents wrote before merging it: How to Review AI-Generated Python Code Before You Merge It.
  • If the agents will run commands on their own, bound what they can do with How to Build a Safe AI Agent Harness in Python.
  • For what happens when hundreds of agents push to one branch at GitHub’s scale, read GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems.

Sources

  • Git manual, git-worktree (text checked against the v2.55.0 source) and git-merge-tree.
  • Git 2.38 release notes, the gc configuration reference and the git-clone manual.
  • Claude Code documentation, Run parallel sessions with worktrees and Common workflows.
  • OpenAI, Codex worktrees.
  • pytest, API reference (the pythonpath setting).
  • django/django, the repository used for the size and speed measurements in Steps 6 and 11.

Tags:

AI Coding AgentsDeveloper ToolingGitGit WorktreeMerge ConflictsVersion Control

Share

Car speedometer dial with a red needle pointing at about 60 miles per hour, or about 100 kilometers per hour on the inner scale
Previous Post

METR’s Anthropic Report Turns AI-Led Acceleration Into a 1.9x Slope Ratio That Reproduces From Its Charts, but the Ratio Runs From 1.1x to 2.9x With the Fit Function, and a Second METR Report Says Anthropic’s Public Risk Report Understated the Pace

Sepia 1900 postcard photograph of two riders on horses galloping neck and neck at the front of a field on a Hannover racecourse, with the rest of the pack behind them
Next Post

CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Nov
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
08 Nov
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
Trending
November 8, 2026
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
November 8, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
November 8, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026