Skip to content

Instantly share code, notes, and snippets.

@ondrasek
Last active May 15, 2026 08:55
Show Gist options
  • Select an option

  • Save ondrasek/f157866a809e05072f0887200b6cdde3 to your computer and use it in GitHub Desktop.

Select an option

Save ondrasek/f157866a809e05072f0887200b6cdde3 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# Auto-commit hook: detects dirty git state and delegates to Claude via exit 2
# Follows fail-fast: detects the first problem, reports it, and exits 2.
# Claude fixes it, hook re-runs, catches the next issue, and so on.
#
# Check order (workflow sequence):
# 1. Behind remote → pull/rebase first
# 2. Untracked files → stage or gitignore
# 3. Unstaged changes → stage
# 4. Staged uncommitted → commit
# 5. Unpushed commits → push
#
# Exit code 0 = all clean, nothing to do
# Exit code 2 = blocking — Claude will act on the instructions printed to stderr
fail() {
echo "" >&2
echo "AUTO-COMMIT HOOK: $1" >&2
echo "$2" >&2
exit 2
}
# --- Guard: not inside a git repo → exit silently ---
git rev-parse --is-inside-work-tree &>/dev/null || exit 0
# Guard against infinite loop: exit if we're already in a hook
[ -n "$CLAUDE_HOOK_RUNNING" ] && exit 0
export CLAUDE_HOOK_RUNNING=1
# Navigate to the appropriate working tree.
# In a worktree .git is a file (gitdir: pointer), not a directory.
# If already in a worktree, stay here — CLAUDE_PROJECT_DIR may point to
# the main repo and we must not auto-commit the wrong branch.
_toplevel=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -n "$_toplevel" ] && [ -f "$_toplevel/.git" ]; then
cd "$_toplevel"
else
cd "${CLAUDE_PROJECT_DIR:-.}"
fi
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
HAS_REMOTE=false
if [ -n "$BRANCH" ] && git rev-parse "origin/$BRANCH" &>/dev/null; then
HAS_REMOTE=true
fi
# ─── 1. Behind remote (must integrate before anything else) ──────────────────
if $HAS_REMOTE; then
behind=$(git log "HEAD..origin/$BRANCH" --oneline 2>/dev/null)
if [ -n "$behind" ]; then
behind_count=$(echo "$behind" | wc -l | tr -d ' ')
fail "Branch is behind remote — integrate now." \
"Your local branch is $behind_count commit(s) behind origin/$BRANCH."$'\n'"Spawn a sub-agent using the Agent tool (subagent_type: general-purpose) to handle this — do not perform the git work yourself in the main conversation. Give the sub-agent the full context above. The sub-agent's ultimate goal is to leave the working tree clean and the branch pushed, by carrying out this full sequence in one pass: (1) integrate remote changes via 'git pull' (creating a merge commit; do NOT rebase or squash), resolving any merge conflicts, (2) stage any intended changes — tracking new files or adding them to .gitignore, (3) commit with a Conventional Commits message (https://www.conventionalcommits.org) including a detailed body explaining what and why and a 'Co-Authored-By: Claude <noreply@anthropic.com>' trailer, (4) push to origin. Currently blocked on step 1: integrating origin/$BRANCH into the local branch. Tell the sub-agent to continue through steps 2-4 after integration so the next hook run finds a clean tree."
fi
fi
# ─── 2. Untracked files (not gitignored) ────────────────────────────────────
untracked=$(git ls-files --others --exclude-standard)
if [ -n "$untracked" ]; then
file_count=$(echo "$untracked" | wc -l | tr -d ' ')
file_list=$(echo "$untracked" | head -20)
detail="$file_count untracked file(s) not in .gitignore:"$'\n'"$file_list"
[ "$file_count" -gt 20 ] && detail="$detail"$'\n'" ... and $((file_count - 20)) more"
fail "Untracked files must be dealt with." \
"$detail"$'\n'"Spawn a sub-agent using the Agent tool (subagent_type: general-purpose) to handle this — do not perform the git work yourself in the main conversation. Give the sub-agent the full context above. The sub-agent's ultimate goal is to leave the working tree clean and the branch pushed, by carrying out this full sequence in one pass: (1) integrate any remote changes via 'git pull' (creating a merge commit; do NOT rebase or squash), resolving any merge conflicts, (2) stage all intended changes — classifying every listed untracked file as either 'git add'-worthy or '.gitignore'-worthy and acting accordingly, plus staging any other modified tracked files, (3) commit with a Conventional Commits message (https://www.conventionalcommits.org) including a detailed body explaining what and why and a 'Co-Authored-By: Claude <noreply@anthropic.com>' trailer, (4) push to origin. Currently blocked on step 2: classifying and handling the untracked files above. Tell the sub-agent to continue through steps 3-4 after that so the next hook run finds a clean tree."
fi
# ─── 3. Unstaged modifications to tracked files ─────────────────────────────
unstaged=$(git diff --name-only)
if [ -n "$unstaged" ]; then
unstaged_count=$(echo "$unstaged" | wc -l | tr -d ' ')
unstaged_list=$(echo "$unstaged" | head -20)
detail="$unstaged_count tracked file(s) with unstaged changes:"$'\n'"$unstaged_list"
[ "$unstaged_count" -gt 20 ] && detail="$detail"$'\n'" ... and $((unstaged_count - 20)) more"
fail "Tracked files have unstaged modifications." \
"$detail"$'\n'"Spawn a sub-agent using the Agent tool (subagent_type: general-purpose) to handle this — do not perform the git work yourself in the main conversation. Give the sub-agent the full context above. The sub-agent's ultimate goal is to leave the working tree clean and the branch pushed, by carrying out this full sequence in one pass: (1) integrate any remote changes via 'git pull' (creating a merge commit; do NOT rebase or squash), resolving any merge conflicts, (2) stage all modified tracked files listed above, (3) commit with a Conventional Commits message (https://www.conventionalcommits.org) including a detailed body explaining what and why and a 'Co-Authored-By: Claude <noreply@anthropic.com>' trailer, (4) push to origin. Currently blocked on step 2: staging the modified files above. Tell the sub-agent to continue through steps 3-4 after staging so the next hook run finds a clean tree."
fi
# ─── 4. Staged but uncommitted changes ──────────────────────────────────────
staged=$(git diff --cached --name-only)
if [ -n "$staged" ]; then
staged_count=$(echo "$staged" | wc -l | tr -d ' ')
staged_list=$(echo "$staged" | head -20)
detail="$staged_count staged file(s) awaiting commit:"$'\n'"$staged_list"
[ "$staged_count" -gt 20 ] && detail="$detail"$'\n'" ... and $((staged_count - 20)) more"
fail "Staged changes must be committed." \
"$detail"$'\n'"Spawn a sub-agent using the Agent tool (subagent_type: general-purpose) to handle this — do not perform the git work yourself in the main conversation. Give the sub-agent the full context above. The sub-agent's ultimate goal is to leave the working tree clean and the branch pushed, by carrying out this full sequence in one pass: (1) integrate any remote changes via 'git pull' (creating a merge commit; do NOT rebase or squash), resolving any merge conflicts, (2) confirm everything intended is staged, (3) commit the staged changes using a Conventional Commits message (https://www.conventionalcommits.org) with a detailed body explaining what changed and why and a 'Co-Authored-By: Claude <noreply@anthropic.com>' trailer, (4) push to origin. Currently blocked on step 3: committing the staged files above. Tell the sub-agent to continue through step 4 immediately after committing so the next hook run finds a clean tree."
fi
# ─── 5. Unpushed commits (ahead of remote) ──────────────────────────────────
if $HAS_REMOTE; then
unpushed=$(git log "origin/$BRANCH..HEAD" --oneline 2>/dev/null)
if [ -n "$unpushed" ]; then
unpushed_count=$(echo "$unpushed" | wc -l | tr -d ' ')
fail "Local commits must be pushed." \
"There are $unpushed_count unpushed commit(s) on $BRANCH that exist only locally."$'\n'"Spawn a sub-agent using the Agent tool (subagent_type: general-purpose) to handle this — do not perform the git work yourself in the main conversation. Give the sub-agent the full context above. The sub-agent's ultimate goal is to leave the working tree clean and the branch pushed, by carrying out this full sequence in one pass: (1) integrate any remote changes via 'git pull' (creating a merge commit; do NOT rebase or squash) if origin/$BRANCH has moved, (2) stage any intended changes, (3) commit with a Conventional Commits message (https://www.conventionalcommits.org) including a detailed body and a 'Co-Authored-By: Claude <noreply@anthropic.com>' trailer, (4) push $BRANCH to origin. Currently blocked on step 4: pushing the $unpushed_count local commit(s). Tell the sub-agent to push now so the next hook run finds a clean tree."
fi
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment