- Git worktrees share .git state (index, config, reflogs), causing race conditions when multiple worktrees run Git commands simultaneously.
- Three corruption modes: index lock collisions (safe failure), reflog write races (silent data loss), and config file corruption (lost settings).
- Fix with filesystem locks (flock), build orchestration (sequential Git ops), or reference clones (–reference) for complete isolation.
The Lockfile That Wasn’t There
Git worktrees promise parallel builds without the disk overhead of multiple clones. Run git worktree add ../feature-branch and you’ve got a second working directory sharing the same .git — perfect for testing a hotfix while keeping your main branch clean, or running CI checks without stopping your current work.
Until both worktrees try to write at the same time.
I hit this building a CI pipeline that tested multiple branches concurrently. The idea was simple: spin up three worktrees, run pytest in each, collect results. Worked great locally with sequential runs. In CI with parallel jobs? Random failures with error: could not lock config file .git/config: Resource temporarily unavailable. Not every run. Just often enough to make the build unreliable.
The problem isn’t obvious from the docs. Git worktrees share more than just objects — they share the index update logic, reflog writes, and config file access. Most of these operations use lockfiles (index.lock, HEAD.lock, config.lock), but the locking is optimistic: Git assumes you’re not hammering the same repo from multiple processes simultaneously. Worktrees break that assumption.

Three Ways Parallel Worktrees Corrupt State
1. Index Lock Collisions
Every git add, git commit, git checkout writes to .git/index.lock before renaming it to index. If two worktrees run git checkout at the same time, one wins the lock, the other gets:
fatal: Unable to create '.git/index.lock': File exists.
Another git process seems to be running in this repository...
This is the “safe” failure mode — Git refuses to proceed. But if you’re running this in a CI script with set -e, your build fails with no clear indication which worktree caused it.
Here’s a minimal repro:
import subprocess
import time
from pathlib import Path
import concurrent.futures
# Setup: create a repo with two worktrees
subprocess.run(["git", "init", "test-repo"])
Path("test-repo/file.txt").write_text("initial")
subprocess.run(["git", "-C", "test-repo", "add", "."])
subprocess.run(["git", "-C", "test-repo", "commit", "-m", "init"])
subprocess.run(["git", "-C", "test-repo", "worktree", "add", "../wt1", "-b", "branch1"])
subprocess.run(["git", "-C", "test-repo", "worktree", "add", "../wt2", "-b", "branch2"])
def checkout_loop(worktree_path, branch):
for i in range(10):
# Force index writes by toggling branches
result = subprocess.run(
["git", "-C", worktree_path, "checkout", branch],
capture_output=True, text=True
)
if result.returncode != 0:
return f"{worktree_path}: FAILED at iteration {i}\n{result.stderr}"
time.sleep(0.01) # Small delay, still hits race condition
return f"{worktree_path}: OK"
# Run checkouts in parallel
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(checkout_loop, "wt1", "branch1"),
executor.submit(checkout_loop, "wt2", "branch2")
]
for future in concurrent.futures.as_completed(futures):
print(future.result())
Run this a few times. On my M1 MacBook, it fails within 3-5 runs:
wt1: FAILED at iteration 4
fatal: Unable to create '.git/index.lock': File exists.
2. Reflog Write Races
Less obvious: git commit updates the reflog for HEAD and the current branch. Each worktree has its own HEAD, but they all write to .git/logs/refs/heads/<branch>. If two worktrees commit to different branches at the same instant, they both try to append to their respective reflog files.
This usually succeeds because filesystem writes are atomic at the syscall level — but the lockfile mechanism (refs/heads/branch.lock) can still collide if you’re committing to the same branch from two worktrees (which you shouldn’t do, but nothing stops you).
Worst case: one commit’s reflog entry gets lost. The commit exists, but git reflog won’t show it. I’ve seen this once in a Jenkins setup where two jobs accidentally checked out the same branch in different worktrees and both ran git commit --amend. One reflog entry vanished. The commit was still reachable via git log, but HEAD@{1} pointed to the wrong SHA.
3. Config File Corruption
The scariest one. If two worktrees run git config simultaneously (common in CI: setting user.name, user.email, or custom config), they both read .git/config, modify it in memory, write to .git/config.lock, and rename.
If the timing is just wrong, you get:
- Lost config entries (last write wins, earlier changes discarded)
- Corrupted config file (partial writes if the process dies mid-rename)
- Silent failures (Git falls back to global config, your repo-level setting is ignored)
I hit this setting core.hooksPath in parallel worktrees. One worktree’s config write succeeded, the other’s hooksPath disappeared. Took me two hours to figure out why pre-commit hooks weren’t running in one worktree.
The Fix: Serialize Writes at the Worktree Level
Git’s locking is per-file, not per-worktree. You need external synchronization.
Option 1: Filesystem Lock (POSIX)
Use flock on Linux/macOS to serialize Git operations:
import subprocess
import fcntl
import time
from pathlib import Path
def git_with_lock(repo_path, git_args, lock_file=".git/worktree.lock"):
"""Run a Git command with exclusive lock on the .git directory."""
lock_path = Path(repo_path) / lock_file
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, 'w') as lock:
# Acquire exclusive lock (blocks if another process holds it)
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
result = subprocess.run(
["git", "-C", repo_path] + git_args,
capture_output=True, text=True
)
# Lock released automatically when file closes
return result
# Usage in parallel CI jobs
result = git_with_lock("test-repo", ["commit", "-m", "Fix race condition"])
if result.returncode != 0:
print(f"Commit failed: {result.stderr}")
This works, but has downsides:
flockisn’t available on Windows (useLockFilefromwin32fileinstead)- You’re serializing all Git operations, even read-only ones (could optimize by using shared locks for reads)
- Lock file cleanup: if the process crashes,
flockreleases automatically, but the lock file remains (harmless but messy)
Option 2: Build Orchestration (Better for CI)
Don’t let worktrees run Git commands in parallel. Use a build system that coordinates:
# GitHub Actions example
jobs:
test-branches:
runs-on: ubuntu-latest
strategy:
matrix:
branch: [main, feature-1, feature-2]
max-parallel: 1 # Force sequential execution
steps:
- uses: actions/checkout@v3
- run: |
git worktree add ../wt-${{ matrix.branch }} ${{ matrix.branch }}
cd ../wt-${{ matrix.branch }}
pytest # No git operations during test run
Key insight: most CI use cases don’t need to run Git commands during the parallel build. You set up worktrees sequentially, run tests in parallel (tests shouldn’t touch .git), then collect results. If you need to commit results, do it after all parallel jobs finish.
Option 3: Separate Clones (When Disk Is Cheap)
If you’re on a system with fast I/O and spare disk (cloud CI runners, not your laptop), just use separate clones:
# Instead of worktrees:
git clone repo.git build-1
git clone repo.git build-2
# Or use reference clones to share objects but keep separate .git:
git clone --reference repo.git repo.git build-1
git clone --reference repo.git repo.git build-2
Reference clones (--reference) share the object database (like worktrees) but have separate .git/index, .git/config, .git/HEAD — no lock contention. Downside: takes 2-3x more disk space than worktrees (each has its own working tree plus metadata).
For CI where you’re building in a fresh container each time, the disk overhead doesn’t matter. For local development with limited SSD space, it does.

