Skip to content

Pre-commit Hooks

Pre-commit hooks automate code quality checks before commits enter version control. This guide covers comprehensive hook configuration for Python and Django projects using Ruff, Bandit, Vulture, djhtml, and other essential tools.

Why Pre-commit Hooks Matter

Automated Quality Gates

Pre-commit hooks catch issues before they enter version control, saving time in code review and preventing broken code from reaching production.

Key Benefits: - Catch issues early - Before code review, before CI - Consistent formatting - Automatic code formatting - Security scanning - Detect vulnerabilities automatically - Fast feedback - Instant feedback while coding - Team consistency - Same checks for all developers - Reduced review time - Reviewers focus on logic, not style

Pre-commit Framework

Installation

Install pre-commit framework globally or per-project:

# With uv (recommended)
uv pip install pre-commit

# Or with pip
pip install pre-commit

# Or via package manager
brew install pre-commit  # macOS
apt install pre-commit   # Ubuntu/Debian

Basic Setup

# Initialize in your repository
cd your-project
pre-commit install

# Output:
# pre-commit installed at .git/hooks/pre-commit

This creates a Git hook that runs before each commit.

Configuration File

Create .pre-commit-config.yaml in repository root:

# Minimum viable configuration
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml

Test configuration:

# Run on all files
pre-commit run --all-files

# Run on staged files only
pre-commit run

Essential Pre-commit Hooks

Standard Pre-commit Hooks

The pre-commit-hooks repository provides fundamental checks:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      # File formatting
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-byte-order-marker

      # File conflicts
      - id: check-case-conflict
      - id: check-merge-conflict

      # File validation
      - id: check-json
      - id: check-toml
      - id: check-yaml

      # Python specific
      - id: check-docstring-first
      - id: debug-statements
      - id: name-tests-test
        args: ['--django']

      # Security
      - id: detect-private-key

      # Dependencies
      - id: requirements-txt-fixer

Hook Descriptions:

  • trailing-whitespace: Removes trailing whitespace
  • end-of-file-fixer: Ensures files end with newline
  • check-yaml: Validates YAML syntax
  • check-json: Validates JSON syntax
  • check-toml: Validates TOML syntax
  • check-docstring-first: Ensures docstring before code
  • debug-statements: Catches pdb, breakpoint() calls
  • detect-private-key: Finds private keys in code
  • name-tests-test: Ensures test files named correctly

Excluding Files and Directories

Global exclusions apply to all hooks:

exclude: >
    (?x)^(
        migrations/|
        htmlcov/|
        node_modules/|
        \.venv/|
        build/|
        dist/
    )$

Pattern Syntax: - (?x) enables verbose regex mode - ^ matches start of path - | separates multiple patterns - Trailing $ matches end

Per-hook exclusions:

- id: detect-private-key
  exclude: ^\.localstack/production-ssm-export\.json$

Ruff: Formatting and Linting

Ruff combines formatting (Black replacement) and linting (Flake8/isort replacement) in one fast tool:

- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.11.8
  hooks:
    # Linting with auto-fix
    - id: ruff
      args:
        - --unsafe-fixes
        - --fix
        - --select=T201,T203,W191,W291,W292,W605,LOG001,LOG002,LOG007,LOG009,PLC0206,PLC0208,Q000,Q001,Q002,Q003

    # Formatting
    - id: ruff-format

Ruff Linting Rules

Selected Rules Explained:

args:
  - --unsafe-fixes    # Apply potentially risky fixes
  - --fix             # Auto-fix issues
  - --select=...      # Specific rules to enforce

Rule Categories:

  • T201, T203: Disallow print() and pprint() in production code
  • W191: No tabs (use spaces)
  • W291, W292: No trailing whitespace
  • W605: Invalid escape sequences
  • LOG001, LOG002, LOG007, LOG009: Logging best practices
  • PLC0206, PLC0208: Dictionary/list comprehension issues
  • Q000, Q001, Q002, Q003: Quote consistency

Why These Specific Rules:

Most formatting and style rules are handled by ruff format. The linter focuses on: - Code that might cause bugs (escape sequences) - Production code quality (no print statements) - Logging best practices - Consistency (quotes, whitespace)

Ruff Configuration

Extend configuration in pyproject.toml:

[tool.ruff]
line-length = 180
target-version = "py313"

exclude = [
    ".git",
    ".venv",
    "__pycache__",
    "migrations",
    "node_modules",
]

[tool.ruff.lint]
select = [
    "E",    # pycodestyle errors
    "W",    # pycodestyle warnings
    "F",    # Pyflakes
    "I",    # isort
    "N",    # pep8-naming
    "UP",   # pyupgrade
    "B",    # flake8-bugbear
]

ignore = [
    "E501",  # Line too long (handled by formatter)
]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
    "S101",  # Allow assert in tests
]

Bandit: Security Scanning

Bandit scans Python code for common security issues:

- repo: https://github.com/PyCQA/bandit
  rev: 1.8.0
  hooks:
    - id: bandit
      language_version: python3
      args:
        - -l              # Severity level threshold (low)
        - --recursive     # Scan subdirectories
        - -x              # Exclude tests
        - tests
        - -q              # Quiet mode (errors only)
        - -s              # Skip specific tests
        - B310,B410,B608,B101,B314,B404
      files: \.py$

Bandit Skipped Tests

Skipped Tests Explained:

  • B310: Audit URL open for permitted schemes
  • Why skip: Django uses URLs extensively, false positives

  • B410: Import XML modules

  • Why skip: Django templates use XML, known safe contexts

  • B608: SQL injection (hardcoded SQL)

  • Why skip: Django ORM prevents this, false positives on raw strings

  • B101: Assert used

  • Why skip: Valid in tests, Python optimizes away in production

  • B314: XML parsing (blacklist)

  • Why skip: Django uses safe XML parsing

  • B404: Import subprocess module

  • Why skip: Sometimes necessary, review manually

Bandit Configuration

Create .bandit configuration file:

# .bandit
exclude_dirs:
  - /tests/
  - /migrations/
  - /node_modules/
  - /.venv/

skips:
  - B310  # Audit URL open
  - B410  # Import XML
  - B608  # SQL injection check
  - B101  # Assert usage
  - B314  # XML parsing
  - B404  # Subprocess import

tests:
  - B201  # Flask debug mode
  - B501  # Request without cert validation
  - B502  # SSL with bad version
  - B503  # SSL with bad defaults
  - B506  # Unsafe YAML load
  - B601  # Paramiko exec/shell
  - B602  # Shell=True subprocess

Inline Bandit Suppressions

For legitimate security exceptions:

# Suppress specific warning with comment
import subprocess  # nosec B404

# Execute shell command (reviewed and safe)
result = subprocess.run(["ls", "-la"], shell=False)  # nosec B602

# Hardcoded test password (only in tests)
TEST_PASSWORD = "test123"  # nosec B105

Use Suppressions Sparingly

Only suppress Bandit warnings after thorough security review. Document why the suppression is safe.

Vulture: Dead Code Detection

Vulture finds unused code:

- repo: local
  hooks:
    - id: vulture
      name: vulture
      entry: vulture .vulture.py --min-confidence 100 --exclude "venv,node_modules,migrations,management,fixtures/cassettes,.md,README.md,TEST_DATABASE_README.md,TEST_DATABASE_SETUP.md,conftest_playwright.py"
      language: system
      pass_filenames: false
      files: ^poseidon/tests/

Vulture Configuration

Create .vulture.py whitelist for false positives:

# .vulture.py
"""Vulture whitelist for false positives."""

# Django model fields accessed via ORM
_.objects  # Django Manager
_.DoesNotExist  # Django exception
_.MultipleObjectsReturned  # Django exception

# Django methods
_.get_absolute_url
_.get_queryset
_.get_context_data
_.form_valid
_.form_invalid

