Git Hooks

Enforcing rules automatically with pre-commit and commit-msg hooks, client-side vs server-side enforcement, and sharing hooks across a team.

What hooks are

A hook is a script Git runs automatically at a specific point in its workflow — before a commit is finalized, before a push leaves your machine, after a checkout, and many other points. Every Git repository already has a .git/hooks/ directory full of sample hooks (.sample files) that do nothing until you remove the extension and make them executable. Hooks are how a team enforces rules ("no commit without a properly formatted message," "run the linter before this leaves my machine") without relying on everyone remembering to do it manually.

Bash
$ ls .git/hooks/
applypatch-msg.sample      pre-merge-commit.sample
commit-msg.sample          pre-push.sample
pre-commit.sample          pre-rebase.sample
prepare-commit-msg.sample  update.sample

The hooks that matter most day to day

Hook Runs Typical use
pre-commit Before a commit message is even prompted for Lint/format staged files, run fast unit tests, block committing debug statements
commit-msg After the message is written, before the commit is finalized Enforce a commit message format/convention
pre-push Before commits are uploaded to a remote Run a fuller test suite, block pushing directly to main
post-checkout After git checkout/git switch completes Reinstall dependencies if the lockfile changed between branches

A hook is just an executable script — any language works, as long as the file is executable and its shebang points at a real interpreter. Git only cares about the exit code: 0 lets the operation proceed, anything else aborts it.

A real pre-commit hook: blocking obvious mistakes

Bash
#!/bin/bash
# .git/hooks/pre-commit — blocks committing files that still contain
# debug statements or leftover merge conflict markers.

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

if [ -z "$STAGED_FILES" ]; then
    exit 0
fi

FOUND_ISSUE=0

for file in $STAGED_FILES; do
    if grep -nE "console\.log\(|dd\(|dump\(" "$file" > /dev/null 2>&1; then
        echo "Debug statement found in $file — remove it before committing."
        FOUND_ISSUE=1
    fi

    if grep -nE "^(<<<<<<<|=======|>>>>>>>)" "$file" > /dev/null 2>&1; then
        echo "Unresolved merge conflict markers found in $file."
        FOUND_ISSUE=1
    fi
done

if [ "$FOUND_ISSUE" -eq 1 ]; then
    echo "Commit aborted. Fix the issues above and try again."
    exit 1
fi

exit 0
Bash
$ chmod +x .git/hooks/pre-commit
$ git commit -m "Add user search"
Debug statement found in app/Search.php — remove it before committing.
Commit aborted. Fix the issues above and try again.

Nothing here needed a special hooks framework — git diff --cached --name-only (the same staging-area concept from Git Introduction) lists exactly the files about to be committed, and a non-zero exit 1 is all it takes to stop the commit before it's created.

A real commit-msg hook: enforcing a message format

Unlike pre-commit, the commit-msg hook receives one argument: the path to a temporary file already containing the message the user just typed, so it can inspect (and even fail on) the message's actual content:

Bash
#!/bin/bash
# .git/hooks/commit-msg — enforces Conventional Commits style:
# "type(scope): summary", e.g. "feat(auth): add password reset"

MESSAGE_FILE="$1"
FIRST_LINE=$(head -n 1 "$MESSAGE_FILE")
PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\([a-z0-9-]+\))?: .+"

if ! echo "$FIRST_LINE" | grep -Eq "$PATTERN"; then
    echo "Commit message does not follow the required format:"
    echo "  <type>(<scope>): <summary>"
    echo "  e.g. feat(auth): add password reset flow"
    echo ""
    echo "Allowed types: feat, fix, docs, style, refactor, test, chore"
    exit 1
fi

exit 0
Bash
$ git commit -m "fixed bug"
Commit message does not follow the required format:
  <type>(<scope>): <summary>
  e.g. feat(auth): add password reset flow

Allowed types: feat, fix, docs, style, refactor, test, chore

$ git commit -m "fix(auth): correct password reset token expiry check"
[main a1b2c3d] fix(auth): correct password reset token expiry check

This is exactly how tools that generate a changelog automatically from commit history (semantic-release and similar) can work at all — the message format itself carries structured meaning (a fix: versus a feat: implies a patch versus minor version bump) only if it's actually enforced consistently, which a commit-msg hook guarantees far more reliably than a code review comment reminding people each time.

Client-side vs. server-side hooks

Everything above runs client-side — on the machine making the commit or push — which means it can be skipped entirely (git commit --no-verify) or simply not exist if a teammate hasn't set it up, since .git/hooks/ is not something git clone copies from the remote. Genuine enforcement that nobody can bypass locally has to also live server-side: a pre-receive hook on a self-hosted Git server, or the equivalent branch-protection/status-check features on GitHub or GitLab (requiring a passing CI check before a PR can merge, for instance).

Client-side hooks Server-side enforcement
Where it runs The developer's own machine The Git server / CI platform
Can be bypassed locally Yes (--no-verify, or simply not installed) No — it runs regardless of the client
Best for Fast feedback before a commit/push even happens Rules that genuinely must never be skipped
Typical setup A framework like Husky, or plain scripts + core.hooksPath Branch protection rules, required status checks, a server pre-receive hook

Sharing hooks across a team

.git/hooks/ is not version-controlled — it lives inside .git/, which every clone regenerates fresh and empty of custom hooks. Two common ways teams distribute the same hooks to everyone:

Bash
# Point Git at a tracked directory instead of .git/hooks/ directly
$ git config core.hooksPath .githooks
Plaintext
.githooks/
├── pre-commit
└── commit-msg

With .githooks/ committed to the repository and core.hooksPath set (ideally by an onboarding script every developer runs once), everyone's local Git installation runs the exact same hooks — no separate framework required. For more elaborate needs (hooks with dependencies, hooks that need to be installed automatically on npm install), a dedicated tool like Husky (JavaScript ecosystem) or the Python-based pre-commit framework manages this same idea with more tooling around it: automatic installation, a shared config file listing which checks to run, and a plugin ecosystem of ready-made hooks.

Common mistakes

  • Assuming a .git/hooks/pre-commit script will "just work" for every teammate after being added to the repository — the directory isn't tracked by Git at all, so it has to be distributed via core.hooksPath or a framework like Husky, not by committing directly into .git/hooks/.
  • Writing a hook that silently does nothing on failure (forgetting to exit 1) — Git only blocks the operation on a non-zero exit code; a hook that always exits 0 enforces nothing no matter what it prints.
  • Relying entirely on client-side hooks for a rule that truly must never be skipped — --no-verify bypasses every client-side hook instantly, so genuinely mandatory checks belong in CI or server-side branch protection too.
  • Making a pre-commit or pre-push hook too slow (a full test suite on every single commit) — a slow hook trains people to reach for --no-verify out of frustration, defeating its purpose; save the expensive checks for pre-push or CI, and keep pre-commit fast.