Skip to main content

Pre-Commit Hooks - Automate Quality Gates Before Every Commit

Reading time: ~28 minutes | Level: Intermediate → Engineering

Before reading further, consider this scenario:

You push code to the remote. CI starts. You wait 5 minutes for the pipeline to spin up, clone the repo, install dependencies, and begin running. CI fails. The reason: a trailing whitespace on line 47 of a config file.

You fix it. Commit. Push. Wait 5 minutes again.

CI fails again. This time: a missing blank line at the end of a file that a linter requires.

Fix. Commit. Push. Wait.

Total time wasted: 15 minutes on two violations that could each have been caught in under 0.1 seconds - before the first commit was even created.

This is the problem pre-commit hooks solve. The fix is not to make CI faster (though that helps). The fix is to move the check to where it is cheapest: your local machine, in the 50 milliseconds between git commit and the commit object being created.

What You Will Learn

  • What Git hooks are and how they work at the filesystem level
  • The pre-commit framework: installation, configuration, the full hook lifecycle
  • Building a production .pre-commit-config.yaml with real hooks
  • Running hooks: staged files vs all files
  • Bypassing hooks: when it is acceptable and when it is a red flag
  • CI integration: running the same hooks in CI as locally
  • Making hooks fast: stage assignment, pass_filenames, and type filtering
  • Team adoption strategy: how to introduce hooks to a resistant team

Prerequisites

  • Lesson 06 (Linting and Formatting) - hooks run Ruff, Black, and mypy; you need to understand what those tools do
  • Basic Git familiarity - commits, staging, branches
  • Lesson 02 (pytest) - hooks optionally run your test suite

Part 1 - Git Hooks: The Mechanism

Git hooks are shell scripts that Git executes automatically at specific points in the Git workflow. They live in .git/hooks/ in your repository.

.git/
hooks/
pre-commit ← runs before a commit is created
commit-msg ← runs after the commit message is entered
pre-push ← runs before git push sends data to remote
post-commit ← runs after a commit is created (read-only)
post-merge ← runs after a merge completes
pre-rebase ← runs before a rebase begins
...

Each file in .git/hooks/ is a shell script (or any executable). Git passes information via exit codes: if a hook exits with a non-zero code, Git aborts the operation.

# A minimal pre-commit hook (manually created)
cat .git/hooks/pre-commit
#!/bin/sh
echo "Running pre-commit checks..."
python -m pytest tests/ --quiet
# If pytest exits non-zero, git commit is aborted

The problem with raw Git hooks:

  1. They are not version-controlled - .git/hooks/ is not tracked by Git
  2. Every developer must manually set them up
  3. Managing multiple hooks, dependencies, and versions is a maintenance burden
  4. No standard way to share hook configurations across a team

The pre-commit framework solves all of these problems.

Part 2 - The pre-commit Framework

pre-commit is a Python tool that manages Git hooks declaratively. You define your hooks in a .pre-commit-config.yaml file that lives in your repository root (version-controlled). The framework handles installing dependencies, running hooks in the right order, and showing clear failure messages.

Installation

# Install pre-commit globally or in your project virtualenv
pip install pre-commit

# Install the Git hooks into .git/hooks/
# This creates .git/hooks/pre-commit (and others) that delegate to pre-commit
pre-commit install

# Also install commit-msg hook (for commit message linting, if configured)
pre-commit install --hook-type commit-msg

# Also install pre-push hook (for slow checks)
pre-commit install --hook-type pre-push

After pre-commit install, the .git/hooks/pre-commit file is replaced with a script that delegates to the pre-commit framework, which reads .pre-commit-config.yaml.

.pre-commit-config.yaml Structure

# Minimum pre-commit version required to run this config
minimum_pre_commit_version: "3.0.0"

# List of hook repositories
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 # pin to a specific version tag
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer

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

Key concepts:

  • repo: URL of the hook repository on GitHub (or local for local hooks)
  • rev: Git tag or SHA to use - always pin to a specific version, never use main or HEAD
  • hooks: list of hook IDs to enable from that repository
  • args: additional CLI arguments passed to the hook
  • stages: which Git stages trigger this hook (commit, push, manual)
  • types: filter to only run on files of this type (e.g., [python], [yaml])

Part 3 - Running Hooks

# Run all hooks against staged files (what git commit triggers)
pre-commit run