# Django class attributes
_.verbose_name
_.verbose_name_plural
_.ordering

# pytest fixtures
_.pytest_configure
_.pytest_sessionstart
_.pytest_collection_modifyitems

# Django settings
_.INSTALLED_APPS
_.MIDDLEWARE
_.TEMPLATES

# Django URL patterns
_.urlpatterns
_.app_name

# Django admin
_.list_display
_.list_filter
_.search_fields
_.readonly_fields

# DRF ViewSets
_.permission_classes
_.serializer_class
_.queryset
_.filter_backends

# Celery tasks
_.delay
_.apply_async

Running Vulture

# Manual run with detailed output
vulture myproject/ --min-confidence 80

# Show only high-confidence findings
vulture myproject/ --min-confidence 100

# Use whitelist
vulture myproject/ .vulture.py --min-confidence 80

Confidence Levels: - 100%: Definitely unused - 80-99%: Probably unused - 60-79%: Maybe unused

Pre-commit Recommendation: Use --min-confidence 100 to avoid false positives.

Pylint: Advanced Linting

Pylint provides comprehensive code analysis:

- repo: local
  hooks:
    - id: pylint
      name: pylint
      entry: python -u -m pylint
      language: system
      types: [python]
      pass_filenames: true
      verbose: false
      args:
        - --jobs=20
        - --persistent=no
        - --output-format=text
        - --rcfile=.pylintrc
      files: ^poseidon/tests/
      exclude: ^poseidon/tests/conftest\.py$

Pylint Configuration

Create .pylintrc:

[MASTER]
jobs=4
ignore=migrations,node_modules,.venv,venv
persistent=no

[MESSAGES CONTROL]
disable=
    C0111,  # missing-docstring
    C0103,  # invalid-name (conflicts with Django conventions)
    R0903,  # too-few-public-methods (Django models)
    R0801,  # duplicate-code (tests often similar)
    W0212,  # protected-access (needed for Django internals)
    W0613,  # unused-argument (Django view signatures)

[FORMAT]
max-line-length=180
indent-string='    '

[DESIGN]
max-args=10
max-locals=25
max-returns=10
max-branches=20
max-statements=75

[SIMILARITIES]
min-similarity-lines=10
ignore-comments=yes
ignore-docstrings=yes

Django-Specific Disables:

  • C0103: Django uses pk, id (not PEP 8 compliant)
  • R0903: Django models often have few public methods
  • W0212: Accessing _meta and other protected attributes is common
  • W0613: Django views receive request even if unused

djhtml: Django Template Formatting

Format Django/Jinja templates:

- repo: https://github.com/rtts/djhtml
  rev: '3.0.7'
  hooks:
    - id: djhtml
      files: \.*/templates/.*\.html$

Features: - Indents Django template tags - Formats HTML consistently - Handles {% block %}, {% if %}, etc. - Preserves template logic

Example:

<!-- Before djhtml -->
{% if user.is_authenticated %}
<div class="welcome">
<h1>Welcome, {{ user.username }}!</h1>
</div>
{% endif %}

<!-- After djhtml -->
{% if user.is_authenticated %}
    <div class="welcome">
        <h1>Welcome, {{ user.username }}!</h1>
    </div>
{% endif %}

pyupgrade: Modern Python Syntax

Automatically upgrade to modern Python syntax:

- repo: https://github.com/asottile/pyupgrade
  rev: v3.19.1
  hooks:
    - id: pyupgrade
      args: [--py312-plus]

Transformations:

# Before pyupgrade
from typing import List, Dict, Optional

def process(items: List[str]) -> Dict[str, int]:
    result: Dict[str, int] = {}
    for item in items:
        result[item] = len(item)
    return result

# After pyupgrade (Python 3.12+)
def process(items: list[str]) -> dict[str, int]:
    result: dict[str, int] = {}
    for item in items:
        result[item] = len(item)
    return result

