Git Hooks That Actually Help
· Jerwin Arnado · 6 min read ·
A Git hook is a script Git runs automatically at a point in its lifecycle — before a commit, before a push, after a checkout. Used well, they catch mistakes before they become someone else’s problem: a lint error that never gets committed, a broken test that never reaches CI, a malformed commit message that never lands. Used badly, they’re slow, undocumented, and bypassed within a week. This is the short list of hooks worth having, and the way to share them that doesn’t rot.
Where hooks live
Every repo has a .git/hooks/ directory full of .sample files. Drop an executable
script named for the hook (no extension), and Git runs it at that lifecycle point. A
non-zero exit code aborts the operation. The catch you already met with the
commit-msg hook: .git/hooks/ is
not version-controlled, so a raw hook lives only on your machine. We’ll fix sharing at
the end.
The four hooks worth having
| Hook | Fires | Good for |
|---|---|---|
pre-commit |
before the commit is created | lint, format, block secrets/debug statements |
commit-msg |
after you write the message | validate Conventional Commits |
pre-push |
before a push reaches the remote | run the test suite, block pushing to main |
post-merge / post-checkout |
after merge/checkout | re-run composer install / npm install |
pre-commit: lint and format before it’s recorded
The workhorse. Run your formatter and linter on the staged files; abort if they fail:
#!/usr/bin/env bash
# .git/hooks/pre-commit
# Format staged PHP files; fail the commit if the linter does.
./vendor/bin/pint --dirty || exit 1
npm run lint || exit 1
--dirty (Laravel Pint) only touches changed files, keeping it fast. The win: badly
formatted code never enters a commit, so PRs stop filling with “fix formatting” noise.
pre-push: the last gate before the remote
pre-commit should be fast (you do it constantly); the slower checks belong in
pre-push, which fires far less often:
#!/usr/bin/env bash
# .git/hooks/pre-push — run tests before anything reaches the remote
php artisan test || {
echo "✗ Tests failed — push aborted." >&2
exit 1
}
This catches the “green locally, forgot to run tests, pushed red” mistake before CI (and
your teammates) ever see it. It pairs naturally with bisect:
keep main green and a future bisect actually has honest endpoints to work with.
Keep hooks fast, or they get bypassed
The fastest way to kill a hook is to make it slow. A pre-commit that takes 20 seconds
trains everyone to type --no-verify, and then it protects nothing. Rules:
pre-commit: only the staged files, only fast checks (format, lint, secret-scan).pre-push: the slow stuff (full test suite) — it runs far less often.- Anything truly heavy belongs in CI, not a local hook. Hooks are a fast first line, not a replacement for the pipeline.
Sharing hooks: lefthook (or husky)
Because .git/hooks/ isn’t tracked, hand-rolled hooks don’t travel to teammates. The
plain-Git fix is git config core.hooksPath .githooks (a tracked directory) plus a
one-time setup step. A hook manager automates even that. lefthook is fast,
language-agnostic, and config-driven — a single lefthook.yml in the repo root:
pre-commit:
parallel: true
commands:
pint:
glob: "*.php"
run: ./vendor/bin/pint --dirty {staged_files}
eslint:
glob: "*.{js,vue,ts}"
run: npx eslint {staged_files}
pre-push:
commands:
tests:
run: php artisan test
commit-msg:
commands:
conventional:
run: npx commitlint --edit {1}
Install it once (lefthook install, ideally wired into your project’s setup script) and
every clone gets the same hooks. glob + {staged_files} means each tool only runs on the
files it cares about, and parallel: true runs them at once — fast by default. (husky
does the same job in the Node ecosystem; pre-commit the Python framework, another.) The
point is the same: hooks the whole team shares, installed automatically, no per-machine
ritual to forget.
Caveats and best practices
- Hooks are bypassable by design.
--no-verifyskips them. They catch honest mistakes, not adversaries — so the real enforcement is the CI gate on the PR. Hooks + CI, never hooks alone. - Never put secrets or environment assumptions in a hook. It runs on every teammate’s machine; assume nothing about their paths beyond what the repo provides.
- A secret-scanning
pre-commitis high value. Blocking an.envvalue or an API key before it’s committed is far cheaper than purging it from history and rotating the credential after. - Document the install step. Even with a manager, a clone that skips
lefthook installsilently has no hooks. Put it in the README and themake setuptarget.
Conclusion
# lefthook.yml — shared, auto-installed, fast
pre-commit: { commands: { lint: { run: "..." } } } # format/lint staged files
pre-push: { commands: { test: { run: "..." } } } # tests before the remote
commit-msg: { commands: { conv: { run: "..." } } } # message format
Good hooks are fast, shared, and a first line of defense — not the only one. Lint on commit, test on push, validate the message, and back it all with CI. Done right, the whole class of “oops, I committed/pushed something broken” mistakes quietly stops happening.