5.2. Pre-Commit Hooks
What are pre-commit hooks?
Git hooks are automated scripts that Git runs at key moments in your workflow. The most important one is the pre-commit hook, which runs against your changes before they are committed to version control. Think of it as a quality gatekeeper for your codebase: a first line of defense that enforces standards and catches issues on your local machine before the code is shared with your team or integrated into the main branch. These hooks can perform a wide range of tasks, from simple code formatting and syntax checks to more complex static analysis.
Why are git hooks essential?
Git hooks are a cornerstone of modern development workflows for several key reasons:
- Enforce Consistent Standards: They automatically enforce coding standards (like formatting and linting), ensuring that all code contributed to the project is clean and consistent.
- Prevent Simple Mistakes: They catch common errors, such as lingering debug statements, syntax errors, or leaked secrets before they are even committed, saving significant time on debugging and code reviews.
- Reduce CI/CD Failures: By running checks locally, you can identify and fix issues that would otherwise cause a CI/CD pipeline to fail. This tightens the feedback loop, making you more productive.
While CI/CD workflows are crucial for comprehensive, server-side validation (like running a full test suite), git hooks offer the advantage of immediate feedback. They run locally, making them faster and easier to debug. A best practice is to use pre-commit hooks for rapid local checks and reserve more time-consuming and resource-intensive jobs for your CI/CD pipeline.
How do you set up git hooks with lefthook?
The tool of choice for managing git hooks is lefthook. It is a single, fast, language-agnostic binary that manages every git hook from one configuration file. Crucially, it lets you keep hooks thin: instead of re-declaring tool versions and commands, each hook simply calls a mise task. This guarantees that your git hooks, your terminal, and your CI all run the exact same checks.
First, add lefthook to your project. Pin it as a development dependency and expose the install step through your mise.toml:
# dependency-groups in pyproject.toml
[dependency-groups]
dev = ["lefthook>=2.1.9"]
Next, create a lefthook.yml file in your project's root directory. Every command delegates to a mise run task—there are no inline tool invocations to keep in sync:
# https://lefthook.dev
# Thin hooks: every command delegates to a `mise run` task so hooks and CI stay identical.
pre-commit:
parallel: false
commands:
format:dprint:
priority: 1
glob: "*.{json,md,toml,yaml,yml}"
run: mise run format:dprint {staged_files}
stage_fixed: true
format:python:
priority: 1
glob: "*.py"
run: mise run format:python {staged_files}
stage_fixed: true
check:leaks:
priority: 2
run: mise run check:leaks --staged
check:
priority: 3
run: mise run check
pre-push:
commands:
test:
run: mise run test
Finally, install the hooks into your local .git directory. This is wired into mise run install, so setting up a freshly cloned project is a single command:
# Install git hooks (also runs as part of `mise run install`)
uv run lefthook install
Now the configured commands run automatically on every git commit and git push. You can also trigger a hook manually at any time:
# Run every pre-commit command against the current changes
uv run lefthook run pre-commit
How does this configuration work?
This small lefthook.yml encodes a few important design decisions:
- Thin, delegated commands: Every
runis justmise run <task>. The tool versions (Ruff, gitleaks, trivy, ...) live inpyproject.tomlandmise.toml, not scattered across hook definitions—so there is only one place to update them. - Format staged files, check the whole tree: The formatters receive
{staged_files}and restage their fixes automatically withstage_fixed: true, keeping commits fast. Thecheckandtesttasks take no file list, so they always validate the whole project and correctness stays global. - Ordered, sequential execution: Lefthook runs a hook's commands alphabetically by name by default, which would let
checkrun before the formatters. Setting an explicitpriority(formatters first,checklast) together withparallel: falseensures the formatters restage their changes beforecheckreads the files from disk. - Fast local secret scanning:
check:leaks --stagedscans only the incoming change for credentials, complementing the deeper history scan that runs in CI.
You only need to pin one tool—lefthook itself (2.x)—because everything the hooks execute is defined by your mise tasks. This is a major simplification over legacy setups that duplicated every linter's version inside the hook config.
How can you standardize commit messages and changelogs?
Clear, consistent commit messages are vital for a healthy project history. The Conventional Commits standard gives each commit a structured prefix that explains its intent:
feat: add champion/challenger model promotion
fix: handle empty feature frame in inference job
refactor: extract MLflow client into a service
chore: bump scikit-learn to 1.9
Adopting this convention unlocks automation. git-cliff is a fast, highly configurable changelog generator that parses your Conventional Commits and produces a clean CHANGELOG.md—no extra runtime dependency, since it is a standalone binary you can pin in mise.
Configure it once in a cliff.toml file, mapping commit prefixes to changelog sections:
[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
{ message = "^feat", group = "🚀 Features" },
{ message = "^fix", group = "🐛 Bug Fixes" },
{ message = "^refactor", group = "♻️ Refactor" },
{ message = "^docs", group = "📚 Documentation" },
{ message = "^chore\\(release\\)", skip = true },
]
tag_pattern = "v[0-9].*"
You can then generate or update your changelog and cut releases from the same commit history:
# Generate the full changelog from the git history
git cliff --output CHANGELOG.md
# Preview only the unreleased changes since the last tag
git cliff --unreleased
# Tag a release, then publish it with the GitHub CLI
git tag --annotate v1.0.0 --message "Release v1.0.0"
gh release create v1.0.0 --notes-from-tag
This keeps your changelog and version tags perfectly aligned with your commit history—see the CI/CD Workflows chapter for automating the release publication.
What is the difference between pre-commit, pre-push, and commit-msg hooks?
Lefthook can manage hooks at different stages of the Git workflow. Understanding the most common ones is key to using them effectively:
-
pre-commit: This is the most common hook. It runs before you even type a commit message. Its purpose is to inspect the snapshot of the files you are about to commit. This is the ideal stage for running fast checks like formatters, linters, and secret scanners. If any of these checks fail, the commit is aborted, allowing you to fix the issues first. -
commit-msg: This hook runs after thepre-commithook and before the commit is finalized. It takes the commit message as an argument. Its primary use case is to validate the commit message itself—for example, to ensure it follows the Conventional Commits format. If the hook fails, the commit is aborted. -
pre-push: This hook runs before you push your commits to a remote repository. It's your last line of defense on the client side. Because it runs less frequently thanpre-commit, it's a suitable place for longer-running checks that you would not want on every single commit, such as running the test suite (mise run test).
How can you bypass a hook?
On rare occasions, you may need to bypass a hook—for example, to commit a work-in-progress that you don't intend to push. To skip all hooks for a single commit or push, use the --no-verify flag.
# Bypass hooks for a single commit
git commit -m "WIP: work in progress" --no-verify
# Bypass hooks for a single push
git push --no-verify
Use this option with caution. Bypassing hooks should be the exception, not the rule, as it defeats the purpose of having automated quality checks. Whenever possible, fix the underlying failure instead of skipping the hook.
What are the best practices for using hooks?
To implement git hooks effectively, follow these guidelines:
- Keep Hooks Thin: Never inline tool commands. Every hook should call
mise run <task>, so your local checks and CI stay identical and there is only one place to change behavior. - Keep it Fast: Prioritize checks that execute quickly (ideally in seconds). Slow hooks create friction and tempt developers to bypass them. Formatters and linters belong in
pre-commit; reserve the full test suite forpre-push. - Pin Your Toolchain: Pin your tools in
mise.tomlandpyproject.toml. This ensures that all developers on the team use the exact same versions, preventing inconsistencies and "it works on my machine" issues. - Collaborate on Configuration: The hook configuration should be a team decision. Discuss and agree upon the standards you want to enforce to ensure buy-in and consistency across the project.
- Balance Local vs. CI/CD: Use
pre-commitfor quick, local feedback. Reserve comprehensive, time-consuming checks (like integration tests, end-to-end tests, or complex builds) for your CI/CD pipeline. Thepre-pushhook is a good middle ground for the test suite. - Start Simple, Iterate: Begin with a small, essential set of hooks (formatting, linting, secret scanning). You can always add more specialized commands later as the project's needs evolve.