Other Upgrades: - dict(){} - set([])set() - .format() → f-strings (where safe) - Old-style % formatting → f-strings - typing.Listlist (Python 3.9+)

Custom Local Hooks

Create project-specific hooks:

- repo: local
  hooks:
    - id: check-gunicorn
      name: Check if Gunicorn is installed
      entry: python .hooks/check_gunicorn.py
      language: system
      types: [python]
      files: ''
      pass_filenames: false

Custom Hook Script (.hooks/check_gunicorn.py):

#!/usr/bin/env python
"""Check if Gunicorn is properly installed in requirements."""

import sys
from pathlib import Path

def check_gunicorn():
    """Ensure Gunicorn is in requirements for production."""
    req_file = Path("requirements/production.txt")

    if not req_file.exists():
        print(f"Warning: {req_file} not found")
        return 0

    content = req_file.read_text()

    if "gunicorn" not in content.lower():
        print(f"Error: Gunicorn not found in {req_file}")
        print("Add 'gunicorn>=22.0.0' to production requirements")
        return 1

    return 0

if __name__ == "__main__":
    sys.exit(check_gunicorn())

Other Custom Hook Ideas:

- repo: local
  hooks:
    # Check for Django migrations
    - id: check-migrations
      name: Check for pending migrations
      entry: python manage.py makemigrations --check --dry-run
      language: system
      pass_filenames: false
      files: models\.py$

    # Validate Django settings
    - id: check-django
      name: Django system check
      entry: python manage.py check --deploy
      language: system
      pass_filenames: false

    # Security check
    - id: check-security
      name: Django security check
      entry: python manage.py check --deploy --fail-level WARNING
      language: system
      pass_filenames: false

Complete Configuration Example

Production-ready .pre-commit-config.yaml:

# Python & Django Pre-commit Configuration
# Comprehensive hooks for code quality, security, and consistency

default_language_version:
  python: python3.13

default_stages: [pre-commit, pre-push]

exclude: >
  (?x)^(
      migrations/|
      htmlcov/|
      node_modules/|
      \.venv/|
      build/|
      dist/
  )$

repos:
  # Standard pre-commit hooks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      # File formatting
      - id: check-added-large-files
      - id: check-byte-order-marker
      - id: check-case-conflict
      - id: trailing-whitespace
      - id: end-of-file-fixer

      # File validation
      - id: check-json
      - id: check-toml
      - id: check-yaml

      # Python specific
      - id: check-docstring-first
      - id: debug-statements
      - id: name-tests-test
        args: ['--django']
        exclude: ^tests/conftest\.py$

      # Security
      - id: detect-private-key
        exclude: ^\.localstack/production-ssm-export\.json$

      # Dependencies
      - id: requirements-txt-fixer

      # Merge conflicts
      - id: check-merge-conflict

  # Security scanning
  - repo: https://github.com/PyCQA/bandit
    rev: 1.8.0
    hooks:
      - id: bandit
        language_version: python3
        args:
          - -l
          - --recursive
          - -x
          - tests
          - -q
          - -s
          - B310,B410,B608,B101,B314,B404
        files: \.py$

  # Advanced linting
  - repo: local
    hooks:
      - id: pylint
        name: pylint
        entry: python -u -m pylint
        language: system
        types: [python]
        pass_filenames: true
        verbose: false
        args:
          - --jobs=20
          - --persistent=no
          - --output-format=text
          - --rcfile=.pylintrc
        files: ^tests/
        exclude: ^tests/conftest\.py$

  # Dead code detection
  - repo: local
    hooks:
      - id: vulture
        name: vulture
        entry: vulture .vulture.py --min-confidence 100 --exclude "venv,node_modules,migrations,management"
        language: system
        pass_filenames: false
        files: ^src/

  # Ruff linting (fast, comprehensive)
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.11.8
    hooks:
      - id: ruff
        args:
          - --unsafe-fixes
          - --fix
          - --select=T201,T203,W191,W291,W292,W605,LOG001,LOG002,LOG007,LOG009,PLC0206,PLC0208,Q000,Q001,Q002,Q003

  # Ruff formatting (Black replacement)
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.11.8
    hooks:
      - id: ruff-format

  # Django template formatting
  - repo: https://github.com/rtts/djhtml
    rev: '3.0.7'
    hooks:
      - id: djhtml
        files: \.*/templates/.*\.html$

  # Modern Python syntax
  - repo: https://github.com/asottile/pyupgrade
    rev: v3.19.1
    hooks:
      - id: pyupgrade
        args: [--py312-plus]

  # Custom project hooks
  - repo: local
    hooks:
      - id: check-django
        name: Django system check
        entry: python manage.py check
        language: system
        pass_filenames: false
        files: \.py$

