git · level 9

Hooks

Client-side and server-side hooks — and why CI is a server-side hook in disguise.

175 XP

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 commit and git 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:

  1. .git/ is per-clone. Nothing in .git/ is committed or shared. Your team can't get your hooks by cloning the repo.
  2. 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 run npm install (Husky) or lefthook 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-staged for "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:

  1. Husky's pre-commit script runs.
  2. lint-staged finds staged files matching the globs.
  3. Runs ESLint and Prettier on them.
  4. Re-stages the auto-fixes.
  5. 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-receive hook 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:

  1. Be fast. Anything over a few seconds gets bypassed. Run only what's needed; run it on staged files only.
  2. Auto-fix when possible. Don't fail with "run prettier"; run prettier yourself and re-stage. The developer should hardly notice.
  3. Have an escape hatch. Document --no-verify for 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-msg with commitlint. Pays off in changelogs and semantic-release.
  • Secret scanning on commit. Run git-secrets or trufflehog in pre-commit. Blocks the commit if AWS credentials are detected. Saves you a security incident.
  • Commit-message prefix automation. prepare-commit-msg that prefixes with the branch's ticket ID — turns feat(auth): add TOTP into feat(auth): JIRA-1234 add TOTP automatically.

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
  • Huskyfree tier

    Node.js git hook manager. The default for JS/TS projects.

    library
  • Lefthookfree tier

    Language-agnostic, fast, parallel-by-default git hook manager in Go.

    cli
  • pre-commitfree tier

    Python-based hook framework with a huge library of pre-built hooks.

    cli
  • lint-stagedfree tier

    Run linters/formatters on git-staged files only. Pairs with husky.

    library
  • commitlintfree tier

    Lint commit messages against conventional-commits or custom rules.

    library