Some checks only make sense for your repository. The generated CLI reference in docs/ must match the current --help output. Every command module must be registered in the command table. Nobody should commit a breakpoint(). The version in the changelog heading must be valid. No public hook exists for these, and writing a whole published package for a twenty-line check is overkill. pre-commit's local hooks fill the gap: hooks defined directly in your .pre-commit-config.yaml under repo: local, running a regular expression, a script in the repository, or a small Python program. This guide shows the four ways to write a local hook, when to use each, the contract a hook must follow, and how to test hooks so they do not become the flaky step everyone skips with --no-verify. It belongs to the pre-commit hooks for CLI projects topic.
Prerequisites
- A repository already using pre-commit, as set up in setting up pre-commit for Python CLI repos.
- Python 3.10+ and uv; the examples run the project's own CLI through
uv run.
Four ways to write a local hook
pygrep runs a Python regular expression over the matching files and fails on any match. No code and no environment — ideal for forbidding a pattern.
python builds an isolated virtual environment and runs an entry point in it, with any additional_dependencies you list. Reproducible and independent of the developer's setup, but it cannot import your project unless you install it into that environment.
system runs whatever command is on the developer's PATH. It is the only way to run something inside your project's environment — for example your own CLI through uv run — at the cost of depending on the developer having that environment.
script runs a script file from the repository, with no environment management. Handy for small shell or Python glue with no dependencies.
The recipe
Here is a .pre-commit-config.yaml with one local hook of each useful kind, all solving real problems in a CLI repository:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: no-breakpoints
name: no breakpoint() or pdb in source
language: pygrep
entry: '\bbreakpoint\(\)|\bimport pdb\b|\bpdb\.set_trace\('
types: [python]
exclude: ^tests/fixtures/
- id: changelog-heading
name: changelog headings are valid versions
language: python
entry: python scripts/hooks/check_changelog.py
files: ^CHANGELOG\.md$
additional_dependencies: ["packaging>=24"]
- id: cli-reference-up-to-date
name: CLI reference is up to date
language: system
entry: uv run --frozen python scripts/hooks/check_cli_docs.py
files: ^(src/mytool/.*\.py|docs/reference\.md)$
pass_filenames: false
A hook that checks files it is given
The changelog hook is a normal Python script that receives filenames, checks each, prints problems as file:line: message, and returns non-zero if anything is wrong:
# scripts/hooks/check_changelog.py
"""Every '## [x.y.z]' heading in the changelog must be a valid PEP 440 version."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from packaging.version import InvalidVersion, Version
HEADING = re.compile(r"^## \[(?P<v>[^\]]+)\]")
def check(path: Path) -> list[str]:
problems = []
seen: list[Version] = []
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
m = HEADING.match(line)
if not m or m["v"].lower() == "unreleased":
continue
try:
v = Version(m["v"])
except InvalidVersion:
problems.append(f"{path}:{lineno}: '{m['v']}' is not a valid version")
continue
if seen and v >= seen[-1]:
problems.append(f"{path}:{lineno}: {v} is not older than the entry above ({seen[-1]})")
seen.append(v)
return problems
def main(argv: list[str]) -> int:
problems = [p for name in argv for p in check(Path(name))]
for problem in problems:
print(problem)
return 1 if problems else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
A hook that keeps generated docs in sync
The documentation hook uses pass_filenames: false because it does not check individual files: it regenerates the CLI reference from the real command tree and compares it with the committed file. Running through uv run --frozen means it uses the project's own environment and locked dependencies:
# scripts/hooks/check_cli_docs.py
"""Fail if docs/reference.md does not match what the CLI would generate now."""
from __future__ import annotations
import difflib
import sys
from pathlib import Path
from mytool.docs import render_reference # renders help for every command as Markdown
DOC = Path("docs/reference.md")
def main() -> int:
expected = render_reference()
actual = DOC.read_text(encoding="utf-8") if DOC.exists() else ""
if expected == actual:
return 0
diff = difflib.unified_diff(actual.splitlines(), expected.splitlines(),
"docs/reference.md (committed)", "generated", lineterm="", n=1)
print("\n".join(list(diff)[:40]))
print('\ndocs/reference.md is stale: run "uv run mytool docs > docs/reference.md"')
return 1
if __name__ == "__main__":
raise SystemExit(main())
Generating reference documentation from the command tree is covered in generating man pages and docs from a CLI. The hook is what keeps it honest: changing a help string without regenerating the docs now fails on commit, with the exact command to fix it.
The hook contract
Every hook, whatever its language, follows the same contract with pre-commit:
- Input is the list of staged files matching the hook's filters, as command-line arguments — unless
pass_filenames: false. The list may be split across several invocations if it is long. - Exit code 0 means pass; anything else means fail.
- Modifying files also means fail, even with exit code 0. pre-commit detects the change and stops the commit so the developer can review and stage the modification. Hooks that fix things should still exit non-zero to be explicit.
- Output is shown only when the hook fails (or with
verbose: true). Keep it short and actionable. - The working directory is the repository root.
UX considerations
- Fast or not at all. Local hooks run on every commit. Anything over a second or two will be skipped with
--no-verifywithin a week. Filter withfiles:so slow hooks only run when relevant files change, and move genuinely slow checks to CI. - Tell people the fix. The last line of a failure should be the exact command that fixes it, as the docs hook does.
- Prefer
pythonoversystemwhen you can.systemhooks fail confusingly for a contributor who has not runuv sync. When you need the project environment,uv run --frozenmakes the failure mode obvious and cheap to fix. - Name hooks for the problem. "CLI reference is up to date" in pre-commit's output tells a contributor what failed without opening the config.
Testing the behaviour
Hook scripts are ordinary Python and deserve ordinary unit tests — call main() with filenames and assert on the return value and output:
# tests/test_hooks.py
from scripts.hooks.check_changelog import main
def test_valid_changelog(tmp_path, capsys):
f = tmp_path / "CHANGELOG.md"
f.write_text("# Changelog\n\n## [Unreleased]\n\n## [2.4.0]\n\n## [2.3.1]\n")
assert main([str(f)]) == 0
assert capsys.readouterr().out == ""
def test_invalid_and_misordered_versions(tmp_path, capsys):
f = tmp_path / "CHANGELOG.md"
f.write_text("## [2.3.1]\n\n## [2.4.0]\n\n## [two]\n")
assert main([str(f)]) == 1
out = capsys.readouterr().out
assert ":3: 2.4.0 is not older" in out
assert ":5: 'two' is not a valid version" in out
For the hook definitions themselves, pre-commit run <hook-id> --all-files runs one hook against the whole repository — the quickest way to see its real behaviour — and pre-commit try-repo . <hook-id> exercises hook definitions from the working tree before they are committed. Add pre-commit run --all-files to CI as well, so a hook that fails for everyone is caught even if a contributor skipped it locally.
Conclusion
Local hooks let a repository enforce its own rules at the cheapest possible moment — before the commit exists. Use pygrep for forbidden patterns, python with additional_dependencies for self-contained checks, and system with uv run --frozen when the check needs your project's own code. Follow the contract (filenames in, exit code out, modifications count as failure), keep hooks fast and their messages actionable, and unit-test the scripts behind them like any other code.
Frequently asked questions
Where should hook scripts live?
In a scripts/hooks/ directory that is not part of the installed package, so they never ship to users. Give the directory an __init__.py if you want to import the scripts in tests, and exclude it from the wheel in your build configuration.
Can a local hook use my CLI's own commands?
Yes, through language: system and uv run --frozen mytool .... That is often the best design: add a mytool check-docs or mytool lint-config command, and have the hook call it, so developers can run the same check by hand.
How do I skip a slow hook locally but keep it in CI?
Set the SKIP environment variable (SKIP=cli-reference-up-to-date git commit ...) for occasional skips, or give the hook stages: [manual] and run pre-commit run --hook-stage manual in CI. The second keeps commits fast by default while CI still enforces it.
How do I stop a local hook from diverging from CI?
Run the same thing in both places. The simplest arrangement is a CI step that runs pre-commit run --all-files, so every hook in the configuration — local ones included — gates merges exactly as it gates commits. For hooks that need the project environment, make sure the CI job runs uv sync first, as a developer would.
Should hooks auto-fix or only check?
Auto-fix when the fix is mechanical and unambiguous (regenerating docs, sorting a list); only check when a human must decide. Either way, the hook fails the commit so the change is reviewed.