ci:
  autoupdate_schedule: weekly
  skip: []
  submodules: false

Hook Execution Stages

Configure when hooks run:

default_stages: [pre-commit, pre-push]

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      # Run on every commit
      - id: trailing-whitespace
        stages: [pre-commit]

      # Run only before push
      - id: check-added-large-files
        stages: [pre-push]

      # Run manually only
      - id: check-yaml
        stages: [manual]

Available Stages: - pre-commit: Before git commit - pre-push: Before git push - pre-merge-commit: Before merge commit - pre-rebase: Before rebase - manual: Only when run manually

Install Hooks for Stages:

# Install all stages
pre-commit install --install-hooks --hook-type pre-commit
pre-commit install --hook-type pre-push
pre-commit install --hook-type pre-merge-commit

# Or all at once
pre-commit install --install-hooks -t pre-commit -t pre-push

Running Pre-commit Hooks

Automatic Runs

After pre-commit install, hooks run automatically:

# Commit triggers pre-commit hooks
git add .
git commit -m "feat: add new feature"

# Output:
# Trim Trailing Whitespace.......Passed
# Fix End of Files...............Passed
# Check Yaml.....................Passed
# ruff.........................Passed
# ruff-format..................Passed

If hooks fail, commit is aborted:

git commit -m "fix: bug fix"

# Output:
# Trim Trailing Whitespace.......Failed
# - hook id: trailing-whitespace
# - files were modified by this hook
#
# Fixing myfile.py

# Fix issues and retry
git add .
git commit -m "fix: bug fix"

Manual Runs

Run hooks manually anytime:

# Run all hooks on all files
pre-commit run --all-files

# Run all hooks on staged files
pre-commit run

# Run specific hook
pre-commit run ruff --all-files
pre-commit run bandit --all-files

# Run on specific files
pre-commit run --files myapp/models.py myapp/views.py

Skipping Hooks

Skip hooks when necessary (use sparingly):

# Skip all hooks for one commit
git commit -m "fix: urgent hotfix" --no-verify

# Skip specific hook
SKIP=pylint git commit -m "fix: temp commit"

# Skip multiple hooks
SKIP=pylint,bandit git commit -m "WIP: work in progress"

Use --no-verify Sparingly

Skipping hooks should be rare and only for emergencies. The hooks exist to maintain code quality and security.

Updating Hooks

Manual Updates

Update hook versions:

# Update all hooks to latest versions
pre-commit autoupdate

# Update specific repository
pre-commit autoupdate --repo https://github.com/astral-sh/ruff-pre-commit

Output:

Updating https://github.com/pre-commit/pre-commit-hooks ... updating v4.5.0 -> v5.0.0.
Updating https://github.com/astral-sh/ruff-pre-commit ... updating v0.11.0 -> v0.11.8.

CI Auto-updates

Configure automatic updates in .pre-commit-config.yaml:

ci:
  autoupdate_schedule: weekly  # or monthly, quarterly
  autoupdate_branch: 'main'
  skip: []                     # Hooks to skip in CI
  submodules: false

Pre-commit.ci service (if enabled) will create PRs with updates.

Troubleshooting

Hook Failures

Common Issues:

# Hook not found
# Solution: Install hooks
pre-commit install

# Python not found
# Solution: Ensure Python in PATH
which python3

