Pre-commit Hook Takes 3 Minutes — Entire Team Bypasses It
CI failures jumped 6x after a 3-minute pre-commit—discover 3 rules to prevent hook bypass and the exact structure that keeps hooks fast and enforced.
- Client-side: pre-commit (lint/format), commit-msg (message validation), pre-push (full tests)
- Server-side: pre-receive (enforce branch protection), update (per-branch rules)
- Exit code 0 = success, non-zero = cancel the operation
- Located in .git/hooks/ — not shared on clone by default
Imagine every time you dropped a letter into a postbox, a little robot checked it for spelling mistakes, confirmed you'd written a return address, and only then let it slide in. Git hooks are exactly that robot — tiny scripts that Git automatically runs before or after key events like committing, pushing, or merging. You write the rules once, and Git enforces them every single time, without you having to remember to do it yourself.
Git hooks are executable scripts that Git runs automatically at specific lifecycle events — before a commit, after a merge, before a push. They enforce quality gates at the exact point where mistakes enter the repository: the git command itself.
Client-side hooks (pre-commit, commit-msg, pre-push) catch lint errors, enforce commit message conventions, and run tests before code leaves a developer's machine. Server-side hooks (pre-receive, update) enforce branch protection and reject non-compliant pushes regardless of what the developer's local hooks allowed.
The critical distinction: client-side hooks can always be bypassed with --no-verify. They are quality-of-life tools, not enforcement mechanisms. Server-side hooks are the only truly un-bypassable enforcement. Teams that rely solely on client-side hooks for critical rules have a false sense of security.
What Git Hooks Actually Are — and Where They Live
Every git repository you initialise or clone contains a hidden .git folder. Inside that folder is a hooks directory. Open it and you'll find a collection of sample scripts with names like pre-commit.sample, commit-msg.sample, and post-merge.sample. These samples are Git's way of telling you: 'these are the moments I can pause and hand control over to you.'
A hook is just an executable script — shell, Python, Node.js, Ruby, whatever you like — placed in .git/hooks with a specific filename that Git recognises. When Git reaches a trigger point (say, the moment you run git commit), it looks for a script with the matching name, runs it, and checks the exit code. Exit code 0 means 'all good, carry on.' Any non-zero exit code means 'stop everything — something failed.'
This is the critical insight beginners miss: hooks are not magic — they're just scripts with agreed-upon filenames and a simple yes/no contract with Git via exit codes. That simplicity is what makes them so powerful. There's no special API to learn, no plugin system to configure. If you can write a script, you can write a hook.
- .git/hooks/ is local-only — never cloned, never pushed, never shared
- Git ships sample hooks as *.sample files — rename to activate
- core.hooksPath lets you point Git to a committed hooks directory
- Husky automates hook installation via npm's prepare lifecycle script
Client-Side Hooks — Your First Line of Defence Before Code Leaves Your Machine
Client-side hooks run on your local machine in response to actions you perform. Think of them as your personal quality gate that catches issues before they ever reach your team or your CI server.
The three most useful client-side hooks are:
pre-commit — fires before Git even looks at your commit message. This is your chance to run linters, formatters, or unit tests against only the staged files. Fail here and no commit is created at all.
commit-msg — fires after you've typed your commit message but before the commit is finalised. This is where you enforce message conventions like Conventional Commits (feat:, fix:, chore:). Teams using automated changelogs depend on this.
pre-push — fires before your local commits are sent to the remote. It's a heavier gate — a good place for running your full test suite when you want to be absolutely sure before sharing your work.
The practical rule of thumb: keep pre-commit fast (under 5 seconds), because developers run it on every single commit. Slower checks belong in pre-push or your CI pipeline. Nobody disables a hook that's instant — they absolutely disable one that makes every commit take 2 minutes.
- git diff --cached --name-only lists only staged files — not untracked or modified files
- --diff-filter=ACM includes Added, Copied, Modified — excludes Deleted files
- grep -E filters to relevant file extensions — skip files your linter does not handle
- Pre-commit under 5 seconds: developers never bypass it. Over 30 seconds: everyone bypasses it.
set -e at the top of a hook script is a critical safety net that most tutorials omit. Without it, a command that fails (non-zero exit) does not stop the script — subsequent commands continue, and the script may reach exit 0 at the end, telling Git the hook passed even though errors occurred. Every production hook script should start with set -e unless you have a specific reason to handle errors manually.--no-verify.Enforcing Commit Message Standards With the commit-msg Hook
Messy commit messages are a silent tax on your team. When you are debugging a regression six months later, a log full of 'wip', 'fix', and 'asdf' is genuinely useless. The commit-msg hook solves this by validating the message before the commit is recorded — no nag emails, no code review comments, just an immediate block with a helpful explanation.
Git passes the commit message as a file path (not the message text directly) to the commit-msg hook. Your first job in the script is always to read that file. This trips up a lot of people.
The example below enforces the Conventional Commits specification, which is the standard behind tools like semantic-release and standard-version. These tools can automatically determine whether a release bumps a major, minor, or patch version — but only if every commit message follows the format. The hook is the enforcement mechanism that makes that automation trustworthy.
- $1 is a file path — the commit message is written to that file by Git
- cat "$1" reads the message. Reading from stdin gives you nothing
- Strip comments (lines starting with #) before validating — Git includes them in the file
- Test your hook: echo 'fix stuff' > /tmp/test-msg && .git/hooks/commit-msg /tmp/test-msg
feat: triggers a minor bump, fix: triggers a patch, BREAKING CHANGE triggers a major bump. Without the commit-msg hook enforcing the format, one non-conventional commit breaks the entire automated release pipeline. The hook is not about aesthetics — it is about automation reliability.cat "$1", never from stdin. Conventional Commits enforcement enables automated changelog and release pipelines. The hook is the enforcement mechanism that makes automation trustworthy.Server-Side Hooks — Enforcement That Cannot Be Bypassed
Client-side hooks are quality-of-life tools. They can always be skipped with git commit --no-verify or git push --no-verify. For rules that must be enforced regardless of what a developer does locally, server-side hooks are the answer.
Server-side hooks run on the Git server when a push is received. The two most important are:
pre-receive — runs once for the entire push. If it exits non-zero, the entire push is rejected. This is the single most powerful hook: it can enforce branch protection, reject force-pushes, require commit signatures, or block pushes to protected branches.
update — runs once per ref being pushed (each branch or tag). It receives the ref name, old SHA, and new SHA as arguments. This is useful for per-branch rules: allow pushes to feature branches but reject direct pushes to main.
Server-side hooks cannot be bypassed by any flag on the client. They are the true enforcement mechanism. The trade-off: they require server access to configure, and a misconfigured server-side hook blocks the entire team.
- pre-receive: runs once per push, rejects the entire push if it exits non-zero
- update: runs once per ref, receives old SHA, new SHA, ref name as arguments
- Server-side hooks require server access to configure — or use platform-level rules (GitHub branch protection, GitLab push rules)
- A misconfigured server-side hook blocks the entire team — test thoroughly before deploying
--no-verify. Critical rules must live on the server. Most teams use platform-level branch protection (GitHub/GitLab) for common rules and server-side hooks only for custom logic.pre-commit Hook Takes 3 Minutes: Entire Team Bypasses It Within a Week
npm test (full test suite) on every commit.
2. The test suite took 3 minutes on a developer's laptop (vs 90 seconds in CI with caching).
3. Developers committing 15-20 times per day lost 45-60 minutes to the hook.
4. Within 3 days, every developer discovered git commit --no-verify (or -n).
5. The --no-verify flag skips ALL client-side hooks — both pre-commit AND commit-msg.
6. Broken code and non-conventional commit messages reached the remote without any local gate.
7. CI caught the failures 20 minutes later, blocking the team — the original problem, now worse.- A hook that takes more than 5 seconds will be bypassed by every developer within a week. Bypassed hooks provide zero protection.
- pre-commit is for fast feedback: lint, format, type-check. pre-push is for heavier checks: full test suite. CI is for comprehensive validation.
- --no-verify skips ALL client-side hooks simultaneously. If developers bypass pre-commit, they also bypass commit-msg enforcement.
- Server-side enforcement (CI checks, branch protection) is the only reliable quality gate. Client-side hooks are convenience, not enforcement.
ls -la .git/hooks/pre-commit. Look for x permission.
2. If missing: chmod +x .git/hooks/pre-commit.
3. Check if the hook has a valid shebang line: head -1 .git/hooks/pre-commit should show #!/usr/bin/env bash or similar.
4. Check if core.hooksPath is set to a different directory: git config core.hooksPath. If set, your hook must be in that directory, not .git/hooks/.COMMIT_MESSAGE=$(cat "$1") is correct. Reading from stdin is wrong.
3. Debug: add echo "DEBUG: $1" at the top of the hook to see the file path Git passes.
4. Also contain trailing newlines or comments. Strip them: COMMIT_MESSAGE=$(head -1 "$1" | sed 's/#.*//').exit 0 at the end even after errors.
2. A common bug: running a command, not capturing its exit code, then calling exit 0 unconditionally.
3. Fix: capture exit code immediately after the command: ESLINT_EXIT=$?. Then if [ $ESLINT_EXIT -ne 0 ]; then exit 1; fi.
4. In bash, use set -e at the top of the script to exit on any command failure automatically.git config core.hooksPath hooks after cloning.
3. Add this to your README under 'Getting Started'.
4. Or add a post-clone setup script: ./setup.sh that runs the config command.
5. For Node.js projects, Husky handles this automatically via the prepare npm lifecycle script.Key takeaways
.git/hooks/ with a recognised filenamegit diff --cached --name-only.git/hooks/ is never cloned or committedgit config core.hooksPath (language-agnostic) or Husky's prepare npm script (Node.js projects).pre-receive are the only truly un-bypassable enforcement--no-verify, so critical rules belong on the server.set -e to ensure command failures stop the script immediately, preventing false-positive hook passes.Interview Questions on This Topic
Frequently Asked Questions
That's Git. Mark it forged?
4 min read · try the examples if you haven't