DevOpsil
Git
94%
Fresh
Part 6 of 6 in CI/CD Mastery

Pre-Commit Hooks Framework for Automated Code Quality

Sarah ChenSarah Chen8 min read

The Config First

Here's the .pre-commit-config.yaml that catches 90% of issues before they hit CI. Then I'll walk through it.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
        args: [--allow-multiple-documents]
      - id: check-json
      - id: check-merge-conflict
      - id: detect-private-key
      - id: no-commit-to-branch
        args: [--branch, main, --branch, master]

  - repo: https://github.com/psf/black
    rev: "24.4.2"
    hooks:
      - id: black

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.8
    hooks:
      - id: ruff
        args: [--fix]

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint-docker

  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.92.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint

That runs on every git commit. No exceptions. No excuses.

Why Pre-Commit Hooks

Code reviews should be about logic and architecture. Not trailing whitespace. Not import ordering. Not formatting debates.

Pre-commit hooks eliminate the noise:

  • Catch issues in seconds, not in a 15-minute CI run
  • Fix problems automatically — Black formats, Ruff fixes lint errors
  • Block bad commits before they pollute your history
  • Standardize across the team without relying on editor configs

If it's not automated, it doesn't exist. Manual "please run the linter" comments in PRs are proof.

Installation

# Install pre-commit
pip install pre-commit

# Or via Homebrew
brew install pre-commit

# Install the hooks in your repo
cd your-repo
pre-commit install

# Also install for commit messages (optional)
pre-commit install --hook-type commit-msg

That last command is a one-time setup per clone. After this, hooks run automatically.

Running Against All Files

First time setting up hooks? Run them against everything:

# Check all files, not just staged ones
pre-commit run --all-files

# Run a specific hook
pre-commit run black --all-files

# Run only on staged files (what happens on commit)
pre-commit run

Expect failures on the first run. That's the point. Fix them all now so every future commit starts clean.

Breaking Down the Hooks

The Foundation Hooks

- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v4.6.0
  hooks:
    - id: trailing-whitespace
    - id: end-of-file-fixer
    - id: check-yaml
    - id: check-merge-conflict
    - id: detect-private-key
    - id: no-commit-to-branch
      args: [--branch, main]

These are non-negotiable. detect-private-key has saved teams from credential leaks. no-commit-to-branch prevents accidental pushes to main. Zero configuration, maximum protection.

Auto-Formatting

- repo: https://github.com/psf/black
  rev: "24.4.2"
  hooks:
    - id: black

Black formats your Python code. No arguments. No debates. The hook modifies files in-place, then the commit is aborted so you can review the changes and re-stage.

Fast Linting with Ruff

- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.4.8
  hooks:
    - id: ruff
      args: [--fix]

Ruff replaces Flake8, isort, and a dozen other tools. It runs in milliseconds, not seconds. The --fix flag auto-corrects safe issues.

Custom Local Hooks

Need something specific to your project? Write a local hook:

- repo: local
  hooks:
    - id: check-migrations
      name: Check Django migrations
      entry: python manage.py makemigrations --check --dry-run
      language: system
      types: [python]
      pass_filenames: false

    - id: helm-lint
      name: Lint Helm charts
      entry: helm lint
      language: system
      files: ^charts/
      pass_filenames: false

Local hooks don't need an external repo. They run any command available on the system.

CI Integration

Pre-commit should also run in CI as a safety net:

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

on:
  pull_request:
  push:
    branches: [main]

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

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

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

Five lines. Catches anyone who skipped local hooks with --no-verify. Trust but verify.

Keeping Hooks Updated

# Update all hooks to latest versions
pre-commit autoupdate

# Update a specific repo
pre-commit autoupdate --repo https://github.com/psf/black

# Freeze to exact SHAs (for reproducibility)
pre-commit autoupdate --freeze

Run pre-commit autoupdate monthly. Pin to tags, not branches. Use --freeze in security-sensitive environments to get SHA-pinned versions.

Performance Tips

Slow hooks kill adoption. Keep them fast:

# Only run on relevant files
- id: mypy
  files: ^src/
  types: [python]

# Skip heavy hooks during rapid iteration
# Developers can run: SKIP=mypy git commit -m "wip"

Target file types with types and files patterns. MyPy doesn't need to scan your docs folder. Hadolint only cares about Dockerfiles.

Allow temporary skips with the SKIP environment variable. Better than --no-verify because it only skips specific hooks.

Language-Specific Hook Configurations

JavaScript/TypeScript Projects

repos:
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        types_or: [javascript, jsx, ts, tsx, json, yaml, css]

  - repo: local
    hooks:
      - id: eslint
        name: ESLint
        entry: npx eslint --fix
        language: system
        types_or: [javascript, jsx, ts, tsx]
        pass_filenames: true