# Hook fails on specific file
# Solution: Exclude file
# In .pre-commit-config.yaml:
exclude: problematic_file\.py$

# Hook takes too long
# Solution: Limit files
files: ^src/  # Only run on src/ directory

Performance Optimization

# Speed up hooks
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.11.8
    hooks:
      - id: ruff
        # Limit to changed files only (default)
        pass_filenames: true

        # Or run on whole codebase
        pass_filenames: false
        args: [--all-files]

Performance Tips:

  1. Use fast tools: Ruff (Rust) > Pylint (Python)
  2. Limit scope: Only run on changed files
  3. Parallel execution: Pre-commit runs hooks in parallel
  4. Skip slow hooks: Move slow checks to pre-push or CI

Debugging Hooks

# Verbose output
pre-commit run --verbose --all-files

# Show which hooks will run
pre-commit run --show-diff-on-failure

# Run in debug mode
pre-commit run --all-files --verbose --debug

CI/CD Integration

GitHub Actions

Run pre-commit in CI:

# .github/workflows/pre-commit.yml
name: Pre-commit

on:
  pull_request:
  push:
    branches: [main, develop]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.13'

      - uses: pre-commit/action@v3.0.1

GitLab CI

# .gitlab-ci.yml
pre-commit:
  image: python:3.13-slim
  stage: test
  before_script:
    - pip install pre-commit
  script:
    - pre-commit run --all-files
  cache:
    paths:
      - ~/.cache/pre-commit

Pre-commit.ci Service

Free service for open-source projects:

  1. Visit pre-commit.ci
  2. Enable for your repository
  3. Configure auto-fix PRs

Benefits: - Automatic hook runs on PRs - Auto-fix commits - Auto-update hook versions - Free for open source

Team Adoption

Initial Setup

# 1. Install pre-commit framework
pip install pre-commit

# 2. Create configuration
cat > .pre-commit-config.yaml << 'EOF'
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.11.8
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
EOF

# 3. Install hooks
pre-commit install

# 4. Run on all files to establish baseline
pre-commit run --all-files

# 5. Commit configuration
git add .pre-commit-config.yaml
git commit -m "chore: add pre-commit hooks"

Team Onboarding

Document in CONTRIBUTING.md:

## Pre-commit Hooks

This project uses pre-commit hooks to maintain code quality.

### Setup

```bash
# Install pre-commit framework
pip install pre-commit

# Install hooks
pre-commit install

# Run manually
pre-commit run --all-files

What Hooks Do

  • Ruff format: Formats Python code (Black-compatible)
  • Ruff lint: Checks code style and quality
  • Bandit: Scans for security vulnerabilities
  • djhtml: Formats Django templates
  • pyupgrade: Upgrades to modern Python syntax

Dealing with Failures

If a hook fails:

  1. Auto-fixed: Add fixed files and commit again
  2. Manual fix: Fix the issue and commit again
  3. False positive: Suppress or exclude (discuss with team)

Skipping Hooks (Emergency Only)

git commit --no-verify -m "fix: urgent hotfix"

Only skip hooks for emergencies!

### Gradual Rollout

For existing projects with many violations:

```bash
# 1. Start with just formatting
cat > .pre-commit-config.yaml << 'EOF'
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.11.8
    hooks:
      - id: ruff-format
EOF

# 2. Format entire codebase
ruff format .
git add .
git commit -m "style: format codebase with Ruff"

# 3. Add more hooks incrementally
# Week 2: Add basic checks
# Week 3: Add linting
# Week 4: Add security scanning

Best Practices

✅ Do

  • Install hooks immediately - First thing in new project
  • Run on all files initially - Establish baseline
  • Keep hooks fast - < 10 seconds for pre-commit
  • Auto-fix when possible - Reduce manual work
  • Document suppressions - Explain why issues are ignored
  • Update regularly - Use pre-commit autoupdate
  • Run in CI - Catch missed checks
  • Share configuration - Commit .pre-commit-config.yaml

