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:
- Storing hook configuration in
.pre-commit-config.yaml — committed to the repo, versioned, reviewed like any other file
- Managing isolated tool environments — each hook runs in its own virtualenv, conda env, or language-specific environment; no global installation required
- Running only on changed files — by default, hooks only see the files staged for the current commit, making them fast
- Providing a registry of hundreds of ready-made hooks —
pre-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
|
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"
|
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.
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 visible —
git 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