Skip to content

Git Hooks: Preventing Pipeline Failures with Pre-Push ValidationΒΆ

This tutorial explains how to use Git hooks to catch errors locally before pushing code, preventing CI/CD pipeline failures and saving time in the development workflow.

What Are Git Hooks?ΒΆ

Git hooks are scripts that Git automatically executes before or after certain events such as commit, push, or merge. They allow you to:

  • Run automated checks before code leaves your local machine
  • Enforce code quality standards
  • Prevent broken code from reaching the remote repository
  • Save CI/CD pipeline minutes and reduce feedback loops

Git Hooks vs. MkDocs Hooks

Don't confuse Git hooks (scripts that run during Git operations) with MkDocs hooks (Python functions that run during documentation builds). See MkDocs Hooks and JupyterLite Images for details about MkDocs hooks.

Common Git Hook TypesΒΆ

Hook Name When It Runs Common Use Cases
pre-commit Before a commit is created Lint code, format check, run quick tests
commit-msg After commit message is entered Validate commit message format
on_config After config loads Modify configuration dynamically
on_pre_build Before build starts Clean directories, prepare assets
on_startup Before anything else Initialize resources, check dependencies
on_files When files are collected Add/remove/modify file list
on_post_build After build completes Copy assets, post-process files ← We use this!
on_page_markdown Before markdown rendering Transform markdown content
on_page_content After markdown to HTML Modify HTML content
pre-push Before pushing to remote Run builds, comprehensive tests, validate deployability
post-merge After a merge completes Update dependencies, rebuild assets

Why Use Pre-Push Hooks?ΒΆ

The Problem: Delayed FeedbackΒΆ

Without pre-push validation:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚Developer β”‚       β”‚ Local Git β”‚      β”‚ GitHub β”‚      β”‚ CI Pipeline β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
      β”‚                  β”‚                β”‚                  β”‚
      β”‚ 1. git commit    β”‚                β”‚                  β”‚
      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                β”‚                  β”‚
      β”‚                  β”‚                β”‚                  β”‚
      β”‚ 2. git push      β”‚                β”‚                  β”‚
      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                β”‚                  β”‚
      β”‚                  β”‚ 3. Push code   β”‚                  β”‚
      β”‚                  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                  β”‚
      β”‚                  β”‚                β”‚ 4. Trigger build β”‚
      β”‚                  β”‚                β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚
      β”‚                  β”‚                β”‚                  β”‚
      β”‚                  β”‚                β”‚   Build runs     β”‚
      β”‚                  β”‚                β”‚   (2-10 minutes) β”‚
      β”‚                  β”‚                β”‚                  β”‚
      β”‚                  β”‚ 5. ❌ Build failed!               β”‚
      β”‚ <────────────────┴────────────────┴───────────────────
      β”‚                                                      β”‚
      β”‚ 😞 Wasted 5-20 minutes waiting for CI...             β”‚
      β”‚                                                      β”‚
      β”‚ 6. Fix, commit, push again                           β”‚
      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚
      β”‚                                                      β”‚
      β”‚                  Build runs again (2-10 minutes)     β”‚
      β”‚                                                      β”‚
      β”‚ 7. βœ… Success (finally!)                             β”‚
      β”‚ <─────────────────────────────────────────────────── β”‚
      β”‚                                                      β”‚
View Interactive Sequence Diagram
sequenceDiagram
    participant Dev as Developer
    participant Git as Local Git
    participant GH as GitHub
    participant CI as CI Pipeline

    Dev->>Git: git commit
    Dev->>Git: git push
    Git->>GH: Push code
    GH->>CI: Trigger pipeline
    Note over CI: Build runs (2-10 min)
    CI->>Dev: ❌ Build failed!
    Note over Dev: 😞 Wasted 5-20 minutes
    Dev->>Git: Fix, commit, push
    Git->>GH: Push again
    GH->>CI: Trigger pipeline again
    Note over CI: Build runs (2-10 min)
    CI->>Dev: βœ… Success (finally)

Time wasted: 5-20+ minutes per iteration

The Solution: Pre-Push ValidationΒΆ