❌ Don't

  • Don't skip hooks routinely
  • Don't add too many slow hooks to pre-commit
  • Don't suppress warnings without understanding them
  • Don't install hooks without team agreement
  • Don't use --no-verify except emergencies
  • Don't add hooks that require manual intervention
  • Don't forget to install hooks after cloning

Hook Selection Guidelines

Pre-commit Stage (fast, automatic): - Code formatting (Ruff format) - Basic linting (Ruff) - File validation (YAML, JSON) - Trailing whitespace, EOF - Debug statement detection

Pre-push Stage (slower, less frequent): - Comprehensive linting (Pylint) - Security scanning (Bandit) - Dead code detection (Vulture) - Type checking (mypy) - Django system checks

CI Only (slowest, most comprehensive): - Full test suite - Coverage reports - Integration tests - Build verification - Deployment checks

Advanced Configurations

Conditional Hooks

Run hooks based on conditions:

repos:
  - repo: local
    hooks:
      # Only run in production branch
      - id: production-checks
        name: Production readiness checks
        entry: bash -c 'if [ "$PRE_COMMIT_FROM_REF" = "refs/heads/main" ]; then python manage.py check --deploy; fi'
        language: system
        pass_filenames: false

Multi-language Hooks

For projects with Python and JavaScript:

repos:
  # Python hooks
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.11.8
    hooks:
      - id: ruff-format
      - id: ruff

  # JavaScript hooks
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v9.0.0
    hooks:
      - id: eslint
        files: \.[jt]sx?$
        types: [file]

  # CSS hooks
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier
        files: \.(css|scss|html)$

Environment-Specific Hooks

repos:
  - repo: local
    hooks:
      - id: check-env
        name: Check environment variables
        entry: python .hooks/check_env.py
        language: system
        pass_filenames: false

Custom check script (.hooks/check_env.py):

#!/usr/bin/env python
"""Check required environment variables are documented."""

import os
import sys
from pathlib import Path

REQUIRED_VARS = [
    "DATABASE_URL",
    "SECRET_KEY",
    "REDIS_URL",
]

def check_env_example():
    """Ensure .env.example contains all required variables."""
    env_example = Path(".env.example")

    if not env_example.exists():
        print("Error: .env.example not found")
        return 1

    content = env_example.read_text()
    missing = [var for var in REQUIRED_VARS if var not in content]

    if missing:
        print(f"Error: Missing variables in .env.example: {missing}")
        return 1

    print("✓ All required variables documented in .env.example")
    return 0

if __name__ == "__main__":
    sys.exit(check_env_example())

Integration with justfile

Combine pre-commit with justfile commands:

# Run pre-commit hooks
pc:
    pre-commit run --all-files

# Run pre-commit on specific hook
pch HOOK:
    pre-commit run {{HOOK}} --all-files

# Update pre-commit hooks
pcu:
    pre-commit autoupdate

# Install pre-commit hooks
pci:
    pre-commit install --install-hooks -t pre-commit -t pre-push

# Run all quality checks
check: pc
    pytest --cov=myapp
    python manage.py check --deploy

Usage:

# Run all hooks
just pc

# Run specific hook
just pch ruff

# Update hooks
just pcu

# Full quality check
just check

Summary

Pre-commit hooks automate code quality and security checks, catching issues before they enter version control. Key takeaways:

  • Ruff provides fast formatting and linting (replaces Black + Flake8)
  • Bandit scans for security vulnerabilities
  • Vulture detects unused code
  • Pylint offers comprehensive code analysis
  • djhtml formats Django templates
  • pyupgrade modernizes Python syntax
  • Custom hooks enforce project-specific requirements

Essential Configuration: 1. Start with Ruff (format + lint) 2. Add security scanning (Bandit) 3. Include Django-specific checks 4. Customize for your workflow 5. Run in CI for enforcement

Well-configured pre-commit hooks maintain consistent code quality with minimal developer effort.

Next Steps