A git hook is a script that git runs automatically before or after specific operations — commit, push, merge, rebase. They're one of those features that most developers know exist and fewer have actually set up, despite being one of the most effective ways to catch issues before they reach a remote or a CI pipeline that takes three minutes to tell you about a lint error you could have caught in one second.
Where hooks live and how they work
Every git repository has a .git/hooks/ directory populated with sample files on init. To activate a hook, create an executable file with the exact name git expects — no extension. The most commonly used:
| Hook | When it runs | Common uses |
|---|---|---|
pre-commit | Before a commit is recorded | Lint, format check, secret scanning |
commit-msg | After you type a commit message | Enforce message format, ticket references |
pre-push | Before git push sends anything | Run tests, type-check |
post-merge | After a successful git merge | Run npm install if lockfile changed |
prepare-commit-msg | Before the commit message editor opens | Auto-insert branch name or ticket ID |
The hooks are shell scripts — they can run anything: npm run lint, python scripts/check_secrets.py, go vet ./.... A non-zero exit code aborts the operation.
pre-commit: the most valuable hook
The pre-commit hook runs after you type git commit but before git records the commit. Exit non-zero and the commit is aborted with your message. This is where fast checks belong:
#!/bin/sh
npm run lint --silent || exit 1
npm run format:check --silent || exit 1
The critical rule: only run staged files, not the whole project. A hook that lints every file takes 30 seconds on a large project and people will start using --no-verify to skip it. Most linters support a --cached or --staged flag; tools like lint-staged exist specifically to pipe staged files through arbitrary commands efficiently.
#!/bin/sh
npx lint-staged || exit 1
With lint-staged configured in package.json:
{
"lint-staged": {
"*.ts": ["eslint --fix", "prettier --write"],
"*.{ts,js,json}": "prettier --check"
}
}
Only the staged files get linted and formatted — fast enough that nobody reaches for --no-verify.
commit-msg: enforcing commit format
The commit-msg hook receives the path to the file containing the proposed commit message as its first argument. A simple conventional-commit enforcer:
#!/bin/sh
MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}"
if ! echo "$MSG" | grep -qP "$PATTERN"; then
echo "Commit message must match: type(scope): description"
echo "Example: feat(auth): add OAuth2 login"
exit 1
fi
This is preferable to enforcing message format in CI, where a failed check requires a git commit --amend or an extra fixup commit. Catching it at commit time means the history is clean from the start.
pre-push: the safety net before remote
The pre-push hook runs before git push sends anything to the remote. It's passed the remote name and URL on stdin in a specific format, but most uses just run a check unconditionally:
#!/bin/sh
npm run test || exit 1
npm run typecheck || exit 1
This is where slower checks belong — test suite, type check — things too slow for pre-commit but worth catching before you've made the code public (or before CI has to pick it up). A failed pre-push doesn't affect the local commits; you can fix and push again.
Sharing hooks across a team
The .git/hooks/ directory is not tracked by git — which means your carefully-crafted pre-commit hook stays local unless you have a distribution strategy. Three common approaches:
Commit hooks in the repo, symlink from a setup script. A scripts/install-hooks.sh that ln -sf ../../scripts/hooks/pre-commit .git/hooks/pre-commit is simple and auditable, but requires each developer to run the setup script.
Husky. The most common solution for JavaScript projects: Husky patches the git core hooksPath to point to a .husky/ directory you commit. Hooks install automatically when anyone runs npm install, requiring no manual step.
core.hooksPath in .gitconfig. Git supports a global or local core.hooksPath setting that redirects hook resolution to any directory. Set it in the repo's .git/config or in a shared .gitconfig template teams bootstrap from:
[core]
hooksPath = .githooks
Now create .githooks/pre-commit in the repo and everyone shares it automatically — no framework needed, works in any language project.
What hooks can't do
Hooks run locally. Anyone can skip them with git commit --no-verify or git push --no-verify. This is by design: hooks are for developer convenience and early feedback, not security enforcement. Anything that must be enforced — required CI checks, branch protection rules, required approvals — belongs in CI or in the remote (GitHub/GitLab branch protection), not in hooks.
Hooks also don't run in scenarios like git push --mirror, git format-patch, or some GUI clients that bypass the hook plumbing entirely. The CI pipeline is always the authoritative gate; hooks are the fast feedback loop that shortens the round-trip.
The reference when you need it
Our Git Cheatsheet covers hooks alongside the full git command reference — including the full list of hook names, their trigger points, and their argument conventions — searchable without leaving the browser. For keeping sensitive patterns out of your repo in the first place, the .gitignore Generator builds a comprehensive .gitignore for any language or framework combination so pre-commit secret-scanning has less to worry about.