With a pre-push hook:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚Developer β”‚     β”‚ Pre-Push    β”‚    β”‚ Local Git β”‚    β”‚ GitHub β”‚    β”‚ CI Pipeline β”‚
β”‚          β”‚     β”‚ Hook        β”‚    β”‚           β”‚    β”‚        β”‚    β”‚             β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚ 1. git commit   β”‚                 β”‚              β”‚                β”‚
      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>             β”‚                β”‚
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚ 2. git push     β”‚                 β”‚              β”‚                β”‚
      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>             β”‚                β”‚
      β”‚                 β”‚ 3. Run hook     β”‚              β”‚                β”‚
      β”‚                 β”‚<─────────────────              β”‚                β”‚
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚                 β”‚  Build check    β”‚              β”‚                β”‚
      β”‚                 β”‚  (10-60 sec)    β”‚              β”‚                β”‚
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚ 4. ❌ Error!    β”‚                 β”‚              β”‚                β”‚
      β”‚<─────────────────                 β”‚              β”‚                β”‚
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚ βœ… Fix immediately (seconds, not minutes!)       β”‚                β”‚
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚ 5. git commit & push again        β”‚              β”‚                β”‚
      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>             β”‚                β”‚
      β”‚                 β”‚ 6. Run hook     β”‚              β”‚                β”‚
      β”‚                 β”‚<─────────────────              β”‚                β”‚
      β”‚                 β”‚  Build check    β”‚              β”‚                β”‚
      β”‚                 β”‚  (10-60 sec)    β”‚              β”‚                β”‚
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚ 7. βœ… Passed!   β”‚                 β”‚              β”‚                β”‚
      β”‚<─────────────────                 β”‚              β”‚                β”‚
      β”‚                 β”‚                 β”‚ 8. Push code β”‚                β”‚
      β”‚                 β”‚                 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                β”‚
      β”‚                 β”‚                 β”‚              β”‚ 9. Trigger CI  β”‚
      β”‚                 β”‚                 β”‚              β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚
      β”‚                 β”‚                 β”‚              β”‚                β”‚
      β”‚ 10. βœ… CI Success (first try!)    β”‚              β”‚                β”‚
      β”‚ <─────────────────────────────────┴──────────────┴─────────────────
      β”‚                 β”‚                 β”‚              β”‚                β”‚
View Interactive Sequence Diagram
sequenceDiagram
    participant Dev as Developer
    participant Hook as Pre-Push Hook
    participant Git as Local Git
    participant GH as GitHub
    participant CI as CI Pipeline

    Dev->>Git: git commit
    Dev->>Git: git push
    Git->>Hook: Run validation
    Note over Hook: Build check (10-60 sec)
    Hook->>Dev: ❌ Error found!
    Note over Dev: Fix immediately
    Dev->>Git: Fix, commit, push
    Git->>Hook: Run validation
    Note over Hook: Build check (10-60 sec)
    Hook->>Git: βœ… Passed!
    Git->>GH: Push code
    GH->>CI: Trigger pipeline
    CI->>Dev: βœ… Success!

Time saved: 90% reduction in CI failures and wait time

Real Example: MkDocs Build ValidationΒΆ

The ScenarioΒΆ

Your GitHub Actions pipeline runs:

.github/workflows/deploy-mkdocs.yml
- name: Build MkDocs site
  run: uv run mkdocs build --strict

If this fails in CI, you've wasted:

  • Pipeline execution time
  • Your own time waiting for results
  • Potential embarrassment if others are watching the repo

The Hook SolutionΒΆ

A pre-push hook that runs the exact same command locally:

.git/hooks/pre-push
1
2
3
4
5
6
7
8
#!/bin/bash  # (1)!

echo "Running MkDocs build validation..."
if ! uv run mkdocs build --strict; then  # (2)!
    echo "❌ MkDocs build failed! Fix errors before pushing."
    exit 1  # (3)!
fi
echo "βœ… MkDocs build passed!"
  1. Shebang line - Required as the first line to tell the system this is a Bash script
  2. Negated condition - ! means "if this command fails", so we enter the block on failure
  3. Exit code 1 - Non-zero exit code tells Git to abort the push operation

Automatic Execution

Now when you git push, this runs automatically and blocks the push if it fails!

How Git Hooks Work: ArchitectureΒΆ

Where Hooks LiveΒΆ

your-project/
β”œβ”€β”€ .git/
β”‚   └── hooks/              # (1)!
β”‚       β”œβ”€β”€ pre-commit.sample
β”‚       β”œβ”€β”€ pre-push.sample
β”‚       β”œβ”€β”€ pre-push        # (2)!
β”‚       └── ...
β”œβ”€β”€ src/
β”œβ”€β”€ docs/
└── pyproject.toml
  1. Git hooks directory - Created automatically when you run git init
  2. Your custom hook - Remove the .sample extension to activate

Not Version Controlled

Files in .git/ are never committed to version control. See Sharing Hooks with Your Team for solutions.

Hook Execution FlowΒΆ

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ Developer runs: git push   β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚
                                   β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ Hook exists at                   β”‚
                    β”‚ .git/hooks/pre-push?             β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚              β”‚
                          NOβ”‚              β”‚YES
                            β”‚              β”‚
                            β–Ό              β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ Proceed with push   β”‚  β”‚ Execute hook       β”‚
              β”‚ immediately         β”‚  β”‚ script             β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                 β”‚
                                                 β–Ό
                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                    β”‚ Exit code = 0?        β”‚
                                    β”‚ (Success?)            β”‚
                                    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜
                                         β”‚              β”‚
                                    YES  β”‚              β”‚ NO
                                         β”‚              β”‚
                                         β–Ό              β–Ό
                            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                            β”‚ βœ… Continue     β”‚  β”‚ ❌ BLOCK push    β”‚
                            β”‚ with push       β”‚  β”‚ Show error msg   β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
View Interactive Flowchart
flowchart TD
    A[Developer runs: git push] --> B{Hook exists at<br/>.git/hooks/pre-push?}
    B -->|No| C[Proceed with push immediately]
    B -->|Yes| D[Execute hook script]
    D --> E{Exit code = 0?}
    E -->|Yes - Success| F[Continue with push]
    E -->|No - Failure| G[BLOCK push<br/>Show error message]

    style F fill:#90EE90
    style G fill:#FFB6C6
    style D fill:#87CEEB

Exit Codes MatterΒΆ

Hooks communicate success/failure through exit codes:

  • Exit 0: Success β†’ Git continues with the operation
  • Exit 1-255: Failure β†’ Git aborts the operation
if ! some_validation_command; then
    echo "❌ Validation failed"
    exit 1  # Blocks the push
fi
echo "βœ… Validation passed"
exit 0  # Allows the push
if ! some_validation_command; then
    echo "❌ Validation failed"
    # Missing exit 1 - push will continue!
fi
echo "βœ… Validation passed"

Implementing Pre-Push HooksΒΆ

Step 1: Create the Hook FileΒΆ

Navigate to your repository:

cd /path/to/your/repository
nano .git/hooks/pre-push
cd /d/path/to/your/repository
nano .git/hooks/pre-push
cd D:\path\to\your\repository
notepad .git\hooks\pre-push

Step 2: Write the Hook ScriptΒΆ

For MkDocs validation:

.git/hooks/pre-push
#!/bin/bash
# ABOUTME: Pre-push hook to validate MkDocs build before pushing to remote
# ABOUTME: Prevents pipeline failures by catching build errors locally

set -e  # (1)!

echo ""
echo "πŸ” Running pre-push validation..."
echo ""

# Run MkDocs build in strict mode (same as CI)
echo "πŸ“š Building MkDocs site..."
if ! uv run mkdocs build --strict; then  # (2)!
    echo ""
    echo "❌ MkDocs build failed!"
    echo ""
    echo "Common issues:"
    echo "  β€’ Configuration warnings (check mkdocs.yml)"
    echo "  β€’ Invalid markdown syntax"
    echo "  β€’ Missing files referenced in nav"
    echo "  β€’ Plugin compatibility issues"
    echo ""
    echo "Fix the errors above and try pushing again."
    echo ""
    exit 1  # (3)!
fi

echo ""
echo "βœ… All validations passed! Proceeding with push..."
echo ""

exit 0  # (4)!
  1. set -e causes the script to exit immediately if any command fails (except in if conditions)
  2. Exact CI command - Run the identical command that runs in your GitHub Actions workflow
  3. Exit 1 blocks the push when validation fails
  4. Exit 0 allows the push to proceed when validation succeeds

Match Your CI Exactly

Always use the exact same command that runs in your CI pipeline to ensure 100% parity between local and remote validation.

Step 3: Make the Hook ExecutableΒΆ

Required Step

