Last active
February 26, 2026 19:20
-
-
Save ondrasek/f157866a809e05072f0887200b6cdde3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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. You must integrate the remote changes using git before doing anything else. Do not make any other changes until the branch is up to date." | |
| 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'"You must handle every untracked file listed above. Use git to track files that belong in the repository, and add entries to .gitignore for files that do not. Do not leave any untracked files unresolved." | |
| 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'"You must stage all modified tracked files listed above using git. Do not proceed until every modification is staged." | |
| 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'"You must commit all staged changes now using git. Follow Conventional Commits (https://www.conventionalcommits.org) for the message format. Write a detailed commit body explaining what changed and why. Include Co-Authored-By: Claude <noreply@anthropic.com> in the commit message." | |
| 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. You must push them to origin using git. Do not continue until the remote is up to date with your local branch." | |
| fi | |
| fi | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment