LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Pre-commit Hooks and Code Quality Gates: Enforcing Standards Before They Become Problems

pre-commitcode-qualitylintinggit-hooksdevexci-cdtooling

Code review is expensive. A reviewer who has to point out that a function is missing a type annotation, that a secret is hardcoded, or that the import order is wrong is a reviewer who isn’t reviewing logic, architecture, or correctness. Automated quality gates eliminate entire categories of review feedback before a PR is even opened.

The pre-commit framework is the standard tool for managing git hooks across a team. One YAML file configures dozens of linters, formatters, and security scanners in a reproducible way. Every contributor gets the same checks, running against only the files they changed, with no global tool installation required.

This guide covers the full pre-commit ecosystem: installing and configuring the framework, the best hooks for Python, Go, JavaScript, Rust, and infrastructure code, writing your own custom hooks, integrating with CI so pre-commit can’t be skipped, and building a quality gate strategy that improves code without creating friction.


How pre-commit Works

Git Hooks Without the Pain

Git supports hooks — scripts that run at specific points in the git lifecycle (pre-commit, commit-msg, pre-push, post-merge, etc.). The problem with managing them directly: they live in .git/hooks/ which isn’t committed to the repo, so every contributor has to set them up manually.

The pre-commit framework solves this by:

  1. Storing hook configuration in .pre-commit-config.yaml — committed to the repo, versioned, reviewed like any other file
  2. Managing isolated tool environments — each hook runs in its own virtualenv, conda env, or language-specific environment; no global installation required
  3. Running only on changed files — by default, hooks only see the files staged for the current commit, making them fast
  4. Providing a registry of hundreds of ready-made hookspre-commit.com/hooks.html lists community hooks for every language and tool

Installation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install pre-commit itself (only needs to be done once per machine)
pip install pre-commit
# or: brew install pre-commit
# or: pipx install pre-commit (recommended — isolated from system Python)

# Install the git hooks into your repository
cd your-project
pre-commit install

# Also install commit-msg hook (for conventional commits, if desired)
pre-commit install --hook-type commit-msg

# Verify: make a commit and watch hooks run
git add . && git commit -m "test"

How a Hook Run Works

git commit
    │
    ▼
pre-commit runs (for each hook in .pre-commit-config.yaml):
    │
    ├── Filter: which staged files match this hook's file patterns?
    ├── Check cache: has this file+hook combination run before with same content?
    │   └── Cache hit → skip (instant)
    │   └── Cache miss → run the hook tool on the matching files
    │
    ├── Tool output:
    │   ├── Exit 0 → passed, continue to next hook
    │   └── Exit non-zero → FAILED, abort commit
    │       └── (some hooks auto-fix and exit non-zero so you re-stage)
    │
    └── All hooks passed → commit proceeds

The caching is key to performance. After the first run, unchanged files are skipped entirely. A 50-hook configuration on a large repo typically runs in 2–5 seconds for a small change.


The .pre-commit-config.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# .pre-commit-config.yaml
# The central configuration — commit this to your repo

# Global settings
default_language_version:
  python: python3.12
  node: "20.11.0"

# Whether to fail fast (stop on first hook failure) or run all hooks
fail_fast: false

repos:
  # Each entry is a "repo" — a source of hook definitions
  - 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-json
      - id: check-toml
      - id: check-merge-conflict
      - id: check-added-large-files
        args: ["--maxkb=500"]
      - id: mixed-line-ending
        args: ["--fix=lf"]
      - id: detect-private-key
      - id: no-commit-to-branch
        args: ["--branch", "main", "--branch", "master"]

Hooks by Language

Python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
repos:
  # Ruff: extremely fast Python linter + formatter (replaces flake8, isort, pyupgrade, and more)
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: ["--fix"]        # Auto-fix what can be fixed
      - id: ruff-format        # Replaces black

  # Mypy: static type checking
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies:
          - types-requests
          - types-pyyaml
          - sqlalchemy[mypy]
        args: ["--strict", "--ignore-missing-imports"]

  # Bandit: security linter (finds hardcoded passwords, SQL injection patterns, etc.)
  - repo: https://github.com/PyCQA/bandit
    rev: 1.7.8
    hooks:
      - id: bandit
        args: ["-c", "pyproject.toml"]
        # pyproject.toml [tool.bandit] section configures skips

  # pyproject-fmt: format pyproject.toml consistently
  - repo: https://github.com/tox-dev/pyproject-fmt
    rev: "2.1.3"
    hooks:
      - id: pyproject-fmt
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# pyproject.toml — configure all the Python tools in one place

