Skip to content

← Writing

engineering

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-verify skips 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-commit is high value. Blocking an .env value 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 install silently has no hooks. Put it in the README and the make setup target.

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.