# Run all hooks against ALL files in the repository
pre-commit run --all-files

# Run a specific hook by ID
pre-commit run ruff --all-files
pre-commit run mypy --all-files

# Update all hooks to their latest versions
pre-commit autoupdate

# Clean hook environments (useful when hooks are broken)
pre-commit clean

Staged files vs all files:

By default, pre-commit passes only staged files to each hook. This is intentional - it is fast (only changed files are checked) and it avoids blocking commits due to pre-existing violations in untouched files.

The first time you add pre-commit to an existing project, run pre-commit run --all-files to find all existing violations and fix them before enforcing the hooks on new commits.

Part 4 - A Production .pre-commit-config.yaml

This is a complete production configuration covering the full quality pipeline:

minimum_pre_commit_version: "3.5.0"

repos:
# ── Basic file hygiene ────────────────────────────────────────────────────────
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
# Remove trailing whitespace on every line
- id: end-of-file-fixer
# Ensure every file ends with exactly one newline
- id: check-yaml
# Validate YAML syntax (catches broken CI configs early)
args: [--unsafe] # needed for !reference tags in GitLab CI
- id: check-json
# Validate JSON syntax
- id: check-toml
# Validate TOML syntax (catches broken pyproject.toml early)
- id: check-merge-conflict
# Catch leftover <<<<<<< markers from merge conflicts
- id: check-added-large-files
# Block files over 500 KB from being committed
args: [--maxkb=500]
- id: detect-private-key
# Block files that look like private keys (-----BEGIN RSA PRIVATE KEY-----)
- id: no-commit-to-branch
# Prevent direct commits to main and master
args: [--branch, main, --branch, master]

# ── Secret detection ──────────────────────────────────────────────────────────
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
# Scan for API keys, tokens, passwords in staged files
args: [--baseline, .secrets.baseline]
# Create baseline first: detect-secrets scan > .secrets.baseline

# ── Python: Ruff linting and formatting ───────────────────────────────────────
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.5
hooks:
- id: ruff
# Lint and auto-fix staged Python files
args: [--fix, --exit-non-zero-on-fix]
types: [python]
- id: ruff-format
# Format staged Python files (Black-compatible)
types: [python]

# ── Python: mypy type checking ────────────────────────────────────────────────
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
args: [--ignore-missing-imports, --no-error-summary]
types: [python]
# Run mypy on staged Python files only
# Move to pre-push stage if it is too slow for pre-commit:
# stages: [push]
additional_dependencies:
# Add type stubs for libraries that need them
- types-requests
- types-PyYAML

# ── Python: fast test suite (commit stage) ────────────────────────────────────
- repo: local
hooks:
- id: pytest-fast
name: pytest (fast tests only)
language: system
# Run only tests marked as NOT slow
entry: pytest tests/ -m "not slow" --tb=short -q
pass_filenames: false
# Only trigger when Python files change
types: [python]
stages: [commit]

# ── Python: full test suite (push stage) ─────────────────────────────────────
- repo: local
hooks:
- id: pytest-full
name: pytest (full suite)
language: system
entry: pytest tests/ --tb=short -q
pass_filenames: false
stages: [push]

What Each Hook Does

HookStageSpeedWhat it catches
trailing-whitespacecommitinstanttrailing spaces on any line in any file
end-of-file-fixercommitinstantfiles missing final newline
check-yamlcommitinstantbroken YAML syntax in CI configs and Helm charts
check-jsoncommitinstantbroken JSON in package.json, config files
check-tomlcommitinstantbroken pyproject.toml before it causes mysterious pip failures
check-merge-conflictcommitinstant<<<<<<< markers left from a botched merge
check-added-large-filescommitinstantaccidentally committed binaries, datasets
detect-private-keycommitinstantRSA/EC private keys pattern-matched in any file
no-commit-to-branchcommitinstantaccidental commits to protected branches
detect-secretscommitfastAPI keys, tokens, passwords via entropy analysis
ruffcommitvery fastlint violations + auto-fix
ruff-formatcommitvery fastformatting violations + auto-fix
mypycommitmoderatetype errors in changed files
pytest-fastcommitfasttests not marked @pytest.mark.slow
pytest-fullpushslowentire test suite before pushing to remote

Part 5 - Bypassing Hooks

Bypass a Specific Hook: SKIP

# Skip a single hook by its ID
SKIP=mypy git commit -m "WIP: still working on types"