[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = [
  "E",   # pycodestyle errors
  "W",   # pycodestyle warnings
  "F",   # pyflakes
  "I",   # isort
  "B",   # flake8-bugbear (common bugs)
  "C4",  # flake8-comprehensions (unnecessary list/dict/set calls)
  "UP",  # pyupgrade (modernize Python syntax)
  "S",   # bandit security rules (via ruff)
  "N",   # pep8-naming
  "ANN", # flake8-annotations (missing type annotations)
  "PT",  # flake8-pytest-style
]
ignore = [
  "ANN101",  # Missing type annotation for `self`
  "ANN102",  # Missing type annotation for `cls`
  "S101",    # Use of assert (fine in tests)
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "ANN"]

[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true

[tool.bandit]
skips = ["B101"]  # Skip assert warnings (mypy handles this better)

JavaScript / TypeScript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
  # ESLint: linting
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v9.3.0
    hooks:
      - id: eslint
        files: \.[jt]sx?$
        types: [file]
        additional_dependencies:
          - eslint@9.3.0
          - "@typescript-eslint/eslint-plugin@7.9.0"
          - "@typescript-eslint/parser@7.9.0"
          - eslint-plugin-react@7.34.1
          - eslint-plugin-react-hooks@4.6.2
          - eslint-config-prettier@9.1.0

  # Prettier: formatting
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        types_or: [javascript, jsx, ts, tsx, css, scss, json, yaml, markdown]
        additional_dependencies:
          - prettier@3.2.5
          - "@trivago/prettier-plugin-sort-imports@4.3.0"
          - prettier-plugin-tailwindcss@0.5.14

  # TypeScript type checking
  - repo: local
    hooks:
      - id: tsc
        name: TypeScript type check
        language: node
        entry: npx tsc --noEmit
        pass_filenames: false  # tsc checks the whole project
        types: [ts, tsx]

Go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  # gofmt + goimports
  - repo: https://github.com/dnephin/pre-commit-golang
    rev: v0.5.1
    hooks:
      - id: go-fmt
      - id: go-imports
      - id: go-vet
      - id: go-build

  # golangci-lint: comprehensive linter suite
  - repo: https://github.com/golangci/golangci-lint
    rev: v1.59.1
    hooks:
      - id: golangci-lint
        args: ["--fix", "--timeout=5m"]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# .golangci.yml
linters:
  enable:
    - gofmt
    - govet
    - errcheck        # Check for unchecked errors
    - staticcheck     # Advanced static analysis
    - gosimple        # Suggest simplifications
    - ineffassign     # Detect ineffectual assignments
    - typecheck       # Compilation errors
    - gocritic        # Style and performance suggestions
    - gocyclo         # Cyclomatic complexity
    - gosec           # Security checks
    - misspell        # Spell check comments and strings
    - prealloc        # Suggest slice preallocation
    - bodyclose       # Check HTTP response body is closed
    - noctx           # Find HTTP requests sent without context
    - sqlcloserows    # Check sql.Rows.Close()
    - wrapcheck       # Ensure errors from external packages are wrapped

linters-settings:
  gocyclo:
    min-complexity: 15
  gocritic:
    enabled-tags:
      - diagnostic
      - experimental
      - opinionated
      - style
  gosec:
    excludes:
      - G104  # Errors unhandled (covered by errcheck)

issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - gosec
        - errcheck

Rust

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
  - repo: local
    hooks:
      - id: cargo-fmt
        name: cargo fmt
        language: system
        entry: cargo fmt --
        types: [rust]
        pass_filenames: false

      - id: cargo-clippy
        name: cargo clippy
        language: system
        entry: cargo clippy -- -D warnings
        types: [rust]
        pass_filenames: false

      - id: cargo-test
        name: cargo test
        language: system
        entry: cargo test --quiet
        pass_filenames: false
        stages: [pre-push]  # Only run on push, not every commit

Shell Scripts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  # shellcheck: comprehensive shell script linter
  - repo: https://github.com/shellcheck-py/shellcheck-py
    rev: v0.10.0.1
    hooks:
      - id: shellcheck
        args: ["--severity=warning", "--shell=bash"]

  # shfmt: shell script formatter
  - repo: https://github.com/scop/pre-commit-shfmt
    rev: v3.8.0-1
    hooks:
      - id: shfmt
        args: ["-i", "2", "-ci", "-bn"]  # 2-space indent, case indent, binary next line

Infrastructure (Terraform, Kubernetes, Docker)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
  # Terraform
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.92.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_docs         # Auto-update README with terraform-docs
        args:
          - "--args=--config=.terraform-docs.yml"
      - id: terraform_tflint
        args:
          - "--args=--only=terraform_deprecated_interpolation"
          - "--args=--only=terraform_deprecated_index"
          - "--args=--only=terraform_unused_declarations"
          - "--args=--only=terraform_comment_syntax"
          - "--args=--only=terraform_documented_outputs"
          - "--args=--only=terraform_documented_variables"
          - "--args=--only=terraform_typed_variables"
          - "--args=--only=terraform_module_pinned_source"
          - "--args=--only=terraform_naming_convention"
      - id: terraform_checkov      # Security and compliance scanning

  # Kubernetes manifests
  - repo: https://github.com/pre-commit/mirrors-kubeval
    rev: v0.16.1
    hooks:
      - id: kubeval
        files: \.(yaml|yml)$
        args: ["--strict", "--ignore-missing-schemas"]

  # Helm charts
  - repo: https://github.com/gruntwork-io/pre-commit
    rev: v0.1.23
    hooks:
      - id: helmlint

  # Dockerfile linting
  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.1-beta
    hooks:
      - id: hadolint-docker
        args: ["--ignore", "DL3008", "--ignore", "DL3013"]
        # DL3008: pin apt versions (noisy in dev)
        # DL3013: pin pip versions (handled by lock files)

  # Docker Compose validation
  - repo: https://github.com/IamTheFij/docker-pre-commit
    rev: v3.0.1
    hooks:
      - id: docker-compose-check

Security: Secret Detection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  # Gitleaks: detect secrets, API keys, tokens in code
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.3
    hooks:
      - id: gitleaks

  # detect-secrets: alternative, integrates with CI baseline tracking
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: ["--baseline", ".secrets.baseline"]
        # Create baseline: detect-secrets scan > .secrets.baseline
        # Review baseline carefully — it marks known "false positives"

Commit Message Formatting

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  # Conventional Commits format enforcement
  - repo: https://github.com/compilerla/conventional-pre-commit
    rev: v3.3.0
    hooks:
      - id: conventional-pre-commit
        stages: [commit-msg]
        args:
          - feat
          - fix
          - docs
          - style
          - refactor
          - perf
          - test
          - build
          - ci
          - chore
          - revert

  # Or use commitlint for more flexibility:
  - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
    rev: v9.16.0
    hooks:
      - id: commitlint
        stages: [commit-msg]
        additional_dependencies: ["@commitlint/config-conventional"]

A Complete .pre-commit-config.yaml

Here’s a production-ready configuration for a Python + TypeScript monorepo:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# .pre-commit-config.yaml
default_language_version:
  python: python3.12
  node: "20.11.0"

fail_fast: false

repos:
  # ─── Universal ────────────────────────────────────────────────────────────
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
        exclude: ".*\\.md$"        # Allow trailing spaces in Markdown
      - id: end-of-file-fixer
      - id: check-yaml
        args: ["--unsafe"]          # Allow custom YAML tags
      - id: check-json
      - id: check-toml
      - id: check-xml
      - id: check-merge-conflict
      - id: check-case-conflict     # Prevent case-sensitivity issues on macOS→Linux
      - id: check-symlinks
      - id: check-added-large-files
        args: ["--maxkb=1000"]
      - id: mixed-line-ending
        args: ["--fix=lf"]
      - id: detect-private-key
      - id: no-commit-to-branch
        args: ["--branch", "main", "--branch", "master", "--branch", "production"]
      - id: check-executables-have-shebangs
      - id: check-shebang-scripts-are-executable

  # ─── Secret Detection ─────────────────────────────────────────────────────
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.3
    hooks:
      - id: gitleaks

  # ─── Python ───────────────────────────────────────────────────────────────
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: ["--fix", "--exit-non-zero-on-fix"]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        files: "^services/"        # Only check the services/ directory
        additional_dependencies:
          - types-requests
          - types-pyyaml

  # ─── JavaScript / TypeScript ──────────────────────────────────────────────
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        types_or: [javascript, jsx, ts, tsx, css, scss, json]
        additional_dependencies:
          - prettier@3.2.5
          - "@trivago/prettier-plugin-sort-imports@4.3.0"

  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v9.3.0
    hooks:
      - id: eslint
        files: "\\.tsx?$"
        additional_dependencies:
          - eslint@9.3.0
          - "@typescript-eslint/eslint-plugin@7.9.0"
          - "@typescript-eslint/parser@7.9.0"
          - eslint-plugin-react-hooks@4.6.2

  # ─── Shell ────────────────────────────────────────────────────────────────
  - repo: https://github.com/shellcheck-py/shellcheck-py
    rev: v0.10.0.1
    hooks:
      - id: shellcheck

  - repo: https://github.com/scop/pre-commit-shfmt
    rev: v3.8.0-1
    hooks:
      - id: shfmt
        args: ["-i", "2", "-ci"]

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

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

  # ─── Markdown ─────────────────────────────────────────────────────────────
  - repo: https://github.com/igorshubovych/markdownlint-cli
    rev: v0.41.0
    hooks:
      - id: markdownlint
        args: ["--fix"]
        additional_dependencies:
          - markdownlint-cli@0.41.0

  # ─── Commit Messages ──────────────────────────────────────────────────────
  - repo: https://github.com/compilerla/conventional-pre-commit
    rev: v3.3.0
    hooks:
      - id: conventional-pre-commit
        stages: [commit-msg]
        args: [feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert]

Writing Custom Hooks

When no existing hook does what you need, writing your own is straightforward.

Local Hooks (In the Same Repo)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      # Prevent TODO comments from being committed to main
      - id: no-todos-in-main
        name: No TODO comments
        language: python
        entry: python scripts/check-todos.py
        types: [python, typescript]
        stages: [pre-push]

      # Validate that all API routes have corresponding tests
      - id: api-test-coverage
        name: API endpoints must have tests
        language: python
        entry: python scripts/check-api-tests.py
        pass_filenames: false
        always_run: false
        files: "^services/api/"

      # Enforce migration naming convention
      - id: migration-naming
        name: Migration file naming
        language: python
        entry: python -c "
import sys, re
pattern = re.compile(r'^\d{4}_[a-z][a-z0-9_]*\.sql$')
errors = [f for f in sys.argv[1:] if not pattern.match(f.split('/')[-1])]
if errors:
    print('Migration files must match NNNN_snake_case.sql:')
    for e in errors: print(f'  {e}')
    sys.exit(1)
"
        files: "^migrations/.*\\.sql$"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# scripts/check-todos.py
#!/usr/bin/env python3
"""Prevent TODO/FIXME/HACK comments from being committed."""
import sys
import re
from pathlib import Path

PATTERNS = [
    r'\bTODO\b',
    r'\bFIXME\b',
    r'\bHACK\b',
    r'\bXXX\b',
]
# Allow TODO with a ticket reference: "TODO(ENG-1234):"
ALLOWED_PATTERN = re.compile(r'\bTODO\([A-Z]+-\d+\):')

combined = re.compile('|'.join(PATTERNS))

errors = []
for filepath in sys.argv[1:]:
    path = Path(filepath)
    try:
        lines = path.read_text().splitlines()
    except (UnicodeDecodeError, PermissionError):
        continue

    for i, line in enumerate(lines, 1):
        if combined.search(line) and not ALLOWED_PATTERN.search(line):
            errors.append(f"{filepath}:{i}: {line.strip()}")

if errors:
    print("Found unresolved TODO/FIXME comments (use TODO(TICKET-123): format):")
    for e in errors:
        print(f"  {e}")
    sys.exit(1)

Hooks That Auto-Fix

Some hooks modify files rather than just reporting errors. When a hook modifies a staged file, pre-commit exits non-zero so you re-stage the changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# scripts/add-license-header.py
#!/usr/bin/env python3
"""Add license header to Python files that are missing it."""
import sys
from pathlib import Path

HEADER = '''# Copyright (c) 2026 MyCompany, Inc.
# SPDX-License-Identifier: Apache-2.0
'''

modified = []
for filepath in sys.argv[1:]:
    path = Path(filepath)
    content = path.read_text()
    if not content.startswith('# Copyright'):
        path.write_text(HEADER + content)
        modified.append(filepath)

if modified:
    print(f"Added license header to {len(modified)} file(s):")
    for f in modified:
        print(f"  {f}")
    sys.exit(1)  # Exit non-zero so user re-stages the changes
1
2
3
4
5
6
7
8
# In .pre-commit-config.yaml
- repo: local
  hooks:
    - id: add-license-header
      name: Add license header
      language: python
      entry: python scripts/add-license-header.py
      types: [python]

Sharing Hooks Across Repos

Package your organization’s custom hooks in a dedicated repo:

my-org/pre-commit-hooks/
├── .pre-commit-hooks.yaml     # Hook definitions
├── hooks/
│   ├── check_api_versioning.py
│   ├── validate_openapi.py
│   ├── check_secrets_rotation.py
│   └── enforce_team_conventions.py
└── setup.py (or pyproject.toml)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# my-org/pre-commit-hooks/.pre-commit-hooks.yaml
- id: check-api-versioning
  name: Validate API version headers
  description: Ensures all API handlers include version metadata
  language: python
  entry: hooks/check_api_versioning.py
  types: [python]
  files: "^services/.*/handlers/"

- id: validate-openapi
  name: Validate OpenAPI specification
  description: Validates openapi.yaml against the 3.1 spec
  language: python
  entry: hooks/validate_openapi.py
  files: "openapi\\.ya?ml$"
  pass_filenames: false
1
2
3
4
5
6
# In your application repos' .pre-commit-config.yaml
- repo: https://github.com/my-org/pre-commit-hooks
  rev: v1.3.0   # Pin to a specific tag
  hooks:
    - id: check-api-versioning
    - id: validate-openapi

Stages: When Hooks Run

Hooks run at pre-commit stage by default, but can be configured to run at any git hook stage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
hooks:
  # Fast checks → run on every commit
  - id: ruff
    stages: [pre-commit]

  # Slow checks → run only before push (don't slow down every commit)
  - id: mypy
    stages: [pre-push]

  # Commit message format → run on commit-msg hook
  - id: conventional-pre-commit
    stages: [commit-msg]

  # Run after merge to validate the merged state
  - id: check-dependencies-installed
    stages: [post-merge, post-checkout]
    pass_filenames: false
1
2
3
4
5
6
7
8
# Install all hook types
pre-commit install --hook-type pre-commit
pre-commit install --hook-type commit-msg
pre-commit install --hook-type pre-push
pre-commit install --hook-type post-merge

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

Running Pre-commit Outside of Commits

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Run all hooks on all files (not just staged)
pre-commit run --all-files

# Run a specific hook on all files
pre-commit run ruff --all-files

# Run on specific files
pre-commit run --files src/api/handlers.py src/api/models.py

# Run hooks for a specific stage
pre-commit run --hook-stage pre-push --all-files

# Update all hooks to latest revisions
pre-commit autoupdate

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

# Clear the pre-commit cache (useful when a hook behaves unexpectedly)
pre-commit clean

# Show installed hooks and their versions
pre-commit --version

CI Integration: Making Hooks Non-Bypassable

Pre-commit can be skipped locally with git commit --no-verify. CI is where the hooks become mandatory. Run pre-commit in CI exactly like it runs locally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# .github/workflows/lint.yml
name: Code Quality

on:
  push:
    branches: [main]
  pull_request:

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

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

      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      # Cache pre-commit environments (huge speedup on repeated runs)
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
          restore-keys: pre-commit-${{ runner.os }}-

      # The official pre-commit GitHub Action (handles install + run)
      - uses: pre-commit/action@v3.0.1
        env:
          # Some hooks need additional env
          SKIP: "no-commit-to-branch"  # This check doesn't make sense in CI

Pre-commit CI Service

pre-commit.ci is a free service that runs your hooks automatically on every PR and auto-commits fixes back to the branch:

1
2
3
4
5
6
7
8
9
# .pre-commit-ci-config.yaml (optional — configures pre-commit.ci behavior)
ci:
  autofix_commit_msg: "style: pre-commit auto-fixes"
  autofix_prs: true
  autoupdate_branch: ""
  autoupdate_commit_msg: "chore: pre-commit autoupdate"
  autoupdate_schedule: weekly
  skip: []  # Hook IDs to skip in CI (e.g. slow tests)
  submodules: false

Simply add the pre-commit.ci GitHub App to your repository. It runs hooks on every PR, posts results as a check, and opens PRs to update hook versions weekly.


Performance Tuning

For large repos, pre-commit can become slow. Here’s how to keep it fast:

Exclude Patterns

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Global excludes — never run hooks on these
exclude: |
  (?x)^(
    \.git/|
    node_modules/|
    \.venv/|
    dist/|
    build/|
    .*\.min\.js|
    .*\.min\.css|
    .*_pb2\.py$|        # Generated protobuf files
    .*_pb2_grpc\.py$|
    migrations/versions/ # Auto-generated Alembic migrations
  )$

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        exclude: "^tests/fixtures/"  # Hook-level exclude

Skip Expensive Hooks by Default

Move slow hooks (mypy, full test suite, cargo build) to the pre-push stage so they don’t run on every commit, only before push.

The SKIP Environment Variable

1
2
3
4
5
6
# Skip specific hooks for one commit (useful when you know the issue and will fix it separately)
SKIP=mypy,eslint git commit -m "WIP: rough draft"

# Skip all hooks (emergency use only — this is what --no-verify does)
SKIP=* git commit -m "emergency fix"
# Better: just use --no-verify and document why

Parallel Hook Execution

Pre-commit runs hooks sequentially by default. Some hooks support running in parallel natively (ruff processes multiple files in parallel internally). For additional parallelism at the hook level, use --jobs:

1
2
# Not yet a native pre-commit feature, but you can use parallel in shell hooks
# or split slow hooks across multiple hook definitions

Hook Adoption Strategy

Retrofitting pre-commit onto a codebase with existing violations requires a careful approach — enabling a strict linter on 50,000 lines of legacy code will produce thousands of errors on the first run.

The Baseline Approach

1
2
3
4
5
# Step 1: Run the hook on everything and capture the baseline failures
pre-commit run ruff --all-files 2>&1 | grep "^src/" | sort > .ruff-baseline.txt

# Step 2: Configure the hook to only fail on NEW violations
# For many linters, you can use --diff mode or per-directory ignore lists
1
2
3
4
5
# For ruff: use per-file ignores to suppress legacy violations temporarily
# pyproject.toml
[tool.ruff.lint.per-file-ignores]
"legacy/**" = ["ALL"]   # Gradually reduce this list
"src/old_module.py" = ["E501", "F401"]  # Specific violations in specific files

Gradual Rollout

Week 1: Enable only the lowest-friction hooks
  - trailing-whitespace, end-of-file-fixer, check-merge-conflict
  - detect-private-key (critical — do this immediately)
  - No linters yet

Week 2: Add formatters (not linters)
  - ruff-format / prettier / gofmt
  - Run once on whole codebase, commit the mass-format commit
  - All future commits are automatically formatted

Week 3: Enable linters with auto-fix
  - ruff --fix, eslint --fix
  - Issues that can't be auto-fixed get suppressed with inline comments initially

Week 4+: Enable strict type checking and security scanning
  - mypy, bandit, gitleaks
  - Address violations incrementally, don't try to fix everything at once

Getting Team Buy-In

The fastest path to rejection: a pre-commit config that blocks commits for 30 seconds. The fastest path to adoption: hooks that make developers’ lives easier.

  • Formatters are always welcome — they remove pointless review comments about style
  • Fast hooks first — ruff takes <1s; mypy on a large codebase can take 30s; put mypy at pre-push
  • Auto-fix what you can — hooks that fix the problem instead of just reporting it feel like help, not hindrance
  • Make bypass easy but visiblegit commit --no-verify should be documented and used when genuinely needed; track bypass frequency in CI

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Setup
pip install pre-commit
pre-commit install                           # Install git hooks
pre-commit install --hook-type commit-msg    # Also install commit-msg hook

# Running
pre-commit run                               # Run on staged files (what git commit runs)
pre-commit run --all-files                   # Run on entire repo
pre-commit run ruff --all-files              # Run one hook on entire repo
pre-commit run --files path/to/file.py       # Run on specific files

# Maintenance
pre-commit autoupdate                        # Update all hooks to latest versions
pre-commit clean                             # Clear environment cache
pre-commit gc                                # Remove unused cached repos

# Bypassing (use sparingly)
SKIP=mypy git commit -m "msg"               # Skip specific hooks
git commit --no-verify -m "msg"             # Skip all hooks

# Debugging
pre-commit run --verbose ruff               # Show verbose output
pre-commit try-repo https://github.com/... hook-id  # Test a hook without adding to config

Filed under: Developer Tooling & Workflow

Comments