#!/usr/bin/env bash # Regression matrix for git-guard.py: `bash ~/.claude/hooks/git-guard-test.sh` (exit 1 on mismatch). # Kept as a file on purpose: typed inline, the very commands under test would trip the live hook. G="$(dirname "$(readlink -f "$0")")/git-guard.py" fail=0 # No exempt repository for the rows that do not ask for one, whatever this host's ~/.claude/git-guard-exempt says. export GIT_GUARD_EXEMPT="" # `git reset ` is allowed only for an existing path: give the matrix one to point at. tmp=$(mktemp -d) && cd "$tmp" && touch existing.py trap 'rm -rf "$tmp"' EXIT run() { printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null rc=$? got=allow [ $rc -eq 2 ] && got=block [ "$got" = "$1" ] && mark=ok || { mark=MISMATCH fail=1 } printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)" } echo "--- must block: commits / history ---" run block 'git push' run block 'git -C /home/coja/.dots push origin main' run block 'git -c user.name=x commit -m fix' run block '/usr/bin/git push' run block 'env GIT_DIR=.git git push' run block 'timeout 5 git push' run block 'sudo -u me git push' run block 'cd /tmp && git push' run block 'git status; git commit -am wip' run block 'x=1; git push' run block 'git push 2>&1 | tee log' run block "sh -c 'git push origin HEAD'" run block 'eval "git push"' run block 'git -C $(pwd) push' run block 'echo "$(git push)"' run block 'git --git-dir=/x/.git --work-tree=/x push' run block 'git commit -m "fix: grep pattern; not a command"' run block 'git revert HEAD' run block 'git cherry-pick abc123' run block 'git rebase -i HEAD~3' run block 'git rebase --interactive main' run block 'git -C . rebase -i --autosquash main' run block 'git rebase --autostash origin/main' run block 'git -c rebase.autoStash=true rebase main' run block 'git merge feature' run block 'git merge --no-ff feature' echo "--- must block: discards working-tree changes ---" run block 'git reset --hard HEAD~1' run block 'git reset HEAD~1 --hard' run block 'git reset --soft HEAD~1' run block 'git reset --keep HEAD~2' run block 'git reset --merge' run block 'git reset' run block 'git reset -q' run block 'git reset HEAD' run block 'git reset HEAD~1' run block 'git reset abc1234' run block 'git reset 0123456789abcdef0123456789abcdef01234567' run block 'git reset origin/main' run block 'git reset @{u}' run block 'git restore --staged .' run block 'git restore --staged :/' run block 'git clean -fd' run block 'git -C . clean -n' run block 'git restore file.py' run block 'git restore --staged --worktree file.py' run block 'git restore -W file.py' run block 'git checkout -- .' run block 'git checkout -- file.py' run block 'git checkout HEAD -- file.py' run block 'git checkout .' run block 'git checkout -f main' run block 'git stash drop' run block 'git stash pop' run block 'git stash clear' run block 'git branch -D feature' run block 'git branch -f main HEAD~1' run block 'git branch -M main trunk' echo "--- must block: gh pr write ops (create only with --head, edit only title/body) ---" run block 'gh pr merge 12 --squash' run block 'gh pr close 4' run block 'gh -R o/r pr create --fill' run block 'gh pr create --title x --body y' run block 'gh pr create --dry-run --fill' run block 'gh pr edit 3 --add-reviewer bob' run block 'gh pr edit 3 --title x --base main' run block 'gh pr edit --add-label=bug 3' run block 'gh pr edit 3 -b y -m v2' echo "--- must allow ---" run allow 'git status' run allow 'git log -1 --oneline' run allow 'git diff --cached --stat' run allow 'git add -A common/.config/claude' run allow 'git rev-parse --git-path CLAUDE_COMMIT_MSG' run allow 'git -c core.pager=cat log -3' run allow 'git reset -- file.txt' run allow 'git reset HEAD file.txt' run allow 'git reset HEAD existing.py other.py' run allow 'git reset existing.py' run allow 'git reset -p' run allow 'git reset -- .' run allow 'git restore --staged existing.py' run allow 'git restore --staged file.py' run allow 'git restore -S file.py' run allow 'git checkout main' run allow 'git checkout -b feature' run allow 'git switch main' run allow 'git merge --abort' run allow 'git rebase main' run allow 'git rebase origin/main' run allow 'git rebase -Xtheirs main' run allow 'git rebase --continue' run allow 'git rebase --abort' run allow 'git -c core.editor=true rebase --continue' run allow 'GIT_EDITOR=true git rebase --continue' run allow 'git stash' run allow 'git stash list' run allow 'git stash push -m wip' run allow 'git stash show -p' run allow 'git branch -d merged-branch' run allow 'git branch --list' run allow 'git branch -vv' run allow 'git commit-tree HEAD^{tree} -m x' run allow 'git worktree list' run allow 'grep -rn "git push" README.md' run allow 'grep -n "foo; git commit" file' run allow "echo 'git push'" run allow 'echo "run git push"' run allow "cat > README.md <<'EOF' Deploy with: git status; git commit -am x; git push origin main EOF echo written" run allow 'python3 - < : the hook input carries the session's cwd, as Claude Code sends it. R=$tmp/repo WT=$tmp/repo-wt git init -q "$R" && git -C "$R" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init \ && git -C "$R" worktree add -q "$WT" 2>/dev/null && mkdir -p "$R/sub" && git init -q "$tmp/other" runin() { printf '%s' "$(python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]},"cwd":sys.argv[2]}))' "$3" "$2")" | GIT_GUARD_EXEMPT=$R python3 "$G" 2>/dev/null rc=$? got=allow [ $rc -eq 2 ] && got=block [ "$got" = "$1" ] && mark=ok || { mark=MISMATCH fail=1 } printf '%-8s %-6s %-6s [%s] %s\n' "$mark" "$1" "$got" "${2#"$tmp"/}" "$(printf '%s' "$3" | head -1)" } runin allow "$R" 'git commit -m "docs: x"' runin allow "$R" 'git commit -m x -- docs/usage.md' runin allow "$R" 'git commit -m"fix a bug"' runin allow "$R" 'git commit -m "-a is not the flag here"' runin allow "$R" 'git commit --allow-empty -m x' runin allow "$R/sub" 'git commit -m x' runin allow "$R" 'git merge --ff-only feature' runin allow "$R" 'git merge --no-ff feature' runin allow "$WT" 'git commit -m x' runin allow "$tmp" "git -C $WT commit -m x" runin allow "$tmp" "cd $R && git commit -m x" runin allow "$tmp" 'cd repo && git commit -m x' runin allow "$tmp" "cd $WT; git status; git merge main" runin allow "$R" "sh -c 'git commit -m x'" runin block "$R" 'git push' runin block "$R" 'git commit --amend --no-edit' runin block "$R" 'git commit -am wip' runin block "$R" 'git commit -qa -m wip' runin block "$R" 'git commit --all -m wip' runin block "$R" 'git rebase -i main' runin block "$R" 'git cherry-pick abc123' runin block "$R" 'git revert HEAD' runin block "$R" 'git reset --hard HEAD~1' runin block "$R" 'git checkout -- file.py' runin block "$R" 'GIT_DIR=/x/.git git commit -m x' runin block "$R" 'env GIT_WORK_TREE=/x git commit -m x' runin block "$R" 'git --git-dir=/x/.git commit -m x' runin block "$R" 'git -C /tmp commit -m x' runin block "$R" 'git -C $(mktemp -d) commit -m x' runin block "$tmp" "cd $R && cd /tmp && git commit -m x" runin block "$R" 'cd - && git commit -m x' runin block "$R" 'cd $HOME && git commit -m x' runin block "$R" 'pushd /tmp && git commit -m x' runin block "$tmp" 'git commit -m x' runin block "$tmp/other" 'git commit -m x' runin block "$tmp" "git -C $tmp/other merge x" echo "--- fail-open ---" printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G" [ $? -eq 0 ] && echo "ok non-Bash tool ignored" || { echo "MISMATCH non-Bash" fail=1 } printf 'not json' | python3 "$G" [ $? -eq 0 ] && echo "ok garbage stdin passes" || { echo "MISMATCH garbage" fail=1 } [ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || { echo "GIT-GUARD MATRIX: mismatches above" exit 1 }