Hooks must be executable or Git will silently ignore them!

chmod +x .git/hooks/pre-push
chmod +x .git/hooks/pre-push
icacls .git\hooks\pre-push /grant Everyone:RX

Step 4: Test the HookΒΆ

Try pushing to test:

git push origin your-branch

You should see output like:

πŸ” Running pre-push validation...

πŸ“š Building MkDocs site...
INFO    -  Cleaning site directory
INFO    -  Building documentation to directory: site
INFO    -  Documentation built in 2.34 seconds

βœ… All validations passed! Proceeding with push...

Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Example: Intentional Failure Test

To test that your hook properly blocks pushes, temporarily break your mkdocs.yml:

# Add an invalid configuration
invalid_key: this will cause an error

Then try to push:

$ git push origin documentation

πŸ” Running pre-push validation...

πŸ“š Building MkDocs site...
WARNING -  Config value 'invalid_key': Unrecognised configuration name: invalid_key
Aborted with 1 configuration warnings in 'strict' mode!

❌ MkDocs build failed!

Fix the errors above and try pushing again.

error: failed to push some refs to 'origin'

Remove the invalid line and the push will succeed! βœ…

Advanced Hook PatternsΒΆ

Multiple Validation StepsΒΆ

Run several checks in sequence:

.git/hooks/pre-push
#!/bin/bash
# ABOUTME: Comprehensive pre-push validation
# ABOUTME: Runs linting, tests, and build checks before allowing push

set -e

echo "πŸ” Running pre-push validations..."

# Step 1: Linting
echo ""
echo "1️⃣  Running linter..."
if ! make lint; then  # (1)!
    echo "❌ Linting failed!"
    exit 1
fi

# Step 2: Unit tests
echo ""
echo "2️⃣  Running tests..."
if ! make test; then
    echo "❌ Tests failed!"
    exit 1
fi

# Step 3: MkDocs build
echo ""
echo "3️⃣  Building documentation..."
if ! uv run mkdocs build --strict; then
    echo "❌ MkDocs build failed!"
    exit 1
fi

echo ""
echo "βœ… All validations passed!"
exit 0
  1. Each validation step fails fast - if linting fails, we don't waste time running tests

Performance Consideration

Each check adds time to your push. Keep total hook execution under 60 seconds for good developer experience.

Conditional Checks (Only Changed Files)ΒΆ

Optimize by only checking files you've changed:

.git/hooks/pre-push
#!/bin/bash
# ABOUTME: Smart pre-push hook that only validates what changed
# ABOUTME: Checks docs only if documentation files were modified

set -e

# Get list of files changed compared to remote main branch
CHANGED_FILES=$(git diff --name-only origin/main...HEAD)  # (1)!

# Check if docs-related files changed
if echo "$CHANGED_FILES" | grep -qE "(^docs/|mkdocs\.yml)"; then  # (2)!
    echo "πŸ“š Documentation changed, validating MkDocs build..."
    if ! uv run mkdocs build --strict; then
        echo "❌ MkDocs build failed!"
        exit 1
    fi
    echo "βœ… Documentation build passed!"
else
    echo "ℹ️  No documentation changes, skipping MkDocs validation"
fi

exit 0
  1. Compare your branch to the remote main branch to find what changed
  2. Regex pattern matches files in docs/ directory or the mkdocs.yml file

When to Use Conditional Checks

Use this pattern when:

  • Your validation is slow (> 30 seconds)
  • Changes to unrelated files don't affect the check
  • Your repo has multiple subsystems (docs, code, tests, etc.)

Interactive Override (Emergency Bypass)ΒΆ

.git/hooks/pre-push
#!/bin/bash
# ABOUTME: Pre-push validation with emergency bypass instructions
# ABOUTME: Guides users to use --no-verify only when necessary

echo "πŸ” Running pre-push validation..."

if ! uv run mkdocs build --strict; then
    echo ""
    echo "❌ MkDocs build failed!"
    echo ""
    echo "⚠️  To push anyway (NOT RECOMMENDED), use:"
    echo "    git push --no-verify"  # (1)!
    echo ""
    echo "This bypasses ALL hooks and should only be used in emergencies."
    echo ""
    exit 1
fi

echo "βœ… Validation passed!"
  1. The --no-verify flag skips all hooks for this push

Emergency Use Only

