If you use Claude Code with a custom status line that runs git status or git branch, you’re going to hit this error the moment you ask it to do anything involving git mv, git commit, or git add:
fatal: Unable to create '.git/index.lock': File exists.
Another git process seems to be running in this repository.
I wasted an embarrassing amount of time on this during a large file-move refactor before figuring out what was happening. There’s a one-line setting that can get you out of it, but I’m not sure it’s truly a fix.
The symptom
I was running a codebase restructure — moving ~150 files from src/lib/ into a new src/infrastructure/ directory. Every git mv command Claude Code tried to run failed with the index lock error. Every single one.
My thought process went like this:
- VS Code’s built-in git? Disabled it.
git.enabled: falsein settings. Still failing. - Powerlevel10k’s gitstatusd? Killed all instances with
kill $(pgrep -f gitstatusd). Still failing. - Stale lock file? Removed it with
rm -f .git/index.lock. It came back instantly — not after a few seconds, but instantly.
The lock wasn’t being held by a long-running process. Something was creating it at the exact moment Claude Code tried to use git.
The cause
My Claude Code ~/.claude/settings.json had a custom status line:
{
"statusLine": {
"type": "command",
"command": "... git_dirty=''; [[ $(git status --porcelain 2>/dev/null) ]] && git_dirty='*'; branch=$(git branch --no-color 2>/dev/null | sed ...) ..."
}
}
This runs git status --porcelain and git branch to display the current branch and dirty state in Claude Code’s status bar. It renders on every prompt cycle — which means every time Claude Code is about to execute a tool call, the status line fires first, grabs the git index lock to run git status, and the actual git mv or git commit command fails because the lock is already held.
The race condition is nearly invisible during normal work because most tool calls don’t touch git. But during a refactor where every command is a git mv, you hit it on literally every single call. It looks like the lock file is being held by a ghost process, because by the time you manually check with lsof or ls, the status line command has already finished and released it. The lock exists for milliseconds — just long enough to collide with the git operation Claude Code is trying to run.
The quick “fix”
{
"disableAllHooks": true
}
Add this to ~/.claude/settings.json. It disables hooks and the status line. The git mv commands start working immediately. But this is a hack — you lose all your hooks too, and you’ll forget to re-enable it.
The real fix: stop shelling out to git
The root cause is that the status line runs git status and git branch on every render cycle. The fix is to rewrite it so it doesn’t touch the git index at all.
Option 1: Read .git/HEAD directly instead of running git branch
The current branch is just a file read — no lock required:
branch=$(cat .git/HEAD 2>/dev/null | sed 's|ref: refs/heads/||')
This gives you the branch name without touching the index. It’s instantaneous and can never race with a git operation.
Option 2: Drop dirty state, keep branch
The git status --porcelain call is what checks dirty state (the * indicator). That’s the expensive, lock-acquiring part. If you can live without the dirty indicator, replace the whole status line with:
{
"statusLine": {
"type": "command",
"command": "input=$(cat); cwd=$(echo \"$input\" | jq -r '.workspace.current_dir'); cd \"$cwd\" 2>/dev/null || cd \"$HOME\"; green=$(tput setaf 114); magenta=$(tput setaf 176); white=$(tput setaf 66); reset=$(tput sgr0); branch=$(cat .git/HEAD 2>/dev/null | sed 's|ref: refs/heads/||'); git_info=''; [[ -n \"$branch\" ]] && git_info=\" on ${magenta}${branch}${white}\"; printf \"${green}%s${white}%s${reset}\" \"$cwd\" \"$git_info\""
}
}
No git status. No git branch. Just a file read. Zero lock contention.
Option 3: Cache dirty state on a timer
If you really want the dirty indicator, write it to a temp file on a schedule rather than checking it on every render:
# In a background process or cron (every 5s)
git status --porcelain > /tmp/git-dirty-$$ 2>/dev/null
# In the status line (reads cached state, no git call)
[[ -s /tmp/git-dirty-$$ ]] && git_dirty='*'
This is more complex than it’s worth for most people. Option 2 is the sweet spot.
Why this is hard to diagnose
Three things conspire to make this a nightmare to debug:
1. The lock is transient. By the time you run ls .git/index.lock in a separate terminal, the status line command has finished. The file doesn’t exist. You try git mv again. It fails again. The file still doesn’t exist when you check. You start questioning reality.
2. The usual suspects are innocent. Everyone’s first instinct is “disable VS Code git” or “kill gitstatusd.” Those are real causes of index lock issues in other contexts, so you spend 20 minutes chasing them before realising neither is the culprit.
3. The status line runs invisibly. There’s no log, no spinner, no indication that a shell command just ran. It’s a background rendering concern — you set it up once and forget it exists. When you’re debugging “why can’t Claude Code run git commands,” you’re thinking about Claude Code’s tool execution, not the chrome around the input box.
The broader lesson
Any background process that runs git status is a race condition against foreground git operations. This is true of Powerlevel10k’s gitstatusd, VS Code’s git integration, and now Claude Code’s status line. The difference is that the first two are well-known culprits — they’re on everyone’s mental checklist when debugging lock issues. The Claude Code status line is new enough that it isn’t.
The rule is simple: if your status line needs git info, read files from .git/ directly — don’t shell out to git commands that acquire the index lock. Branch is just cat .git/HEAD. That’s free. Dirty state requires git status, which requires the lock. If you need dirty state, cache it rather than computing it on every render.