Anttiās articles
2025-12-28 ⢠git ⢠workflows ⢠devtools
In response to pre-commit hooks are fundamentally broken.
I use Lefthook in my own projects and it has saved me a ton of time.
Letās start a new Rust project.
$ mkdir fizzbuzz
$ cd fizzbuzz
$ cat << EOF > main.rs
fn main() { for i in 0.. {
println ("fizzbuzz");
}}
EOF
$ git init
Initialized empty Git repository in /Users/antti/fizzbuzz/.git/
$ git add --all
$ git commit -m fizzbuzz
[main (root-commit) 1400cdd] fizzbuzz
1 file changed, 3 insertions(+)
create mode 100644 main.rs
Neat. Now letās say I add this to some list of fizzbuzz projects in different languages. Maybe ā¦. this one. They tell me I need to have āproper formattingā and āuse consistent styleā. How thoughtful.
I can write a pre-commit hook to check that for me:
$ cat << 'EOF' > lefthook.yml
output:
- success
- failure
pre-commit:
jobs:
- glob: "*.rs"
run: rustfmt {staged_files}
stage_fixed: true
EOF
$ lefthook install
$ git add --all
$ git commit --message "add pre-commit hook"
[main 04a881c] add pre-commit hook
1 file changed, 9 insertions(+)
create mode 100644 lefthook.yml
We are now setup, neat! Maybe we will make one last tweakā¦
$ sed -i '1i // Written by jyn' main.rs
$ git add --all
$ git commit main.rs --message "mark who wrote fizzbuzz"
āļø rustfmt {staged_files} (0.02 seconds)
[main 5e41217] mark who wrote fizzbuzz
1 file changed, 6 insertions(+), 3 deletions(-)
rustfmt ran in 0.02 seconds. Neat!
Letās check the robustness of the Lefthook job.
$ git switch -c simulated-old-branch
Switched to a new branch 'simulated-old-branch'
$ git reset HEAD~1 --hard
HEAD is now at 04a881c add pre-commit hook
$ echo 'fn main() { println!("this counts as fizzbuzz, right?"); }' > print.rs
$ git add --all
$ git commit --message "Add print.rs"
āļø rustfmt {staged_files} (0.02 seconds)
[simulated-old-branch 7c6adb4] Add print.rs
1 file changed, 3 insertions(+)
create mode 100644 print.rs
$ cat print.rs
fn main() {
println!("this counts as fizzbuzz, right?");
}
$ git status
On branch simulated-old-branch
nothing to commit, working tree clean
# notice how main.rs did not change!
Neat! Letās run a rebase
$ git rebase main
Successfully rebased and updated refs/heads/simulated-old-branch.
$ git log -1 --oneline
3057f8a (HEAD -> simulated-old-branch) Add print.rs
$ git commit --amend -m "Add print.rs (after rebase)"
[simulated-old-branch 7ad1d6b] Add print.rs (after rebase)
Date: Sun Dec 28 09:26:58 2025 +0200
1 file changed, 3 insertions(+)
create mode 100644 print.rs
$ git status
On branch simulated-old-branch
nothing to commit, working tree clean
Easy peasy lemon squeezy!
skip: rebase, see āskipā in Lefthook documentation.Other people may not have had the pre-commit hook in use. That is okay. CI should enforce standards for everyone. Standards that cannot be enforced with tools make code reviews unnecessarily long in my opinion.
Now, some people may argue that the code they produced works perfectly fine, and they may even state that they hate the fact that CI is blocking them due to a formatting issue. At that point you may ask them to install Lefthook and adopt the setup, and they will not be bothered by such issues again. Everyone gets cleaner diffs and consistent/easy-to-read code to review.
Skimming through My Git pre-commit hook contained a footgun, the issue appears to be a silent hook failure. That has not happened to me with Lefthook, and rustfmt / cargo fmt should be non-blocking anyway. Please let me know if you are aware of a situation where the lefthook job breaks!
Are pre-commit git hooks a good idea? I donāt think so. raises a valid point. I would not run my entire test suite on pre-commit. pre-push should be fine. But in general investing a bit of time into a fast test suite usually pays nice dividends for the general developer experience, even if it requires a bit of rigor. Scanning the rest of the titles, I am not so sure I buy the points they are making. Especially the point about monorepos. It is quite nice not having to coordinate PRs across several repositories in the exactly right order (and hoping the deployments also happen in an acceptable order). Most of the major production incidents I have seen in my (admittedly so far short) career have been about the unit of deployment trying to be smart about granularity. Try to avoid setups that could lead to your changes being half-deployed. And have a robust rollback system.
Granted, developers are usually busy shipping features and will generally settle for workarounds that allow them to do that. That is ok. I suppose that is why the role āDevOps Engineerā exists. People who specialise and can get things working well. Although too often they are busy with their own toys, not dogfooding the things developers are tolerating. Anyway.
Several tools have fantastic autofixing capabilities. Beyond formatters, golangci-lint and ruff come to mind. Easy way to enjoy stage_fixed with autofixes is to use || true to ignore failures.
On blocking tools, some excellent static validators I have had the pleasure of using are shellcheck, actionlint, and action-validator. Now, I like to use them in a pre-commit hook because I am yet to experience a false positive from them, and for me the scope of changes with shell scripts and github actions is limited enough that I want to catch all issues in every commit. Although I do admit action-validatorās output is quite verbose. I usually dump the output with the failing manifest to GitHub Copilot and the issue gets fixed. Regardless, the tool is open source, so someone may submit a pr to improve the situation. There appears to be an active issue already.
On performance, I recently encountered an issue where action-validator would become slow with the gitignored files from my nix-direnv setup. I had recently been dissatisfied with broken relative links in markdown documentation and not being able to find a tool fast enough static validator to catch broken ones, so I built relcheck to do exactly that. And to my pleasure, the same trick of using git ls-files worked quite well with action-validator! In one scenario the (as of writing unmerged) Replace glob with compare-changes #113 ended up yielding a 66x performance improvement. It is a pretty great feeling to achieve that in a Rust project, which presumably should already be quite performant.
I would love to receive an elaboration on the
Donāt get me started on pre-commit hooks that try to add things to the commit youāre about to make.
because I suppose that is what I have advocated for. Does the issue stem from a home-cooked script? Opt for simple Lefthook jobs. I can see the 3 reference, but I suppose if the job is a non-blocking one I am afraid I do not follow what the issue is.
Regarding
āJust donāt write bad hooksā doesnāt work if Iām working on someone elseās project where I donāt control the hook.
Git hooks should always be optional, enforcement happening in CI. If the current hooks are unsatisfactory to you, one may opt to not use them or try to contribute better ones.
So write your pre-commit hooks as Lefthook jobs! They have saved me a lot of time with
I am still iterating on the peak performance⢠setup that I try to distill to go-starter and rust-starter, but thought to write this article already since people appear to be unaware of Lefthook.
But it would be a pleasure to see people pick up relcheck for robust relative markdown links and find-changes-action with compare-changes-action for monorepo setups.
But in all fairness, pre-commit hooks (no matter how you produce them) are bit unwieldy. It took me decently long to get to the relatively well-working setup I have that most people can not justify spending the time on.