# Skip multiple hooks
SKIP=mypy,pytest-fast git commit -m "WIP: broken but saving progress"

SKIP is appropriate when:

  • You are committing a work-in-progress to a personal branch that you will not merge until it is complete
  • A hook is broken due to an environmental issue (network unavailable for type stub download)
  • You are reverting a previous commit and the revert itself should not be blocked

Bypass All Hooks: --no-verify

# Bypass ALL pre-commit hooks
git commit --no-verify -m "emergency: hotfix prod outage"
warning

--no-verify in your team's normal workflow is a red flag. It means either the hooks are too slow, too noisy, or too strict - and developers are bypassing them to avoid friction. The correct response is to fix the hooks: move slow checks to pre-push, tune rules, or fix false positives. A team that regularly uses --no-verify effectively has no quality gates.

The only legitimate use of --no-verify is a genuine emergency: production is down and you need to hotfix immediately. Even then, the CI pipeline catches violations before the code reaches production. Document --no-verify usage in your Git history: git commit --no-verify -m "hotfix: fix null pointer [skip-hooks: prod-emergency]".

Part 6 - CI Integration

Pre-commit hooks are local - they run on developer machines. A developer who clones the repo but does not run pre-commit install gets no hooks. The solution: also run pre-commit run --all-files in CI.

# .gitlab-ci.yml (or .github/workflows/lint.yml)

lint:
stage: lint
image: python:3.11-slim
script:
- pip install pre-commit
- pre-commit run --all-files
cache:
paths:
- ~/.cache/pre-commit/ # cache hook environments between CI runs

This means:

  • Local hooks catch violations before commit (fast feedback)
  • CI hooks catch anything that slipped through (e.g., someone used --no-verify, or cloned without installing hooks)
  • The exact same hook configuration runs in both places - no drift between local and CI
note

pre-commit stores hook environments (virtual envs for each hook's dependencies) in ~/.cache/pre-commit/. The first time a hook runs, pre-commit downloads the hook repository and installs its dependencies - this takes 5–30 seconds per hook. Subsequent runs are instant because the environments are cached. In CI, cache ~/.cache/pre-commit/ to avoid re-creating environments on every pipeline run.

Part 7 - Making Hooks Fast

Slow hooks cause --no-verify abuse. Every hook should complete in under 3 seconds for the pre-commit stage. Strategies:

Assign Slow Hooks to pre-push

hooks:
- id: mypy
stages: [push] # only runs on git push, not git commit

- id: pytest-full
stages: [push] # full test suite on push, fast tests on commit

This means developers get fast feedback (< 1 second) on commit and thorough feedback (10–60 seconds) on push. The pre-push check catches what fast checks missed before code leaves the machine.

Use pass_filenames: true and types

By default, pre-commit passes staged filenames to the hook command. If a hook does not need filenames (e.g., pytest runs the whole test directory), set pass_filenames: false:

- id: pytest-fast
pass_filenames: false # do not pass staged filenames to pytest
entry: pytest tests/ -m "not slow"

Use types to filter which files trigger the hook:

- id: ruff
types: [python] # only run when Python files are staged
# does not trigger on YAML, JSON, Markdown changes

Use files for Pattern Filtering

- id: mypy
types: [python]
# Only run mypy when files in src/ change, not on test files
files: ^src/

Profile Hook Speed

# Time each hook
pre-commit run --all-files --show-diff-on-failure 2>&1 | grep -E "(Passed|Failed|Skipped|[0-9]+\.[0-9]+s)"

If a hook consistently takes > 5 seconds on the commit stage, move it to pre-push or manual.

Part 8 - Team Adoption Strategy

Introducing pre-commit hooks to a team requires managing human factors, not just technical ones. The most common failure mode: add all hooks at once, hooks break for some developers, developers add --no-verify to their git aliases, hooks are effectively dead within a week.

The Three-Phase Rollout

Phase 1: Formatting only (Week 1) - no debates possible

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-merge-conflict

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.5
hooks:
- id: ruff-format # formatting only - no lint rules yet

These hooks are unambiguous. No developer debates whether trailing whitespace should be allowed. They run in milliseconds. They auto-fix (ruff-format rewrites the file; developer just needs to git add again). Acceptance is near-universal.

Phase 2: Linting (Week 2–3) - after formatting is normalized

Add Ruff lint rules after the team is comfortable with formatting hooks. Start with F (logical errors, undeniable bugs) before B (opinions). Run pre-commit run --all-files to fix existing violations before enabling hooks.

Phase 3: Type checking (Week 4+) - after linting is normalized

Add mypy last. Assign it to pre-push initially (not pre-commit) to minimize friction. Only move it to pre-commit once the codebase is substantially annotated.

danger

Never commit secrets. API keys, tokens, passwords, and private keys committed to a repository are effectively leaked - even after deletion, the history remains, and GitHub/GitLab automatically scan public repositories for secrets. Use detect-secrets or gitleaks as a pre-commit hook on every repository, even private ones. Rotate any credential immediately if it is committed, regardless of whether the commit was pushed.

# Initialize a baseline of known false positives
detect-secrets scan > .secrets.baseline
git add .secrets.baseline

# The hook then only alerts on NEW secrets not in the baseline

Part 9 - Local Hook: local Repo

The local repo type lets you define hooks that run scripts from your own project without publishing them to GitHub:

repos:
- repo: local
hooks:
- id: check-migrations-up-to-date
name: Check Django migrations are up to date
language: system
entry: python manage.py migrate --check
pass_filenames: false
types: [python]
stages: [push]

- id: validate-openapi-spec
name: Validate OpenAPI specification
language: python
additional_dependencies: [openapi-spec-validator]
entry: openapi-spec-validator openapi.yaml
pass_filenames: false
files: openapi\.yaml$

- id: check-no-print-statements
name: No print() in production code
language: pygrep
entry: "^[^#]*print\\("
files: ^src/
types: [python]

The pygrep language type runs a regex against file contents \text{---} no subprocess overhead, extremely fast.

Graded Practice

Level 1 \text{---} Predict the Behavior

Given this .pre-commit-config.yaml:

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.5
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
types: [python]
- id: ruff-format
types: [python]

A developer runs git commit -m "add feature" after staging src/main.py which has 3 ruff violations (2 auto-fixable, 1 not).

  1. What happens to src/main.py on disk?
  2. What is the exit code of the ruff hook?
  3. Is the commit created?
  4. What should the developer do next?
Show Answer
  1. src/main.py is modified on disk \text{---} ruff's --fix flag applies the 2 auto-fixable violations directly to the file. The 1 non-auto-fixable violation remains in the file as a reported error.

  2. The ruff hook exits with non-zero (exit 1). The --exit-non-zero-on-fix flag means: even if all violations were auto-fixed, exit with code 1 to force the developer to re-stage the fixed file. This prevents the original (unfixed) version from being committed while the fixed version sits unstaged.

  3. The commit is not created. The hook's non-zero exit code causes git commit to abort.

  4. The developer should:

    • Review the output: ruff reports the 1 non-auto-fixable violation with the file path and line number
    • Manually fix the reported violation
    • git add src/main.py \text{---} stage the auto-fixed changes AND the manual fix
    • git commit -m "add feature" \text{---} run the commit again; this time all violations are resolved and the hook exits 0

The --exit-non-zero-on-fix behavior is intentional: it ensures that every staged commit was reviewed by the developer after auto-fix, not silently modified behind their back.

Level 2 \text{---} Debug This Configuration

A developer reports: "The detect-secrets hook runs every commit and always fails with an error, even on files that have no secrets. It seems to be scanning the .secrets.baseline file itself and finding secrets in it."

The .pre-commit-config.yaml has:

- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: [--baseline, .secrets.baseline]

What is wrong and how do you fix it?

Show Answer

The problem: .secrets.baseline is a JSON file that contains descriptions of known false-positive secrets. It includes the secret patterns themselves (as hashed values and context) so that detect-secrets can recognize them as already-reviewed. When detect-secrets scans all files including .secrets.baseline, it finds patterns in the baseline file that look like secrets, and since those patterns are being introduced as new detections, it reports them as violations.

This creates an infinite loop: fixing the violation updates .secrets.baseline, which itself contains the "secret" patterns, which triggers the hook again.

The fix: Exclude .secrets.baseline from scanning:

- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: [--baseline, .secrets.baseline]
exclude: .secrets.baseline # exclude the baseline file from scanning

Additional checks:

  1. Make sure .secrets.baseline was generated with the current version of detect-secrets:

    detect-secrets scan --baseline .secrets.baseline
  2. If the baseline is out of date (e.g., secrets were removed from the codebase), re-audit it:

    detect-secrets audit .secrets.baseline
  3. Verify .secrets.baseline is committed and up to date in the repo so all developers use the same false-positive list.

Level 3 \text{---} Design Challenge

You are leading the backend team at a startup. The team has 6 engineers, a Python monorepo with 3 microservices in subdirectories (services/api/, services/worker/, services/scheduler/), and a shared library in libs/. The CI pipeline currently takes 12 minutes end-to-end.

Design a complete pre-commit strategy that:

  1. Runs the right hooks for each subdirectory (e.g., mypy config is different per service)
  2. Keeps the pre-commit stage under 2 seconds and the pre-push stage under 30 seconds
  3. Prevents secrets from being committed
  4. Is resilient \text{---} works even when one engineer has a different Python version or missing dependencies
  5. Integrates with CI so violations caught locally are also caught remotely

Include the full .pre-commit-config.yaml, the CI integration snippet, and the team rollout plan.

Show Answer

Full .pre-commit-config.yaml:

minimum_pre_commit_version: "3.5.0"

default_stages: [commit]

repos:
# ── File hygiene (instant, all files) ────────────────────────────────────────
- 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: [--unsafe]
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files
args: [--maxkb=1024]
- id: detect-private-key
- id: no-commit-to-branch
args: [--branch, main, --branch, master, --branch, release]

# ── Secret detection (fast, all files) ───────────────────────────────────────
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: [--baseline, .secrets.baseline]
exclude: .secrets.baseline

# ── Python: Ruff lint + format (very fast, Python files only) ─────────────────
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.5
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
types: [python]
- id: ruff-format
types: [python]

# ── Python: mypy per service (commit stage, targeted) ────────────────────────
# Run mypy per service with its own config to respect per-service mypy settings.
# Each hook is scoped to its directory so only relevant files trigger it.
- repo: local
hooks:
- id: mypy-api
name: mypy (services/api)
language: system
entry: mypy services/api --config-file services/api/pyproject.toml
pass_filenames: false
files: ^services/api/
types: [python]

- id: mypy-worker
name: mypy (services/worker)
language: system
entry: mypy services/worker --config-file services/worker/pyproject.toml
pass_filenames: false
files: ^services/worker/
types: [python]

- id: mypy-scheduler
name: mypy (services/scheduler)
language: system
entry: mypy services/scheduler --config-file services/scheduler/pyproject.toml
pass_filenames: false
files: ^services/scheduler/
types: [python]

- id: mypy-libs
name: mypy (libs)
language: system
entry: mypy libs --config-file libs/pyproject.toml
pass_filenames: false
files: ^libs/
types: [python]

# ── Python: fast tests per service (commit stage) ─────────────────────────────
- repo: local
hooks:
- id: pytest-api-fast
name: pytest fast (services/api)
language: system
entry: pytest services/api/tests/ -m "not slow" --tb=short -q
pass_filenames: false
files: ^services/api/
types: [python]
stages: [commit]

- id: pytest-worker-fast
name: pytest fast (services/worker)
language: system
entry: pytest services/worker/tests/ -m "not slow" --tb=short -q
pass_filenames: false
files: ^services/worker/
types: [python]
stages: [commit]

# ── Full test suite (push stage only) ────────────────────────────────────────
- repo: local
hooks:
- id: pytest-all-full
name: pytest full suite (all services)
language: system
entry: pytest services/ libs/ --tb=short -q
pass_filenames: false
stages: [push]

CI Integration (.gitlab-ci.yml snippet):

pre-commit:
stage: lint
image: python:3.11-slim
variables:
PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.pre-commit-cache
before_script:
- pip install pre-commit --quiet
script:
- pre-commit run --all-files --show-diff-on-failure
cache:
key: pre-commit-${CI_COMMIT_REF_SLUG}
paths:
- .pre-commit-cache/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"

Why PRE_COMMIT_HOME instead of ~/.cache/pre-commit: In CI, the home directory is often not writable or not cached between runs. Setting PRE_COMMIT_HOME to a project-relative path makes the cache key reliable and cacheable.

Resilience strategy:

The language: system hooks require that mypy and pytest are installed in the developer's environment. This can break if a developer's virtualenv does not have them. Two approaches:

Option A - Document it and rely on pyproject.toml dev dependencies:

[project.optional-dependencies]
dev = ["mypy", "pytest", "ruff", "pre-commit"]

pip install -e ".[dev]" gives everyone the right tools.

Option B - Use language: python with additional_dependencies for hooks that need it (pre-commit creates isolated envs):

- id: mypy-api
language: python
additional_dependencies: [mypy, types-requests]
# pre-commit creates its own venv with these deps

This is more isolated but slower on first run.

Team rollout plan:

Week 1: File hygiene + detect-secrets + ruff-format only. Run pre-commit run --all-files on main, commit the fixes, then enable hooks. Meeting with team: explain the purpose, demo the 0.1-second fix feedback loop vs 5-minute CI cycle.

Week 2: Add ruff lint. Run --all-files first. Expected: most violations are auto-fixed. Remaining: discuss in team whether any rules need to go into ignore.

Week 3: Add mypy per-service hooks on pre-push stage. Not pre-commit yet. Goal: zero mypy errors on main.

Week 4: Move mypy from pre-push to pre-commit once the team is comfortable and mypy runs fast enough (with caching, per-service mypy typically runs in 2–5 seconds on changed files).

Week 5+: Add fast pytest. The key metric: time from git commit to "hooks passed" stays under 3 seconds.

Key Takeaways

  • Git hooks are scripts in .git/hooks/ that run at specific Git events. Pre-commit hooks run before a commit is created; pre-push hooks run before code is sent to remote.
  • The pre-commit framework manages hooks declaratively via .pre-commit-config.yaml, which is version-controlled and shared across the team. pre-commit install wires the framework into .git/hooks/.
  • Start with file hygiene hooks (trailing-whitespace, end-of-file-fixer, check-merge-conflict) before adding linting or type checking. These are unambiguous, instant, and have zero false positives - they build team trust in the hook system.
  • Put slow hooks on pre-push, not pre-commit: mypy and full test suites belong on the push stage. The commit stage should complete in under 3 seconds. Slow commit hooks cause --no-verify abuse.
  • SKIP=hook-id git commit bypasses a specific hook legitimately. git commit --no-verify bypasses all hooks - treat this as a rare emergency, not a workflow pattern. Normalize --no-verify usage means your quality gates are effectively disabled.
  • Run pre-commit run --all-files in CI to catch anything that slipped through locally (developers who did not run pre-commit install, or used --no-verify). Cache ~/.cache/pre-commit/ or PRE_COMMIT_HOME in CI to avoid re-installing hook environments on every pipeline run.
  • Secrets detection belongs in pre-commit hooks. Use detect-secrets with a .secrets.baseline for false positive management. Rotate any credential immediately if committed, even to a private repository.
  • Never use main or HEAD for hook rev: always pin to a specific version tag. Unpinned hooks can break when the upstream repository pushes a breaking change.
  • The team adoption sequence is: formatting (no debates) → linting safe rules → linting opinion rules → type checking. Introducing all hooks at once guarantees resistance. Introducing them incrementally builds trust.
  • pre-commit hook environments are cached in ~/.cache/pre-commit/ - the first run is slow (10–60 seconds per hook while dependencies are installed). Subsequent runs are instant. Warn new team members about this so they do not think hooks are broken.

What's Next

You have completed Module 04 - Testing and Quality. You can now:

  • Write pytest test suites with fixtures, parametrization, and mocking
  • Measure and enforce branch coverage with pytest-cov
  • Lint, format, and type-check Python code with Ruff, Black, and mypy
  • Automate all quality checks via pre-commit hooks that run before every commit

Module 05 - Packaging and Environments builds directly on this foundation:

  • pyproject.toml deep dive - you have already written [tool.ruff], [tool.mypy], and [tool.black] sections; Module 05 adds [project], [build-system], and publishing metadata
  • Virtual environments - venv, virtualenv, conda, and modern tools like uv
  • Building and publishing packages to PyPI - python -m build, twine, trusted publishers
  • Dependency management - pip-tools, poetry, and uv lock for reproducible environments
  • Monorepo packaging - how to structure the multi-service repository pattern from the Level 3 exercise above into properly installable packages

The quality tooling you set up in this module (Ruff, Black, mypy, pre-commit) carries forward unchanged - Module 05 simply adds the packaging layer on top of the same pyproject.toml.

© 2026 EngineersOfAI. All rights reserved.