Use PreToolUse Hooks to intercept git commands in Bash — block direct commits to main, enforce commit message formats, and prevent dangerous operations.
Claude Code has permission to run git commit, git push, and git checkout directly. That's convenient most of the time — but it also means Claude might:
Hooks let you intercept these operations before they happen — validate, enforce standards, or block dangerous actions outright.
All git operations in Claude Code go through the Bash tool. So the interception point is:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [...]
}
]
}
}
The Hook receives the full command string via stdin. Check whether it contains specific git operations, then decide whether to allow or block.
Basic pattern:
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
if echo "$cmd" | grep -qE '^git commit'; then
echo "Does not meet standards" >&2
exit 2 # Block and notify Claude
fi
The most common requirement. Claude should work on feature branches, not push directly to main.
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
echo "$cmd" | grep -qE '^git (commit|push)' || exit 0
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [[ "$current_branch" == "main" || "$current_branch" == "master" ]]; then
echo "Direct commits to $current_branch are not allowed. Switch to a feature branch first." >&2
exit 2
fi
When Claude gets this message, it will switch to an appropriate branch before continuing.
Many teams use Conventional Commits: feat: ..., fix: ..., docs: .... But Claude's commit messages won't necessarily match your format.
Validate before committing:
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
echo "$cmd" | grep -qE '^git commit' || exit 0
# Extract the message after -m
msg=$(echo "$cmd" | grep -oP '(?<=-m )["\x27].*?["\x27]' | tr -d '"'"'" || true)
if [[ -z "$msg" ]]; then
exit 0 # No -m flag, probably using an editor — skip
fi
if ! echo "$msg" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{3,}'; then
echo "Commit message format is incorrect." >&2
echo "Required format: <type>(<scope>): <description>" >&2
echo "Example: feat(auth): add OAuth login" >&2
echo "Valid types: feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert" >&2
exit 2
fi
Claude receives the feedback and rewrites the commit message in the correct format.
You want every Claude commit to include attribution — tracking which commits had AI involvement. But you don't want to type it manually every time.
PostToolUse is better here — check after the commit completes, then prompt Claude to amend if attribution is missing:
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
echo "$cmd" | grep -qE '^git commit' || exit 0
echo "$cmd" | grep -q 'amend' && exit 0 # Avoid infinite loop
last_msg=$(git log -1 --format="%B" 2>/dev/null)
if ! echo "$last_msg" | grep -q 'Co-Authored-By'; then
echo "Commit is missing Co-Authored-By attribution." >&2
echo "Run: git commit --amend -m \"\$(git log -1 --format='%s')\" --trailer 'Co-Authored-By: Claude Sonnet 4.6 <[email protected]>'" >&2
exit 2
fi
Some commands are hard to recover from:
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
# Block force push to main branches
if echo "$cmd" | grep -qE 'git push.*--force(-with-lease)?.*\b(main|master)\b'; then
echo "Force pushing to main/master is not allowed." >&2
exit 2
fi
# Block reset --hard (require explicit override)
if echo "$cmd" | grep -qE 'git reset --hard'; then
echo "git reset --hard is a destructive operation and has been blocked. If you genuinely need this, add a # ALLOW: comment explaining why." >&2
exit 2
fi
# Block deleting remote branches
if echo "$cmd" | grep -qE 'git push.*--delete|git push.*:'; then
echo "Deleting remote branches is not allowed. Do this manually on GitHub if needed." >&2
exit 2
fi
The # ALLOW: trick for reset --hard lets Claude "unlock" the operation when it's genuinely necessary — while still requiring an explicit justification.
Log every git operation Claude executes:
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
echo "$cmd" | grep -q '^git' || exit 0
echo "$(date '+%Y-%m-%d %H:%M:%S') GIT: $cmd" >> ~/.claude/git-audit.log
Use PostToolUse for this — log only commands that actually ran.
.claude/hooks/git-guard.sh:
#!/bin/bash
set -e
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
[[ -z "$cmd" ]] && exit 0
echo "$cmd" | grep -q '^git' || exit 0
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
# 1. Block commits and pushes on main/master
if echo "$cmd" | grep -qE '^git (commit|push)'; then
if [[ "$current_branch" == "main" || "$current_branch" == "master" ]]; then
echo "Currently on $current_branch — direct commits and pushes are blocked. Switch to a feature branch." >&2
exit 2
fi
fi
# 2. Commit message format validation
if echo "$cmd" | grep -qE '^git commit.*-m'; then
msg=$(echo "$cmd" | grep -oP '(?<=-m )["\x27][^\x27"]*["\x27]' | tr -d '"'"'" || true)
if [[ -n "$msg" ]] && ! echo "$msg" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{3,}'; then
echo "Commit message format is incorrect." >&2
echo "Format: <type>(<scope>): <description>" >&2
echo "Example: fix(api): handle null response from upstream" >&2
exit 2
fi
fi
# 3. Block dangerous operations (unless ALLOW override is present)
if echo "$cmd" | grep -qE 'git push.*--force|git reset --hard|git push.*--delete'; then
if ! echo "$cmd" | grep -q '# ALLOW:'; then
echo "Dangerous operation blocked: $cmd" >&2
echo "To proceed, add # ALLOW: <reason> to the end of the command." >&2
exit 2
fi
fi
settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/git-guard.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'cmd=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .command); echo \"$cmd\" | grep -q \"^git\" && echo \"$(date +\\'%Y-%m-%d %H:%M:%S\\') $cmd\" >> ~/.claude/git-audit.log || true'"
}
]
}
]
}
}
Use Bash as the matcher, not Git
Claude Code doesn't have a dedicated Git tool — git commands all go through Bash. The matcher must be "Bash".
Don't over-rely on regex for command parsing
Git commands come in many forms: git commit -m "msg", git commit --message="msg", GIT_AUTHOR_NAME=xxx git commit. Overly strict regex will miss edge cases. Intercept the most common dangerous patterns and trust Claude's judgment for the rest.
Global vs. project-level placement
Main branch protection and commit format rules belong in project-level config (.claude/settings.json) — branch names and commit conventions vary by project. Audit logs belong in global config, writing to a shared ~/.claude/git-audit.log.
Managing git workflows with Hooks comes down to: PreToolUse + Bash matcher + git command detection.
Three rules worth configuring first:
1. Block direct commits to main
2. Enforce commit message format
3. Intercept force push, reset --hard, and other destructive operations
Pair these with an audit log and every git action Claude takes becomes traceable. Start with the simplest rule, get it running, then layer in the rest.