Git Hooks
Git hooks for enforcing standards, running tests, and automating workflows at commit, push, and checkout time.
Hook Fundamentals
ls .git/hooks/*.sample
Git ships sample hooks for every supported event. Remove the .sample suffix and make executable to activate.
chmod +x .git/hooks/pre-commit
Hooks must be executable. This is the number one reason a hook "doesn’t work." There is no error message — git silently skips non-executable hooks.
# Exit 0 = allow the operation to proceed
# Exit non-zero = abort the operation
# Hooks receive arguments via $1, $2, etc.
# Hooks receive stdin for some events (pre-receive, post-rewrite)
Pre-commit Hook
#!/usr/bin/env bash
# .git/hooks/pre-commit
# Run shellcheck on staged .sh files
staged_sh=$(git diff --cached --name-only --diff-filter=ACM -- '*.sh')
if [[ -n "$staged_sh" ]]; then
echo "$staged_sh" | xargs shellcheck || exit 1
fi
# Run Python linting on staged .py files
staged_py=$(git diff --cached --name-only --diff-filter=ACM -- '*.py')
if [[ -n "$staged_py" ]]; then
echo "$staged_py" | xargs ruff check || exit 1
fi
--diff-filter=ACM catches Added, Copied, and Modified files — skips Deleted files (which would cause "file not found" errors in linters). This pattern scales to any language.
#!/usr/bin/env bash
# .git/hooks/pre-commit
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
echo "ERROR: Direct commits to $branch are not allowed."
echo "Create a feature branch: git checkout -b feature/your-change"
exit 1
fi
#!/usr/bin/env bash
# .git/hooks/pre-commit
patterns='(API_KEY|SECRET|PASSWORD|PRIVATE.KEY|aws_secret|token.*=.*[A-Za-z0-9]{20,})'
if git diff --cached --diff-filter=ACM -U0 | grep -iEq "$patterns"; then
echo "ERROR: Potential secret detected in staged changes."
echo "Review with: git diff --cached | grep -iE '$patterns'"
exit 1
fi
Commit-msg Hook
#!/usr/bin/env bash
# .git/hooks/commit-msg
commit_msg=$(cat "$1")
pattern='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\(.+\))?: .{1,72}$'
if ! echo "$commit_msg" | head -1 | grep -Eq "$pattern"; then
echo "ERROR: Commit message does not follow conventional format."
echo "Expected: type(scope): description"
echo "Types: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert"
echo "Got: $commit_msg"
exit 1
fi
The head -1 checks only the first line — the subject. Body and footer lines are unconstrained. The 72-character limit keeps subjects readable in git log --oneline.
#!/usr/bin/env bash
# .git/hooks/commit-msg
branch=$(git rev-parse --abbrev-ref HEAD)
ticket=$(echo "$branch" | grep -oE '[A-Z]+-[0-9]+')
if [[ -n "$ticket" ]]; then
# Only append if not already present
if ! grep -q "$ticket" "$1"; then
echo "" >> "$1"
echo "Refs: $ticket" >> "$1"
fi
fi
If your branch is feature/INFRA-342-vault-setup, this appends Refs: INFRA-342 to the commit message automatically. The guard prevents duplication on --amend.
Pre-push Hook
#!/usr/bin/env bash
# .git/hooks/pre-push
echo "Running tests before push..."
if command -v pytest &>/dev/null; then
pytest --tb=short -q || exit 1
fi
Pre-push receives the remote name and URL as arguments ($1 and $2). Stdin receives lines of <local ref> <local sha> <remote ref> <remote sha> — useful for conditional logic based on which branch is being pushed.
#!/usr/bin/env bash
# .git/hooks/pre-push
protected_branches="main master production"
current_branch=$(git rev-parse --abbrev-ref HEAD)
for branch in $protected_branches; do
if [[ "$current_branch" == "$branch" ]]; then
# Check if this is a force push
while read -r local_ref local_sha remote_ref remote_sha; do
if [[ "$local_sha" != "$(git merge-base "$local_sha" "$remote_sha" 2>/dev/null)" ]]; then
echo "ERROR: Force push to $branch is not allowed."
exit 1
fi
done
fi
done
Post-checkout Hook
#!/usr/bin/env bash
# .git/hooks/post-checkout
# $1 = previous HEAD, $2 = new HEAD, $3 = 1 if branch checkout (0 if file checkout)
prev_head="$1"
new_head="$2"
is_branch="$3"
[[ "$is_branch" -eq 0 ]] && exit 0 # Skip file checkouts
# Reinstall if lock file changed between branches
if ! git diff --quiet "$prev_head" "$new_head" -- package-lock.json 2>/dev/null; then
echo "package-lock.json changed — running npm install..."
npm install
fi
if ! git diff --quiet "$prev_head" "$new_head" -- uv.lock 2>/dev/null; then
echo "uv.lock changed — running uv sync..."
uv sync
fi
The $3 flag distinguishes branch checkouts from file checkouts (git checkout — file.txt). Without the guard, restoring a single file would trigger a full reinstall.
Post-merge Hook
#!/usr/bin/env bash
# .git/hooks/post-merge
# $1 = squash flag (1 if squash merge, 0 otherwise)
changed_files=$(git diff-tree -r --name-only ORIG_HEAD HEAD)
if echo "$changed_files" | grep -q "package-lock.json"; then
echo "Dependencies changed — running npm install..."
npm install
fi
if echo "$changed_files" | grep -q "requirements.txt\|uv.lock"; then
echo "Python dependencies changed — running uv sync..."
uv sync
fi
ORIG_HEAD points to where HEAD was before the merge. git diff-tree between those two refs shows exactly what the merge brought in.
Shared Hooks
git config core.hooksPath .githooks
This redirects git from .git/hooks/ to .githooks/ (or any path). Since .githooks/ lives in the working tree, it’s tracked by git. The entire team shares the same hooks.
mkdir -p .githooks
cp .git/hooks/pre-commit.sample .githooks/pre-commit
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
git config --global core.hooksPath ~/.config/git/hooks
Every repo on your machine uses these hooks. Individual repos can override with --local.
Bypassing Hooks
git commit --no-verify -m "WIP: checkpoint before refactor"
git push --no-verify
--no-verify skips pre-commit, commit-msg, and pre-push hooks. Legitimate uses: WIP commits, emergency hotfixes, commits where the hook is known-broken and you’re fixing it. Not legitimate: skipping because the linter found real problems.
--no-verify is a scalpel, not a hammer. If you’re using it regularly, your hooks are either too slow or catching real issues you should fix.
|
Hook Management Tools
# lefthook.yml
pre-commit:
parallel: true
commands:
shellcheck:
glob: "*.sh"
run: shellcheck {staged_files}
ruff:
glob: "*.py"
run: ruff check {staged_files}
asciidoc-lint:
glob: "*.adoc"
run: vale {staged_files}
lefthook install
Lefthook is a single binary with no runtime dependencies. It runs hooks in parallel by default, uses glob patterns for file filtering, and supports {staged_files} interpolation. Faster than Husky (no Node.js required) and simpler than pre-commit (no Python virtualenvs).
Debugging Hooks
bash -x .git/hooks/pre-commit
bash -x traces every command as it executes. If the hook exits non-zero, you see exactly which line failed.
git config core.hooksPath
If this returns a path, git is using that directory instead of .git/hooks/. Empty output means the default .git/hooks/ is active.
find "$(git rev-parse --git-dir)/hooks" -type f -executable ! -name '*.sample'
Shows only active hooks — executable files without the .sample suffix. If this returns nothing, no hooks are installed.