Git Hooks: Automating Quality Gates Without Leaving Your Terminal¶
How I built a pre-commit and pre-push pipeline that catches 90% of CI failures before code ever leaves my machine.
The Cost of Late Feedback¶
I used to push code, wait 10 minutes for CI, get a failure notification for a trailing whitespace or a forgotten test, fix it, push again, wait another 10 minutes. The feedback loop was killing my productivity. I calculated it once: I was spending 45 minutes per day waiting for CI to tell me things I could have caught locally. Git hooks solved this completely.
Understanding the Hook Lifecycle¶
Git hooks are scripts that run at specific points in the git workflow. They live in .git/hooks/ and execute automatically when triggered:
pre-commit → before commit is created
prepare-commit-msg → before editor opens for commit message
commit-msg → after commit message is written
post-commit → after commit is created
pre-push → before push sends data to remote
pre-rebase → before rebase starts
post-merge → after merge completes
post-checkout → after branch checkout
Each hook receives specific arguments and can abort the operation by exiting with a non-zero status.
My Pre-Commit Hook: The Quality Gatekeeper¶
My pre-commit hook runs only on staged files — fast, focused, and non-intrusive:
#!/bin/bash
# .git/hooks/pre-commit — Quality gates for every commit
# Only check staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$STAGED_FILES" ]; then
exit 0
fi
# --- Stage 1: Formatting ---
PYTHON_FILES=$(echo "$STAGED_FILES" | grep '\.py$')
if [ -n "$PYTHON_FILES" ]; then
echo "$PYTHON_FILES" | xargs ruff format --check --quiet
if [ $? -ne 0 ]; then
echo "❌ Formatting issues found. Run: ruff format"
exit 1
fi
fi
# --- Stage 2: Linting ---
if [ -n "$PYTHON_FILES" ]; then
echo "$PYTHON_FILES" | xargs ruff check --quiet
if [ $? -ne 0 ]; then
echo "❌ Lint errors. Run: ruff check --fix"
exit 1
fi
fi
# --- Stage 3: Security scan ---
echo "$STAGED_FILES" | xargs grep -l "password\|secret\|token" 2>/dev/null | \
grep -v "test_\|_test\.\|\.md$" | while read file; do
echo "⚠️ Possible secret in: $file"
done
# --- Stage 4: Whitespace ---
if git diff --cached --check; then
: # clean
else
echo "❌ Trailing whitespace detected"
exit 1
fi
echo "✓ Pre-commit checks passed"
This runs in under 2 seconds for most commits. Fast enough that I never notice it.
My Pre-Push Hook: The Integration Guard¶
Pre-push is more thorough — it runs the full test suite because pushing is a heavier commitment:
#!/bin/bash
# .git/hooks/pre-push — Full validation before push
echo "Running pre-push checks..."
# Run the test suite
python3 -m pytest tests/ --tb=short --quiet
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Fix before pushing."
exit 1
fi
# Check for uncommitted changes after tests
# (tests might have generated artifacts)
if [ -n "$(git status --porcelain)" ]; then
echo "❌ Uncommitted changes detected after tests"
exit 1
fi
echo "✓ All pre-push checks passed"
The commit-msg Hook: Enforcing Conventions¶
I enforce commit message format programmatically — no more "fix stuff" or "wip" making it to main:
#!/bin/bash
# .git/hooks/commit-msg — Validate commit message format
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
TITLE=$(echo "$COMMIT_MSG" | head -1)
# Title length check
if [ ${#TITLE} -gt 50 ]; then
echo "❌ Commit title is ${#TITLE} chars (max 50)"
echo " Title: $TITLE"
exit 1
fi
# Conventional prefix check
if ! echo "$TITLE" | grep -qE "^(feat|fix|docs|style|refactor|test|chore|perf|ci)(\(.+\))?: "; then
echo "❌ Title must follow conventional commits format"
echo " Example: feat(auth): add OAuth2 support"
exit 1
fi
# Body separation check (blank line between title and body)
SECOND_LINE=$(echo "$COMMIT_MSG" | sed -n '2p')
if [ -n "$SECOND_LINE" ]; then
echo "❌ Second line must be blank (separates title from body)"
exit 1
fi
Sharing Hooks Across the Team¶
The .git/hooks/ directory isn't tracked by git — so how do you share hooks? I keep them in a tracked directory and configure git to use it:
# Store hooks in the repository
mkdir -p .githooks/
# Tell git to use this directory
git config core.hooksPath .githooks/
# Team members just need to run this once after clone
Alternatively, I use a setup script that symlinks:
#!/bin/bash
# scripts/install-hooks.sh
HOOK_DIR=$(git rev-parse --show-toplevel)/.git/hooks
SOURCE_DIR=$(git rev-parse --show-toplevel)/.githooks
for hook in "$SOURCE_DIR"/*; do
HOOK_NAME=$(basename "$hook")
ln -sf "$hook" "$HOOK_DIR/$HOOK_NAME"
chmod +x "$HOOK_DIR/$HOOK_NAME"
done
echo "✓ Git hooks installed"
Composable Hooks: Running Multiple Scripts Per Hook¶
Sometimes different tools each want a pre-commit hook. I use a dispatcher pattern:
#!/bin/bash
# .git/hooks/pre-commit — dispatcher
HOOK_NAME="pre-commit"
HOOKS_DIR="$(git rev-parse --show-toplevel)/.githooks/$HOOK_NAME.d"
if [ -d "$HOOKS_DIR" ]; then
for hook in "$HOOKS_DIR"/*; do
if [ -x "$hook" ]; then
"$hook" "$@"
STATUS=$?
if [ $STATUS -ne 0 ]; then
echo "❌ Hook failed: $(basename $hook)"
exit $STATUS
fi
fi
done
fi
Now I can drop scripts into .githooks/pre-commit.d/ and they all run in order.
When to Skip Hooks (Responsibly)¶
Sometimes you need to bypass hooks — during a git commit --amend for a typo fix, or when rebasing through known-broken commits:
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "chore: fix typo"
# Skip pre-push hooks
git push --no-verify
I use --no-verify sparingly and only for trivial changes. If I find myself skipping hooks regularly, that's a signal the hooks are too strict or too slow.
Key Takeaway¶
Git hooks transform git from a passive version control tool into an active quality enforcement system. The investment is small — a few shell scripts — but the return is enormous: fewer CI failures, consistent commit messages, no accidental secret pushes, and faster feedback loops. Start with pre-commit (formatting + linting), add commit-msg (conventions), then graduate to pre-push (tests). Build the quality in.
Tags: git, hooks, automation, quality, ci-cd