From ce8c748462c16d779f699cf0f3eee56ecee26126 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 09:57:30 +0100 Subject: [PATCH] chore(config): add git-lock-guard PreToolUse hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears stale .git/index.lock files before git write commands. Claude Code's internal git status polling leaves orphan locks (anthropics/claude-code#11005) that block add/commit/merge/push. The hook checks lsof/fuser before removing — only stale locks are cleared, never locks held by live processes. Read-only git commands are skipped entirely. Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/git-lock-guard.sh | 63 +++++++++++++++++++++++++++++++++ .claude/settings.json | 14 ++++++++ 2 files changed, 77 insertions(+) create mode 100755 .claude/hooks/git-lock-guard.sh diff --git a/.claude/hooks/git-lock-guard.sh b/.claude/hooks/git-lock-guard.sh new file mode 100755 index 000000000..996b46788 --- /dev/null +++ b/.claude/hooks/git-lock-guard.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# git-lock-guard: PreToolUse hook that clears stale git index.lock files +# before git write operations. +# +# Claude Code's internal git status polling creates stale lock files that +# persist after the process exits (anthropics/claude-code#11005). This hook +# detects and removes them before LLM-initiated git commands run. +# +# Only acts on git write commands (add, commit, merge, push, etc.). +# Read-only commands (status, log, diff, show) are skipped. +set -uo pipefail + +INPUT=$(cat) + +# Only act on Bash tool calls +TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null) +[ "$TOOL_NAME" = "Bash" ] || exit 0 + +COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null) + +# Only act on git write commands — skip read-only operations +case "$COMMAND" in + git\ add*|git\ commit*|git\ merge*|git\ push*|git\ pull*|\ + git\ checkout*|git\ stash*|git\ rebase*|git\ reset*|\ + git\ cherry-pick*|git\ rm*|git\ mv*|git\ fetch*) + ;; + *) + exit 0 + ;; +esac + +# Resolve the git directory for the current working directory +CWD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null) +[ -n "$CWD" ] || exit 0 + +# Find the .git directory (handles both regular repos and worktrees) +GIT_DIR=$(git -C "$CWD" rev-parse --git-dir 2>/dev/null) || exit 0 + +# For worktrees, also check the common git dir (shared index operations) +GIT_COMMON_DIR=$(git -C "$CWD" rev-parse --git-common-dir 2>/dev/null) || GIT_COMMON_DIR="$GIT_DIR" + +for DIR in "$GIT_DIR" "$GIT_COMMON_DIR"; do + LOCK="$DIR/index.lock" + [ -f "$LOCK" ] || continue + + # Check if any process holds the lock + if command -v lsof &>/dev/null; then + if lsof "$LOCK" &>/dev/null; then + # Lock is held by a live process — don't touch it + continue + fi + elif command -v fuser &>/dev/null; then + if fuser "$LOCK" &>/dev/null 2>&1; then + continue + fi + fi + + # Stale lock — remove it + rm -f "$LOCK" 2>/dev/null +done + +# Always allow the command to proceed +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 04439fc2f..0f478d80d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -80,5 +80,19 @@ "Bash(git clean -f *)", "Bash(rm -rf *)" ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/git-lock-guard.sh", + "timeout": 5 + } + ] + } + ] } }