git push --no-verify  # Skips ALL hooks!

Only use this when:

  • You're absolutely certain the code is safe
  • There's a critical production issue
  • The hook itself is broken (rare)

Never use this to bypass failing validations without fixing them!

Sharing Hooks with Your TeamΒΆ

The ChallengeΒΆ

Git hooks in .git/hooks/ are not tracked by version control. Each team member needs to install them manually.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Developer 1         β”‚                  β”‚ GitHub Repo  β”‚                  β”‚ Developer 2         β”‚
β”‚ βœ… Has hook         │──── pushes ────> β”‚              β”‚ ──── clones ───> β”‚ ❌ NO hook          β”‚
β”‚    installed        β”‚                  β”‚              β”‚                  β”‚    installed        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     (Protected)                                                                 (Vulnerable)
View Interactive Diagram
flowchart LR
    A[Developer 1<br/>Has hook installed] -->|pushes| B[GitHub Repo]
    B -->|clones| C[Developer 2<br/>NO hook installed ❌]

    style A fill:#90EE90
    style C fill:#FFB6C6

Solution 1: Tracked Hook ScriptsΒΆ

Store hook scripts in a tracked directory:

your-project/
β”œβ”€β”€ .git/
β”‚   └── hooks/           # ← Not tracked (Git internal)
β”œβ”€β”€ .githooks/           # ← Tracked! βœ… Commit this!
β”‚   β”œβ”€β”€ pre-push
β”‚   β”œβ”€β”€ pre-commit
β”‚   └── install.sh
β”œβ”€β”€ docs/
└── pyproject.toml

Install script (.githooks/install.sh):

.githooks/install.sh
#!/bin/bash
# ABOUTME: Installs Git hooks from .githooks/ to .git/hooks/
# ABOUTME: Run this after cloning the repo: bash .githooks/install.sh

HOOK_DIR=".git/hooks"
SOURCE_DIR=".githooks"

echo "Installing Git hooks from $SOURCE_DIR..."

