Hooks
Client-side and server-side hooks — and why CI is a server-side hook in disguise.
Hooks
Git hooks are scripts git runs at specific moments in the commit/push lifecycle. They're how you bake your team's standards into the workflow itself — formatting, linting, message conventions, security scans — and they're almost always managed by a tool, not by hand.
Analogy
Git hooks are the airport security checkpoints of your repo. Some are at your origin airport (client-side hooks): they catch problems before you board a plane — wrong shoes, a knife in your bag, formatting your toothpaste forgot to declare. Others are at the destination airport (server-side hooks): they check your passport on arrival — visas, fingerprints, "are you really cleared to land here?". You want both. Skipping security at one airport doesn't help; the other one will catch you, but later and at higher cost. CI is the destination airport with extra cameras.
The two sides — client and server
Hooks split into two universes:
- Client-side hooks run on the developer's machine, around their
git commitandgit push. They live in.git/hooks/, which is per-clone, local-only, never pushed. - Server-side hooks run on the git host (GitHub, GitLab, your own server) when a push arrives. They can reject the push.
This is the most important distinction: client hooks are a developer convenience and can be bypassed (--no-verify); server hooks are an enforcement mechanism and cannot.
The client-side hooks you'll use
| Hook | When it fires | Use it for |
|---|---|---|
| pre-commit | Before commit message editor opens | Lint, format, run tests on staged files |
| prepare-commit-msg | Before commit message editor opens (with the message file path) | Auto-prefix with branch name, ticket ID |
| commit-msg | After commit message written, before commit created | Validate message format (conventional commits) |
| post-commit | After commit created | Notify Slack, run a post-commit script |
| pre-push | Before push goes to remote | Run full test suite, scan for secrets |
The 80/20 of client hooks is pre-commit (lint + format) and commit-msg (message convention). Most teams stop there.
Why managers exist (Husky, lefthook, pre-commit)
Vanilla git stores hooks at .git/hooks/<hook-name>. Two problems with that:
.git/is per-clone. Nothing in.git/is committed or shared. Your team can't get your hooks by cloning the repo.- Hooks are shell scripts that everyone has to write from scratch. Want pre-commit to run ESLint on staged files? You write the loop yourself.
Hook managers solve both:
- They keep config (
.husky/,lefthook.yml,.pre-commit-config.yaml) in the repo, version-controlled. - They install the actual hooks at
.git/hooks/automatically when developers runnpm install(Husky) orlefthook install. - They provide built-in helpers — "run this command on staged files of these patterns", "fail if any output", "run in parallel".
The three popular managers:
- Husky — Node-based, the default for JS/TS projects. Pairs with
lint-stagedfor "run linter on staged files only". - lefthook — Go-based binary, language-agnostic, parallel-by-default. The right choice for polyglot repos.
- pre-commit — Python-based, language-agnostic, the largest library of pre-built hooks. Originally Python community.
Pick one and stick with it. Don't mix.
A typical pre-commit setup
.husky/pre-commit:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
package.json:
{
"lint-staged": {
"*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{md,json,yml}": ["prettier --write"]
}
}
Now every git commit:
- Husky's pre-commit script runs.
- lint-staged finds staged files matching the globs.
- Runs ESLint and Prettier on them.
- Re-stages the auto-fixes.
- If any check fails, the commit aborts.
Total time on a typical commit: a few hundred milliseconds. Imperceptible.
A typical commit-msg setup
Enforce conventional commits with commitlint:
.husky/commit-msg:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}
commitlint.config.cjs:
module.exports = { extends: ['@commitlint/config-conventional'] };
Now git commit -m "fix bug" is rejected; git commit -m "fix(api): return 422 on bad json" succeeds.
The --no-verify escape hatch
Any client-side hook can be bypassed:
git commit --no-verify -m "wip"
git push --no-verify
This is by design. Hooks are help, not enforcement. If a developer needs to commit something quickly without the hook, they can.
The implication: client hooks are advice, not policy. If a check is policy (must never go to main), it has to live on the server side or in CI.
Server-side hooks
These run on the git server when a push arrives. They cannot be bypassed by the pusher.
| Hook | When it fires | Use it for |
|---|---|---|
| pre-receive | Before any updates to refs are accepted | Reject the entire push if any rule fails (e.g. force-push to main, secret in commit) |
| update | Once per ref being updated | Reject specific refs (e.g. only certain users can push to release/*) |
| post-receive | After all updates accepted | Trigger CI, notify chat, deploy |
Self-hosted git (Gitolite, Gerrit, GitLab self-hosted) lets you write these directly. Hosted git providers (GitHub, GitLab.com, Bitbucket) don't expose direct server-side hooks but offer branch protection rules and CI/CD as the equivalent.
Why CI is a server-side hook in disguise
Look at this from a system-design angle:
- A
pre-receivehook gates whether a push is accepted. - CI runs after a push, blocks the merge if it fails.
Functionally identical: a check that runs on the server, after the developer has pushed, that gates progress. The difference is implementation:
| Feature | pre-receive hook | CI |
|---|---|---|
| Where it runs | Inside the git server process | A separate runner system |
| Speed | Must finish in milliseconds-seconds | Can take minutes |
| Output | Stdout/stderr to the client | Rich UI, logs, artifacts |
| Visibility | Blocks the push immediately | Sets a status check |
| What it can do | Reject the push | Reject the merge |
For most modern workflows, CI is the server-side hook layer. You set GitHub branch protection to require CI to pass, and the result is functionally identical to a pre-receive hook that runs all your checks. The added value is the UX: rich logs, parallel jobs, history of past runs, fast feedback.
This explains why server-side git hooks are rare in hosted setups: branch protection + CI ate their lunch.
Hook ergonomics — the rules
A pre-commit hook is a developer's daily-use tool. Three rules:
- Be fast. Anything over a few seconds gets bypassed. Run only what's needed; run it on staged files only.
- Auto-fix when possible. Don't fail with "run prettier"; run prettier yourself and re-stage. The developer should hardly notice.
- Have an escape hatch. Document
--no-verifyfor the rare emergency commit. Don't pretend you've made it impossible to bypass.
A pre-push hook can afford to be slower (it runs once per push, not per commit). Run unit tests there; let CI do the integration tests.
Common patterns
- Lint + format on commit, full test suite on push. Quick feedback for cheap checks; deeper checks before the network round-trip.
- Conventional commits enforcement.
commit-msgwith commitlint. Pays off in changelogs and semantic-release. - Secret scanning on commit. Run
git-secretsortrufflehogin pre-commit. Blocks the commit if AWS credentials are detected. Saves you a security incident. - Commit-message prefix automation.
prepare-commit-msgthat prefixes with the branch's ticket ID — turnsfeat(auth): add TOTPintofeat(auth): JIRA-1234 add TOTPautomatically.
Anti-patterns
- Slow pre-commit hooks. A 30-second hook gets
--no-verify'd daily. The hook is now worse than nothing. - Hooks not committed to the repo. Each developer configures their own. Inconsistent. Use a manager.
- Treating client hooks as enforcement. They're advice. Real enforcement lives on the server / in CI / in branch protection.
- One mega-hook that does everything. Split into separate concerns. lefthook's parallel runner especially loves this.
What to internalise
- Client hooks (pre-commit, commit-msg, pre-push) are local advice; they can be bypassed.
- Server hooks (pre-receive, update) are enforcement; they cannot be bypassed by the pusher.
- Use a manager (Husky/lefthook/pre-commit) so hooks are version-controlled and team-shared.
- Hooks must be fast. Auto-fix when possible. Defer slow checks to CI.
- CI is the modern server-side hook for most teams — branch protection enforces it.
Tools in the wild
5 tools- libraryHuskyfree tier
Node.js git hook manager. The default for JS/TS projects.
- cliLefthookfree tier
Language-agnostic, fast, parallel-by-default git hook manager in Go.
- clipre-commitfree tier
Python-based hook framework with a huge library of pre-built hooks.
- librarylint-stagedfree tier
Run linters/formatters on git-staged files only. Pairs with husky.
- librarycommitlintfree tier
Lint commit messages against conventional-commits or custom rules.