You will learn: Install, run, and debug a local pre-commit gate, including staged-file selection, tool modifications, failed hooks, and reproducible configuration.
Practice in: A local Git checkout of the Midnight Museum project
By the end of Lesson 4, the museum project has a reliable manual sequence:
ruff check src testsruff format --check src testsmypy srcpytest-q
The sequence works only when someone remembers to run it. A Git pre-commit hook runs after git commit begins but before Git creates the commit. The pre-commit framework manages those hook programs and their configuration.
Before installing anything, answer these questions:
Which files will an ordinary hook run inspect?
What happens if a formatter changes a file during the hook?
How does a hook communicate failure to Git?
Where are shared hook definitions stored, and where is the installed Git script stored?
Why does a local hook improve feedback without replacing CI?
Start with a Git repository and a committed configuration file named .pre-commit-config.yaml:
The YAML belongs in version control. The installed .git/hooks/pre-commit script is local to this checkout. A new clone must run pre-commit install again.
The framework selects staged files, runs configured hooks, and lets Git continue only after a successful result.
flowchart LR
A[Working tree] --> B[git add]
B --> C[Staged snapshot]
C --> D[git commit]
D --> E[pre-commit hooks]
E -- Pass --> F[Commit created]
E -- Fail or modify --> G[Review and repair]
G --> B
2. Read configuration as an execution contract
Each field answers a practical question:
repos lists sources of hook definitions.
repo: local means this project supplies the command instead of downloading a hook repository.
id is the stable name used to select one hook.
name is the label shown in output.
entry is the command executed; pre-commit does not split a shell pipeline for you unless the hook intentionally invokes a shell.
language: system uses commands already available in the active environment.
types: [python] filters filenames by identified file type.
pass_filenames: false prevents pre-commit from appending selected filenames to a project-level command such as mypy src or pytest -q.
always_run: true runs the test hook even when no selected Python filename would otherwise reach it.
Isolated hooks versus system commands
A remote hook repository can declare its own language environment and revision:
Pre-commit downloads and caches the pinned hook environment. This improves isolation and makes the revision visible. It also needs network access on the first run.
language: system is convenient when the project already installs all tools in one locked development environment. It assumes that environment is active and contains compatible commands. Neither style is universally correct; choose and document the environment boundary. The course repository uses system hooks for Ruff and MyPy because its Poetry/Conda development environment owns those tools.
Put rewrite-producing hooks first
If lint fixes or formatters change source, run them before check-only tools:
Ruff lint/fix;
Ruff formatting;
MyPy;
pytest.
A later check should examine the final source shape. In CI, use check-only commands because automation should report an unformatted commit rather than silently create an uncommitted correction.
Git distinguishes the working tree from the staged snapshot. If you edit quest.py and run git add, the current content becomes staged. If you edit it again without staging, the working tree and staged version differ.
Inspect both deliberately:
git status --shortgit diffgit diff --cached
git diff shows unstaged working-tree changes.
git diff --cached shows the patch the next commit would contain.
pre-commit temporarily manages unstaged changes so hooks can check the staged snapshot without mixing it with unrelated work.
A pass means each configured hook exited successfully for the selected state. It does not prove that every repository file was checked: file filters and the ordinary staged-file selection still matter.
Run the whole repository when adding or changing hooks
The first time a hook is introduced, existing unstaged files might contain the very problem it checks. Run:
pre-commit run --all-files
Run one hook while diagnosing:
pre-commit run ruff-check --all-files--verbose
--verbose displays more command output even when useful details would otherwise be hidden.
Passed, skipped, modified, and failed mean different things
Ruff lint...............................................................PassedCheck YAML.........................................(no files to check)SkippedRuff format.............................................................Failed- hook id: ruff-format- files were modified by this hookMyPy...................................................................Failed- hook id: mypy- exit code: 1
Passed: the hook returned success and made no disallowed change.
Skipped: no selected file matched or the configured stage did not apply.
Modified/Failed: a rewriting hook changed a file. Review the diff, stage the new content, and run again.
Failed with exit code: the command found a problem or could not run. Read its diagnostic before editing.
Do not immediately rerun git commit after a formatter modification. First:
git diffgit add src/midnight_museum/quest.pypre-commit run --all-files
The second clean run proves no hook has another change to make.
4. Diagnose the earliest surprising result
A hook says “command not found”
For a system hook, activate/install the declared development environment and confirm command --version. For an isolated remote hook, inspect environment creation output and network/cache availability.
A hook skips a file you expected
Check files, exclude, types, types_or, the Git stage, and whether the file is tracked. Use pre-commit run <id> --files path/to/file.py --verbose to test selection explicitly.
MyPy receives unexpected filenames
Project commands often need pass_filenames: false. Otherwise pre-commit appends selected files to the entry. Inspect verbose output and make the command contract explicit.
pytest does not run on a documentation-only commit
If tests are part of every commit’s local gate, use always_run: true with pass_filenames: false. If the project intentionally runs tests only on Python changes, document that policy and rely on CI for the authoritative full gate.
A formatter keeps failing on every run
Inspect the diff and tool order. A lint fix after formatting may rewrite code back into a shape that needs another format pass. Put fixers before formatters, then ensure the second run is idempotent.
A hook that takes twenty minutes encourages people to avoid it. Put fast formatting, linting, and focused type checks early. Whether the full test suite belongs in pre-commit or pre-push depends on project size. The Midnight Museum suite is small enough to run locally on each commit.
Git permits bypassing verification with git commit --no-verify. This is useful for rare recovery workflows and is also why a local hook cannot be the shared authority. A contributor may not install it, may have a different environment, or may bypass it. CI repeats the committed contract on a clean shared runner.
Do not describe pre-commit as a security boundary. It is a feedback and consistency tool running code defined by a repository. Review hook sources and revisions before executing an unfamiliar project’s configuration.
Update revisions as real dependency changes
For remote hooks:
pre-commit autoupdatepre-commit run --all-files
Inspect the revision diff and tool behavior. An automatic version edit is a proposal, not evidence that the new version fits the project. Keep the update in a focused patch when possible.
WarningNever erase user work to satisfy a hook
A formatter or fixer should touch only intended files. Inspect git status and git diff; do not reset, clean, or overwrite unrelated working-tree changes.
6. Build one named local quality contract
A project is easier to automate when the commands are stable and visible:
ruff check src testsruff format --check src testsmypy srcpytest -q
The pre-commit configuration may use a rewriting ruff format for convenience, while the final manual/CI gate uses ruff format --check. Keep that distinction explicit:
local repair loop: tools may make reviewable changes;
acceptance gate: commands report nonzero rather than modifying committed source.
Lesson 6 will place the check-only sequence in CI.
add local hooks for Ruff lint, Ruff format, MyPy, and pytest;
ensure project-wide tools do not receive appended staged filenames;
install the hook;
create and stage an unsorted import plus an unused import;
commit or run the hook and capture the first modified/failed output;
inspect, stage, and rerun the repaired file;
introduce a real MyPy argument mismatch and explain its diagnostic;
repair it rather than suppressing it; and
finish with a clean pre-commit run --all-files transcript.
Hint A: use the verified local order
Place Ruff lint before Ruff format, then MyPy and pytest. Use language: system for this lab because the active project environment already owns the tools.
Hint B: select the full-project commands
Set pass_filenames: false for mypy src and pytest -q. Add always_run: true to pytest if it should run on every commit.
Hint C: prove the second run is clean
After a hook modifies a file, inspect git diff, stage the intended result, and run pre-commit run --all-files again. A single modifying run is not completion.
A clean run should end with four Passed statuses. Preserve the earlier modified-file and MyPy failure records too; they demonstrate that the gate actually detected and helped repair intended problems.
Key points
Git hooks are local event scripts; pre-commit shares and manages their configuration and environments.
Ordinary commit hooks check the staged snapshot, while --all-files establishes a repository-wide baseline.
Passed, skipped, modified, and failed outputs carry different evidence.
Review and stage tool modifications, then rerun until the gate is idempotent.
Use explicit filters, pass_filenames, ordering, and environment choices so commands run in the intended scope.
A fast local hook shortens feedback but remains bypassable and machine-local; CI is the shared clean gate.