When Worktrees Are Actually Safe
Worktrees work great if you follow one rule: never run Git commands in multiple worktrees simultaneously.
Safe use cases:
- IDE integrations: VS Code showing
mainin one window,featurein another. You’re only running Git commands in one at a time (whichever window has focus). - Sequential CI: Set up worktrees, run read-only operations (build, test) in parallel, commit results one at a time.
- Long-running services: Worktree 1 runs your dev server (no Git commands), worktree 2 is where you edit and commit.
Unsafe:
- Parallel
git commitin multiple worktrees (reflog corruption) - Parallel
git checkout(index lock collisions) - Any parallel
git configwrites (config file corruption)
What I’d Do Next Time
For CI pipelines, I’d skip worktrees entirely and use reference clones:
#!/bin/bash
REF_REPO="/tmp/shared-objects"
# One-time setup: create a bare clone to share objects
git clone --mirror https://github.com/user/repo.git "$REF_REPO"
# In each CI job:
git clone --reference "$REF_REPO" https://github.com/user/repo.git "job-$CI_JOB_ID"
Disk usage is 10-20% higher than worktrees, but you get complete isolation. No lock contention, no shared state, no weird race conditions.
For local development, worktrees are still useful — I use them to keep a clean main branch checked out while working on features. But I’ve stopped running Git commands in multiple worktrees at the same time. If I need to commit in both, I do it sequentially. The 2-second overhead is worth avoiding the debugging hell of a corrupted reflog.
One thing I haven’t tested: how does this interact with Git LFS? LFS stores objects in .git/lfs/objects, and I’d bet money there are race conditions there too. If you’re using LFS with worktrees and seeing intermittent errors, that might be why. (If anyone’s tested this, I’d love to hear about it.)
FAQ
Q: Can I safely run git status in parallel worktrees?
Yes, mostly. git status reads the index but doesn’t write to it (unless you have a dirty worktree and it needs to update the stat cache, which is rare). I’ve never seen git status cause corruption. But git checkout, git commit, git add — those are unsafe in parallel.
Q: Does git worktree use separate reflogs for each worktree?
Partially. Each worktree has its own HEAD reflog in .git/worktrees/<name>/logs/HEAD, but branch reflogs (.git/logs/refs/heads/<branch>) are shared. If you commit to the same branch from two worktrees (which you shouldn’t), they both write to the same reflog file, causing races.
Q: What happens if I delete a worktree while another is using it?
If you run git worktree remove ../wt1 while wt1 is in the middle of a commit, Git usually detects it and refuses (worktree is locked). But if you force-remove it (rm -rf), the other worktree might reference a deleted .git/worktrees/<name> directory and crash with fatal: not a git repository. Always use git worktree remove, never manual deletion.
If your build uses worktrees and you’re seeing random Git errors every 10th run, grab a good stress ball and start wrapping your Git commands in locks. Or just switch to reference clones and save yourself the pain.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,909 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (972 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (940 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (847 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (654 views)