for hook in "$SOURCE_DIR"/*; do
    if [ -f "$hook" ] && [ "$(basename "$hook")" != "install.sh" ]; then  # (1)!
        hook_name=$(basename "$hook")
        cp "$hook" "$HOOK_DIR/$hook_name"
        chmod +x "$HOOK_DIR/$hook_name"
        echo "βœ… Installed $hook_name"
    fi
done

echo ""
echo "βœ… Git hooks installed successfully!"
echo "Hooks will now run automatically during git operations."
  1. Skip copying the install script itself - only copy actual hook files

Setup instructions for team (add to README):

README.md
## Development Setup

After cloning this repository, install Git hooks:

```bash
bash .githooks/install.sh

This installs pre-push validation that catches errors before they reach CI.

!!! success "Commit the Hooks"
    ```bash
    git add .githooks/
    git commit -m "Add pre-push hook for MkDocs validation"
    git push
    ```

    Now everyone who clones can run the install script!

### Solution 2: Git Config (Git 2.9+)

Configure Git to use a tracked hooks directory automatically:

```bash
# Set hooks directory for this repo
git config core.hooksPath .githooks

Now hooks in .githooks/ are used automatically without manual installation!

Add to project setup:

.githooks/pre-push
1
2
3
4
5
6
7
8
9
#!/bin/bash
# This file is in .githooks/ and tracked by Git

echo "πŸ” Running pre-push validation..."
if ! uv run mkdocs build --strict; then
    echo "❌ Build failed!"
    exit 1
fi
echo "βœ… Build passed!"

Commit it:

git add .githooks/pre-push
git commit -m "Add pre-push hook for MkDocs validation"

Team members setup:

# After cloning
git config core.hooksPath .githooks
# Done! Hooks work automatically now

Make it Easy for Your Team

Add hook setup to your project documentation and onboarding checklist so new team members don't forget!

Solution 3: Pre-Commit FrameworkΒΆ

Use the pre-commit framework for robust, cross-platform hook management:

Install:

uv add --dev pre-commit
pip install pre-commit

Create configuration:

.pre-commit-config.yaml
1
2
3
4
5
6
7
8
9
repos:
  - repo: local
    hooks:
      - id: mkdocs-build  # (1)!
        name: MkDocs Build Validation
        entry: uv run mkdocs build --strict  # (2)!
        language: system
        pass_filenames: false  # (3)!
        stages: [push]  # (4)!
  1. Unique ID for this hook
  2. The command to run - same as your CI pipeline
  3. Don't pass changed filenames to the command
  4. Only run this hook on push, not commit

Install hooks:

pre-commit install --hook-type pre-push

Commit the config:

git add .pre-commit-config.yaml
git commit -m "Add pre-commit framework for hook management"

Automatic for Everyone

Team members just need to run pre-commit install --hook-type pre-push after cloning. The framework handles platform differences automatically!

TroubleshootingΒΆ

Hook Not RunningΒΆ

Problem: Push completes without running hook

Solution 1: Verify File Exists
# Check if file exists
ls -la .git/hooks/pre-push

# Should show something like:
# -rwxr-xr-x  1 user  staff  423 Dec  8 10:30 .git/hooks/pre-push
#  ^^^
#  └── These 'x' indicate executable permission
Solution 2: Check Executable Permission
chmod +x .git/hooks/pre-push
chmod +x .git/hooks/pre-push
Solution 3: Verify Shebang Line

The first line must be:

#!/bin/bash

Or for sh compatibility:

#!/bin/sh

Common mistakes:

  • Missing shebang entirely
  • Extra spaces before #
  • Not on the first line
Solution 4: Test Hook Manually
# Run the hook directly
.git/hooks/pre-push

# Should show output and exit with 0 or 1
echo $?  # Shows exit code of last command

Hook Blocks Valid PushΒΆ

Problem: Hook fails but code is actually fine

Solution 1: Check Your Environment
# Verify UV is installed and accessible
which uv
# Should show: /path/to/uv (or C:\path\to\uv.exe on Windows)

uv --version
# Should show: uv 0.x.x

# Verify dependencies are installed
uv sync
Solution 2: Run Validation Manually
# Run the same command the hook runs
uv run mkdocs build --strict

# If this fails, the issue is with your code/config, not the hook
# If this succeeds, there's a hook environment issue
Solution 3: Check Working Directory

Hooks run from the repository root. Verify:

# In your hook, add debug output:
echo "Working directory: $(pwd)"
echo "Files here: $(ls)"
Solution 4: Emergency Bypass

If you're certain the code is fine:

git push --no-verify

But investigate why the hook failed afterwards!

Windows Line Ending IssuesΒΆ

Problem: Hook fails with ^M: bad interpreter error

This happens when the hook file has Windows line endings (CRLF) instead of Unix line endings (LF).

Solution 1: Using dos2unix (Git Bash)
# Install dos2unix if needed (usually included in Git Bash)
dos2unix .git/hooks/pre-push
Solution 2: Using PowerShell
(Get-Content .git\hooks\pre-push -Raw) -replace "`r`n","`n" | `
    Set-Content .git\hooks\pre-push -NoNewline
Solution 3: Using Git Configuration

Configure Git to handle line endings automatically:

# Set for this repo
git config core.autocrlf false

# Ensure .githooks/ files use LF
echo "* text=auto" > .gitattributes
echo ".githooks/* text eol=lf" >> .gitattributes
git add .gitattributes
git commit -m "Ensure hooks use LF line endings"

Hook Shows Error But Push ContinuesΒΆ

Problem: Hook prints error message but push still succeeds

The hook is not exiting with a non-zero code!

Solution: Add Explicit Exit

Wrong ❌:

if ! uv run mkdocs build --strict; then
    echo "Error!"
    # Missing exit 1 here!
fi
# Script continues and returns 0 by default

Correct βœ…:

if ! uv run mkdocs build --strict; then
    echo "Error!"
    exit 1  # This blocks the push!
fi

Or use set -e at the top:

#!/bin/bash
set -e  # Exit on any error

uv run mkdocs build --strict
# If this fails, script exits with non-zero automatically

Best PracticesΒΆ

1. Keep Hooks Fast ⚑¢

Users will bypass slow hooks, defeating their purpose!

Duration User Experience
< 10 seconds Excellent - barely noticeable
10-30 seconds Good - acceptable wait time
30-60 seconds Poor - users get impatient
> 60 seconds Terrible - users will bypass

Speed Optimization Strategies

  • Run only on changed files (see Conditional Checks)
  • Use caching (UV caches dependencies automatically)
  • Move slow checks to CI only (keep hooks for fast checks)
  • Run checks in parallel when possible

2. Provide Clear Error Messages πŸ’¬ΒΆ

Bad ❌:

echo "Failed"
exit 1

Good βœ…:

echo "❌ MkDocs build failed!"
echo ""
echo "Common fixes:"
echo "  β€’ Check mkdocs.yml for configuration errors"
echo "  β€’ Validate markdown syntax in changed files"
echo "  β€’ Run 'uv run mkdocs build --strict' to see details"
echo ""
echo "Need help? See: docs/tutorials/git-hooks-pre-push-validation.md"
exit 1

Help Users Help Themselves

Good error messages include:

  • What failed (specific check name)
  • Why it might have failed (common causes)
  • How to debug (command to run manually)
  • Where to get help (documentation link)

3. Match CI Exactly 🎯¢

Run the exact same commands as your CI pipeline:

.github/workflows/deploy.yml
- name: Build MkDocs site
  run: uv run mkdocs build --strict
.git/hooks/pre-push
uv run mkdocs build --strict

This ensures 100% parity between local and remote validation.

Don't Use Approximations

❌ Don't run mkdocs build locally if CI runs mkdocs build --strict ❌ Don't run tests on one Python version if CI uses another βœ… Match the command exactly, including all flags and options

4. Document Hook Requirements πŸ“ΒΆ

Add to your project README:

README.md
## Development Setup

### Install Git Hooks

This project uses Git hooks to validate code before pushing.

**Install hooks:**

```bash
bash .githooks/install.sh
```

**Required tools:**

- [UV](https://docs.astral.sh/uv/) (Python package manager)
- Python 3.12+
- Make (optional, for build automation)

**What the hook does:**

- Validates MkDocs can build without errors
- Catches configuration issues before CI
- Saves time by providing immediate feedback

**Bypass in emergencies only:**

```bash
git push --no-verify  # NOT RECOMMENDED
```

This skips validation and should only be used for critical fixes.

5. Version Control Your Hooks πŸ—‚οΈΒΆ

Store hooks in .githooks/ or similar and commit them:

# Create tracked hooks directory
mkdir -p .githooks

# Move hook to tracked location
mv .git/hooks/pre-push .githooks/pre-push

# Make it executable
chmod +x .githooks/pre-push

# Commit to version control
git add .githooks/pre-push
git commit -m "Add pre-push hook for MkDocs validation"

# Configure Git to use this directory
git config core.hooksPath .githooks

Comparison: Hooks vs. CI-OnlyΒΆ

Aspect Pre-Push Hook CI-Only
Feedback Speed 10-60 seconds ⚑ 2-10+ minutes 🐌
Catch Errors Before push βœ… After push ❌
CI Load Reduced πŸ“‰ Higher πŸ“ˆ
Developer Experience Immediate feedback 😊 Delayed feedback 😞
Setup Complexity Medium (manual install) Low (automatic)
Bypassable Yes (--no-verify) No (authoritative)
Platform Issues Possible (Windows/Mac differences) Rare (standardized environment)

Best of Both Worlds

Use both:

  • Pre-push hooks for fast, common checks (lint, quick tests, build validation)
  • CI for comprehensive, authoritative validation (all tests, security scans, deployment)

Hooks catch 90% of issues instantly, CI provides final authority.

SummaryΒΆ

Git hooks, especially pre-push hooks, are powerful tools for:

βœ… Catching errors early - Before they reach the remote repository βœ… Saving time - Immediate local feedback vs. waiting for CI βœ… Reducing CI load - Fewer failed pipeline runs βœ… Enforcing standards - Consistent validation across the team

Quick ReferenceΒΆ

# Create pre-push hook
nano .git/hooks/pre-push

# Make executable
chmod +x .git/hooks/pre-push

# Test hook
git push origin your-branch

# Bypass hook (emergency only!)
git push --no-verify

Key TakeawaysΒΆ

  1. Hooks are scripts in .git/hooks/ that run automatically during Git operations
  2. Pre-push hooks run before git push completes and can block it
  3. Exit codes matter: 0 = success (allow), non-zero = failure (block)
  4. Share with your team by storing hooks in a tracked directory like .githooks/
  5. Match CI exactly to ensure local validation mirrors remote validation

Next StepsΒΆ

Additional ResourcesΒΆ


Related Tutorials: