A command-line tool is only as trustworthy as the code behind it. When users pip install your CLI and run it in their own pipelines, a regression you never caught locally becomes a broken build on someone else's machine. Pre-commit hooks close that gap: they run your linter, formatter, type checker, and tests automatically before any commit lands, so the messy work-in-progress never reaches your history. This hub explains what pre-commit is, why CLI projects benefit from it in particular, and how the individual quality gates fit together.
What pre-commit actually is
pre-commit is a framework — a small Python application — for managing Git hooks. You declare the checks you want in a .pre-commit-config.yaml file at the repo root, and pre-commit installs a Git pre-commit hook that runs them against your staged files every time you git commit. Crucially, it isolates each tool in its own managed environment, so contributors don't need to install ruff, mypy, or black globally. Clone the repo, run one install command, and everyone runs the exact same versions of the exact same checks.
This matters more for CLI projects than for, say, a one-off script. A CLI is software other people execute. It has an entry point, argument parsing, exit codes, and usually a published package on PyPI. Each of those is a surface where a small mistake — an unhandled None, a misformatted help string, a type error in a Typer callback — ships straight to users. Catching it at commit time is the cheapest possible place to catch it.
The gate philosophy
Think of pre-commit as a stack of fast, ordered gates. Each gate has one job, and code only proceeds if it passes all of them. For a Python CLI the four gates that earn their keep are:
- Ruff (lint) — catches unused imports, undefined names, mutable default arguments, and hundreds of other bugs in milliseconds. It replaces flake8, isort, pyupgrade, and several plugins in a single fast binary.
- Ruff (format) — applies a deterministic, black-compatible code style so diffs stay about logic, not whitespace. Running format and lint from the same tool keeps their rules from fighting each other.
- mypy — verifies your type hints actually hold. For CLIs this is where you catch the
str | Noneyou forgot to guard before passing it toPath(). - pytest — runs your test suite as a final gate. A CLI's behavior (exit codes, stdout, error messages) is best pinned with tests, so a green suite before commit is a strong signal.
The ordering is deliberate: cheap, auto-fixing checks first (format, lint), then static analysis (mypy), then the slower behavioral check (pytest) last. If formatting alone fails, you don't waste time running the whole test suite.
Local hooks vs CI hooks
There are two places these gates run, and you want both. Local hooks fire on your machine at commit time. They're fast and give instant feedback, but they're easy to bypass — anyone can git commit --no-verify. CI hooks run the same .pre-commit-config.yaml in your continuous-integration pipeline, where bypassing isn't possible. The single source of truth is the config file, so the local and CI runs check identical things:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install pre-commit
- run: pre-commit run --all-files --show-diff-on-failure
Local hooks make the right thing convenient; CI hooks make it mandatory. Use local hooks for fast feedback and CI as the enforcement backstop.
A note on versions and reproducibility
Pre-commit pins each hook to a specific revision (a Git tag or commit), which is what makes runs reproducible across machines and across time. You upgrade those pins deliberately with pre-commit autoupdate rather than drifting silently. This pairs naturally with how you manage the rest of your toolchain — see uv for Python CLI dependency management for keeping the dev dependencies that back these hooks locked and reproducible too.
Where to go next
The step-by-step companion to this page walks through every command and the complete config, from a clean clone to a green CI run:
- Setting up pre-commit for Python CLI repos — install pre-commit, write a full
.pre-commit-config.yamlwith ruff, mypy, and a local pytest hook, wire up the matchingpyproject.tomlconfig, and run it in CI.
Related
- Project Setup & Dependency Management — the pillar this hub belongs to.
- Setting up pre-commit for Python CLI repos — the hands-on walkthrough.
- Managing CLI versioning & changelogs — the other half of a disciplined release workflow.