Skip to main content
Git

Git hooks: pre-commit, pre-push, and stopping bad code at the door

Git hooks are shell scripts that run automatically at key points in the git workflow. Here's how pre-commit, commit-msg, and pre-push hooks work, how to share them across your team, and where they can't catch everything.

Thien Nguyen
By Thien Nguyen
Updated July 20, 2026 · 5 min read

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:

HookWhen it runsCommon uses
pre-commitBefore a commit is recordedLint, format check, secret scanning
commit-msgAfter you type a commit messageEnforce message format, ticket references
pre-pushBefore git push sends anythingRun tests, type-check
post-mergeAfter a successful git mergeRun npm install if lockfile changed
prepare-commit-msgBefore the commit message editor opensAuto-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.

References

Primary documentation and specifications checked when this article was last updated.

GitDeveloper ToolsWorkflow

Related articles

All articles