Skip to content

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

Reference