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.10"]
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.
# Lefthook orders commands alphabetically, so priorities are explicit: formatters (10)
# restage before the staged secret scan (20) and the whole-tree checks (30) read from disk.
pre-commit:
parallel: false
commands:
format:dprint:
priority: 10
glob: "*.{json,md,toml,yaml,yml}"
run: mise run format:dprint {staged_files}
stage_fixed: true
format:python:
priority: 10
glob: "*.py"
run: mise run format:python {staged_files}
stage_fixed: true
check:leaks:
priority: 20
run: mise run check:leaks --staged
check:
priority: 30
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.toml,mise.toml, andmise.lock, 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: The
prioritynumbers andparallel: falseare what make the hook correct rather than merely convenient—see below. - Layered secret scanning:
check:leaks --stagedscans only the incoming change for credentials, and it is one of three passes at different depths—see below.
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.
Why do the priorities jump by ten?
Lefthook runs a hook's commands alphabetically by name when you do not tell it otherwise. Read the command names in the file above in alphabetical order and the problem is obvious: check comes before check:leaks, which comes before format:dprint and format:python. Without priorities, the hook lints and type-checks your files first, then formats them—so an unformatted file fails check:format even though the very next command was about to fix it. You would fix nothing, re-run git commit, and it would pass the second time. A hook that only works on the retry is a hook people learn to bypass.
The priority key overrides that ordering explicitly. Lower numbers run first, so the three tiers are:
- 10 — formatters. They rewrite the staged files and restage the result via
stage_fixed: true. - 20 — the staged secret scan. It reads the index after formatting, so it inspects exactly the bytes that are about to be committed.
- 30 — the whole-tree checks.
mise run checkreads the files from disk, which now match what is staged.
Two supporting details make the ordering real:
parallel: falseis mandatory. Priorities decide the order of starting, but only sequential execution guarantees that the formatters have finished writing and restaging beforecheckreads from disk. Withparallel: true, the three tiers would race.- The gaps of ten are a convention, not a requirement. Numbering 10/20/30 instead of 1/2/3 leaves room to slot a new command between two tiers later—a schema validator at 15, say—without renumbering the whole file and re-reviewing every line of the diff.
How should you layer secret scanning?
Secret scanning is the clearest example of why a single check is not enough. The same tool, gitleaks, runs three times at three different depths, and each pass catches something the others structurally cannot:
| Where | Command | What it can catch |
|---|---|---|
pre-commit hook |
mise run check:leaks --staged |
The secret before it exists in history—the only moment a fix is free. |
mise run check (local and CI) |
gitleaks git --log-opts="--max-count=100" --verbose |
A secret in the recent commits, fast enough to run on every pull request. |
| Scheduled workflow | gitleaks git --redact=100 --verbose over a full-depth checkout |
A secret that was committed and later deleted, which no shallow scan will ever see again. |
The middle pass is the compromise: bounding it with --max-count=100 keeps the gate quick, but that bound is exactly what makes it blind to older history. That is why the third pass exists—a weekly .github/workflows/security.yml that checks out with fetch-depth: 0 and scans everything. The CI/CD Workflows chapter covers that workflow in detail.
The order matters as much as the depth. Once a secret is committed, removing it requires rewriting history and rotating the credential, because anyone who pulled the branch already has it. The --staged pass is the only one that prevents the incident instead of reporting it.
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 },
{ message = "^chore\\(deps\\)", skip = true },
]
tag_pattern = "v[0-9].*"
The two skip = true entries deserve a warning, because a skip rule is a silent contract with whatever writes those commits. Release commits are written by you, so ^chore\(release\) matches by construction. Dependency bumps are written by Dependabot, which by default prefixes its commits build(deps) or build(deps-dev)—not chore(deps). A repository with the filter above and a default Dependabot configuration therefore filters nothing: the rule never matches, and every routine dependency bump lands in the published changelog next to the features users actually care about.
The fix is to make the bot speak the convention the filter expects, in .github/dependabot.yml:
commit-message:
prefix: "chore(deps)"
The general lesson is worth more than the specific fix: a filter that matches nothing looks exactly like a filter that has nothing to filter. Whenever you add a skip rule, generate the changelog once and confirm that something actually disappeared.
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.