Go Projects

repos:
  - repo: https://github.com/dnephin/pre-commit-golang
    rev: v0.5.1
    hooks:
      - id: go-fmt
      - id: go-vet
      - id: golangci-lint
        args: [--fast]

  - repo: local
    hooks:
      - id: go-test
        name: Run Go unit tests
        entry: go test -short ./...
        language: system
        pass_filenames: false
        types: [go]

Multi-Language Monorepo

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: detect-private-key

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.8
    hooks:
      - id: ruff
        args: [--fix]
        files: ^(services/api|libs/python)/

  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.92.0
    hooks:
      - id: terraform_fmt
        files: ^infrastructure/
      - id: terraform_validate
        files: ^infrastructure/

Use files patterns to scope hooks to specific directories. In a monorepo, Python linting shouldn't slow down a developer who only changed Terraform files.

Secrets Detection Deep Dive

Credential leaks are expensive. Set up multiple layers of detection.

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: detect-private-key

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.2
    hooks:
      - id: gitleaks

Gitleaks catches more than just private keys. It detects AWS access keys, GitHub tokens, Slack webhooks, database connection strings, and dozens of other patterns.

Create a .gitleaks.toml to customize:

# .gitleaks.toml
[allowlist]
  paths = [
    '''test/fixtures/.*''',
    '''docs/examples/.*'''
  ]

[[rules]]
  id = "custom-api-key"
  description = "Internal API key pattern"
  regex = '''DEVOPSIL_API_KEY_[A-Za-z0-9]{32}'''
  secretGroup = 0

Team Adoption Strategy

The biggest risk with pre-commit hooks isn't the technology — it's adoption. Here's how to roll them out without a revolt.

Phase 1: Start with formatters only. Black, Prettier, terraform_fmt. These auto-fix issues without requiring manual work. Developers see immediate value.

Phase 2: Add linters with auto-fix. Ruff with --fix, ESLint with --fix. Still minimal friction — most issues resolve automatically.

Phase 3: Add validators. MyPy, terraform_validate, kubeval. These require manual fixes but catch real bugs.

Phase 4: Add security checks. Gitleaks, detect-private-key, Hadolint. Now the team trusts the system enough to accept blocking checks.

At each phase, run pre-commit run --all-files on the main branch first to fix existing violations. Never add a hook that immediately fails on existing code — that teaches people to skip hooks.

Troubleshooting

Problem: Hook runs but doesn't see all files. Fix: Pre-commit only checks staged files by default. Use --all-files for full scans. If you partially stage a file (git add -p), the hook sees the staged version, not the working directory version.

Problem: Different results locally vs CI. Fix: Pin exact versions with rev. Ensure CI uses the same .pre-commit-config.yaml. Check that system-level hooks have the same tool version in both environments.

Problem: Team members skip hooks with --no-verify. Fix: Run pre-commit in CI. The CI check is the enforcement. Local hooks are the convenience.

Problem: Hooks are too slow (> 10 seconds). Fix: Profile with pre-commit run --verbose to identify the slow hook. Scope heavy hooks with files patterns. Move truly slow checks (full test suites) to CI instead.

Problem: Python version conflicts between hooks and project. Fix: Pre-commit manages its own virtual environments per hook. If a hook needs a specific Python version, set language_version: python3.12 on the hook entry.

Measuring Impact

Track these metrics before and after rolling out pre-commit hooks:

  • CI failure rate due to lint/format issues — should drop to near zero
  • Average PR review cycle time — should decrease as reviewers stop flagging style issues
  • Number of PR comments about formatting — should vanish
  • Secret leak incidents — should drop to zero for committed code

Run this to see how many commits would have been caught:

# Check how many files currently fail hooks
pre-commit run --all-files 2>&1 | grep -c "Failed"

If the number is high on first run, that's technical debt being surfaced. Fix it once, and every future commit starts clean.

Conclusion

Pre-commit hooks shift quality checks left — to the moment code is written, not the moment it's reviewed. Install the framework, add the hooks, run them in CI as backup. Start with formatters, add linters, then layer in security scanning. Scope hooks to relevant files in monorepos. Roll out gradually so the team builds trust in the system. Arguments about code style end permanently. Your reviewers focus on what matters. That's the whole point.

Share:
Sarah Chen
Sarah Chen

CI/CD Engineering Lead

Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.

Related Articles

GitQuick RefFresh

Git Commands: Cheat Sheet

Git commands cheat sheet for DevOps engineers — branching, rebasing, stashing, bisecting, cherry-picking, and recovery workflows with examples.

Sarah Chen·
3 min read