You asked Claude Code to add, commit, and push a small change. Instead of finishing, it stopped on fatal: Unable to create '.git/index.lock': File exists. and refused to move. If you only see this in VMs, containers, or CI sandboxes — but never on your laptop — you're not alone. I hit this constantly when I started running Claude Code inside scheduled tasks, and the official "just delete the lock file" advice almost never holds for more than a day.
The reason this is hard to chase down is that the error message points at one symptom while the actual cause is usually three layers stacked on top of each other: ownership mismatch, leftover state from a previous bash session, and slow file system flushes. Treat only one and the next run will fail in the same place.
After enough scheduled-task crashes I gave up trying to "fix" the lock and switched to a layered set of workarounds that match the environment I was running in. The three patterns below are the ones that have survived months of nightly automation for me.
Why the same git command works locally but freezes inside a VM
It's tempting to blame Claude Code, but the CLI is just shelling out to git. The lock collision lives in the environment. Three things tend to go wrong inside a sandbox that simply do not happen on your laptop.
- Ownership drift. When the VM creates
/tmp(or your working directory) under a special user likenobody:nogroup, Claude Code's bash sessions — usually running asrootor a regular user — can't write into it cleanly.git addpartially succeeds, leaves a half-built index, and the lock survives. - Zombie state from prior bash calls. Each Claude Code bash invocation runs in its own process. If a previous
git commitwas killed mid-flight (timeout, SIGTERM, container reaping), the lock file persists with no parent process to clean it up. - File system flush latency. Shadow mounts, NFS-style volumes, and overlay file systems used by sandboxes don't always make a
rmimmediately visible to the next process. The lock looks gone, thengitsees it again half a second later.
The "zombie state" version is by far the most common in environments like Cowork mode, where every bash call boots a fresh shell and the previous one's processes are unreachable. You can't kill what you don't have a PID for, so removing the lock is your only real option.
Workaround 1: Sweep the lock files and reset before every commit
If the failure is sporadic — once a week or so — the smallest fix is to wrap every commit with a deterministic cleanup. Three lines before git add will solve most cases.
# 1. Remove any lingering lock files (index, HEAD, refs)
find .git -maxdepth 2 -name "*.lock" -type f -delete 2>/dev/null
# 2. Force the index back into a known state
git reset --mixed HEAD 2>/dev/null
# 3. Wait for the file system to flush (NFS / shadow mount safety)
sync
sleep 1
# 4. Commit normally
git add content/
git commit -m "Add: new article"The find is doing real work here. index.lock is the most famous one, but HEAD.lock and refs/heads/main.lock show up just as often, and missing either of those will hang the next command. The -maxdepth 2 keeps you from accidentally deleting valid lock files inside .git/objects/ during a pack operation.
This pattern is the right starting point if you want to keep your local script and your sandbox script identical. It's a small wedge that handles the everyday case without restructuring anything.
Workaround 2: Move the working directory to a writable home
If layer 1 isn't enough — you delete the lock today and tomorrow it's stuck again on the very first git add — the issue is almost certainly ownership on the parent directory. VMs that recycle /tmp between boots sometimes recreate /tmp/repos under a user you can't write to.
I solved this for good by adding a writability probe before cloning. If /tmp/repos is hostile that day, the script silently moves to $HOME/repos instead.
WORK="/tmp/repos/myproject"
# Probe: fall back to $HOME/repos if /tmp/repos is read-only
mkdir -p "$(dirname "$WORK")" 2>/dev/null
if ! touch "$(dirname "$WORK")/.write_test" 2>/dev/null; then
echo "⚠️ /tmp/repos is not writable — falling back to $HOME/repos"
WORK="$HOME/repos/myproject"
mkdir -p "$HOME/repos"
fi
rm -f "$(dirname "$WORK")/.write_test" 2>/dev/null
# Use $WORK from here on
git clone --depth 1 "$REPO_URL" "$WORK"
cd "$WORK"What this gives you is a script that auto-corrects on the days the sandbox state is bad, without any manual intervention. I run this in combination with the patterns from Fixing Claude Code Bash tool execution errors, which catches the related "command silently dies" failure mode. Together they removed almost all of the "what happened overnight?" debugging time from my scheduled tasks.
Workaround 3: Skip local git entirely and push via the GitHub REST API
For jobs where I cannot afford another failed run — daily content pushes that other systems depend on — I stopped fighting .git/index.lock and switched to the GitHub Git Data API. The code is longer, but lock collisions become structurally impossible: there is no .git directory to lock.
# Required env: GITHUB_TOKEN, OWNER, REPO, BRANCH (defaults to main)
TOKEN="$GITHUB_TOKEN"
OWNER="masakihirokawa"
REPO="myproject"
BRANCH="main"
API="https://api.github.com/repos/${OWNER}/${REPO}"
# 1. Upload the file as a base64 blob
CONTENT=$(base64 -w0 < content/articles/en/sample.mdx)
BLOB_SHA=$(curl -sS -X POST -H "Authorization: token $TOKEN" \
-d "{\"content\":\"${CONTENT}\",\"encoding\":\"base64\"}" \
"${API}/git/blobs" | jq -r .sha)
# 2. Get the branch tip and its tree
PARENT_SHA=$(curl -sS -H "Authorization: token $TOKEN" \
"${API}/git/refs/heads/${BRANCH}" | jq -r .object.sha)
BASE_TREE=$(curl -sS -H "Authorization: token $TOKEN" \
"${API}/git/commits/${PARENT_SHA}" | jq -r .tree.sha)
# 3. Build a new tree with the new file layered on top
TREE_SHA=$(curl -sS -X POST -H "Authorization: token $TOKEN" \
-d "{\"base_tree\":\"${BASE_TREE}\",\"tree\":[{\"path\":\"content/articles/en/sample.mdx\",\"mode\":\"100644\",\"type\":\"blob\",\"sha\":\"${BLOB_SHA}\"}]}" \
"${API}/git/trees" | jq -r .sha)
# 4. Create the commit and advance the ref
COMMIT_SHA=$(curl -sS -X POST -H "Authorization: token $TOKEN" \
-d "{\"message\":\"Add sample\",\"tree\":\"${TREE_SHA}\",\"parents\":[\"${PARENT_SHA}\"]}" \
"${API}/git/commits" | jq -r .sha)
curl -sS -X PATCH -H "Authorization: token $TOKEN" \
-d "{\"sha\":\"${COMMIT_SHA}\"}" \
"${API}/git/refs/heads/${BRANCH}"Those four steps — Blobs → Trees → Commits → Refs — are the canonical Git Data API flow. For a single file the whole sequence finishes in under five seconds and never touches the file system, so ownership and flush latency are out of the picture.
I would not recommend rewriting your day-to-day workflow this way. But for jobs that absolutely need to ship — a daily content push, a release commit, a status update — this pattern is worth the extra lines. If you want to deepen the surrounding design, Claude Code git push troubleshooting, end to end and Resilience patterns for long-running Claude Code automation make a good follow-up read.
How to choose between the three
Each workaround trades effort for breadth, and that trade-off is what should drive the decision.
- Sporadic failures (once a week or so): stay with workaround 1 — the lock sweep plus
git reset. It keeps your local and sandbox scripts almost identical, which is its biggest advantage. - Predictable failures after every VM restart: add workaround 2 — the writable-directory probe. Ownership problems do not heal with a single
rm; you need a different parent directory. - Jobs that must not fail: switch to workaround 3 — the REST API path. You give up local git ergonomics, but you also give up every category of lock-related failure at once.
The trap I fell into early on was layering all three preemptively. The script became hard to read, the symptoms got harder to reason about, and I couldn't tell which layer was actually saving me. Diagnose first, then add only the workaround that matches the layer where the failure lives.
A 30-second sanity check before you blame git
When the failure first shows up, it's worth ruling out a few cheap explanations before you reach for any of the workarounds above. I keep this short diagnostic snippet in my notes:
# Who owns this directory?
ls -ld . .git
# Is anything actually holding the lock?
ls -la .git/*.lock 2>/dev/null
fuser .git/index.lock 2>/dev/null # may not be available in sandboxes
# How much disk is left? ENOSPC fakes a lock symptom on some systems
df -h .If ls -ld . shows an owner that doesn't match the user from whoami, you're looking at workaround 2. If df -h . shows under 50 MB free, every workaround above will eventually fail — clean up before you do anything else. And if fuser reports a live PID, do not delete the lock; let that command finish or kill the process first.
Building this 30-second check into your script means you stop chasing ghost problems. Most of my "weird sandbox bugs" turned out to be one of those three lines telling me exactly what was wrong.
The single most useful thing you can do right now is add workaround 1's four lines in front of your existing git add. You'll feel the difference in your weekly failure rate within days.