Git Hooks Explained — Automate, Enforce and Protect Your Codebase
- A git hook is just an executable script in
.git/hooks/with a recognised filename — Git's contract with it is purely via exit codes: 0 means success, anything else cancels the operation. - Keep pre-commit hooks fast by linting only staged files using
git diff --cached --name-only— slow hooks get disabled by frustrated developers, which is worse than having no hook. .git/hooks/is never cloned or committed — share hooks across your team usinggit config core.hooksPath(language-agnostic) or Husky'spreparenpm script (Node.js projects).
- 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
Hook not running at all — git commit succeeds silently
ls -la .git/hooks/pre-commit (check for executable permission)head -1 .git/hooks/pre-commit (verify shebang: #!/usr/bin/env bash)commit-msg hook regex never matches — message always rejected or always accepted empty
cat "$1" (read the commit message file Git passes as argument)echo "DEBUG: arg=$1, msg=$(cat "$1")" (add to top of hook for debugging)Entire team bypassing hooks with --no-verify
time .git/hooks/pre-commit (measure how long the hook takes)git diff --cached --name-only | wc -l (check how many files are staged — more files = slower hook)Hook runs but git commit still succeeds despite errors
bash -x .git/hooks/pre-commit (run hook with debug output to trace execution)echo $? (check exit code after the hook runs)Shared hooks not working after teammate clones the repo
git config core.hooksPath (check if hooks path is configured)ls -la hooks/ (verify the shared hooks directory exists and is committed)Production Incident
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.Production Debug GuideSystematic recovery paths for hook execution failures, bypass detection, and sharing issues.
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.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.
#!/usr/bin/env bash # io.thecodeforge — Explore Git Hooks Directory # Step 1: Initialise a fresh repo so we can explore its structure git init my-demo-project cd my-demo-project # Step 2: List the hooks directory — every repo has this out of the box ls -la .git/hooks # Step 3: Look at one of the sample files to understand the structure # Git ships these as *.sample so they don't accidentally run cat .git/hooks/pre-commit.sample # Step 4: See all available hook trigger points ls .git/hooks/*.sample | sed 's/.sample$//' | xargs -I {} basename {} # Output lists every hook Git supports: # applypatch-msg, commit-msg, post-update, pre-applypatch, # pre-commit, pre-push, pre-rebase, pre-receive, # prepare-commit-msg, update
total 56
drwxr-xr-x 2 user user 4096 Jun 10 09:00 .
drwxr-xr-x 8 user user 4096 Jun 10 09:00 ..
-rwxr-xr-x 1 user user 478 Jun 10 09:00 applypatch-msg.sample
-rwxr-xr-x 1 user user 896 Jun 10 09:00 commit-msg.sample
-rwxr-xr-x 1 user user 189 Jun 10 09:00 post-update.sample
-rwxr-xr-x 1 user user 424 Jun 10 09:00 pre-applypatch.sample
-rwxr-xr-x 1 user user 1642 Jun 10 09:00 pre-commit.sample
-rwxr-xr-x 1 user user 1348 Jun 10 09:00 pre-push.sample
-rwxr-xr-x 1 user user 4898 Jun 10 09:00 pre-rebase.sample
-rwxr-xr-x 1 user user 544 Jun 10 09:00 pre-receive.sample
-rwxr-xr-x 1 user user 1239 Jun 10 09:00 prepare-commit-msg.sample
-rwxr-xr-x 1 user user 3610 Jun 10 09:00 update.sample
.git/ directory is never tracked by git — so your hooks stay on your machine only. If you add a pre-commit hook locally, your teammates won't have it after cloning. Share hooks using core.hooksPath (language-agnostic) or Husky (Node.js projects).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.
#!/usr/bin/env bash # io.thecodeforge — pre-commit hook: runs ESLint on staged JS/TS files only # so developers get instant feedback without linting the whole project. # # HOW TO INSTALL: # cp this file to .git/hooks/pre-commit # chmod +x .git/hooks/pre-commit # OR: use core.hooksPath to share with team set -e # Exit immediately on any command failure # Collect only the JS and TS files that are staged (index), not all tracked files. # --diff-filter=ACM means: Added, Copied, or Modified — skips Deleted files. STAGED_JS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|jsx|tsx)$' || true) # If no relevant files are staged, there's nothing to lint — exit cleanly. if [ -z "$STAGED_JS_FILES" ]; then echo "pre-commit: No JS/TS files staged. Skipping lint check." exit 0 # Exit code 0 = success, Git continues with the commit fi echo "pre-commit: Running ESLint on staged files..." echo "---" # Run ESLint only against the files we collected above. npx eslint $STAGED_JS_FILES echo "---" echo "pre-commit: All checks passed. Proceeding with commit." exit 0
---
/home/user/project/src/utils/formatDate.ts
12:5 error 'unusedVar' is assigned a value but never used no-unused-vars
27:1 error Expected indentation of 2 spaces but found 4 indent
2 problems (2 errors, 0 warnings)
---
pre-commit: ESLint found errors. Commit aborted.
Fix the issues above, then stage your changes again with 'git add'.
- 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.
#!/usr/bin/env bash # io.thecodeforge — commit-msg hook: enforces Conventional Commits # Format: <type>(<optional scope>): <description> # Examples: # feat(auth): add OAuth2 login support # fix: prevent crash when user list is empty # chore(deps): update lodash to 4.17.21 set -e # Exit immediately on any command failure # Git passes the PATH to the temporary file containing the commit message # as the first argument ($1). We must read from that file — not from stdin. COMMIT_MESSAGE_FILE="$1" COMMIT_MESSAGE=$(cat "$COMMIT_MESSAGE_FILE") # Strip leading/trailing whitespace and comments (lines starting with #) COMMIT_MESSAGE_FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -1 | sed 's/#.*//' | xargs) # Define the Conventional Commits regex pattern. # Allowed types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert # The scope (in parentheses) is optional. # The description must be at least 10 characters to avoid messages like "fix: stuff" CONVENTIONAL_COMMIT_REGEX="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\([a-zA-Z0-9_-]+\))?: .{10,}$" # Test the commit message against our pattern. if ! echo "$COMMIT_MESSAGE_FIRST_LINE" | grep -qE "$CONVENTIONAL_COMMIT_REGEX"; then echo "" echo " COMMIT REJECTED — Message does not follow Conventional Commits format." echo "" echo " Your message: \"$COMMIT_MESSAGE_FIRST_LINE\"" echo "" echo " Required format:" echo " <type>(<optional scope>): <description — at least 10 characters>" echo "" echo " Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert" echo "" echo " Examples:" echo " feat(payments): add Stripe webhook handler" echo " fix: resolve null pointer in user session lookup" echo " chore: update Node.js to v20 in CI workflow" echo "" exit 1 # Reject the commit — Git discards it entirely fi # If we reach here, the message is valid. echo " Commit message format validated." exit 0
Your message: "fix stuff"
Required format:
<type>(<optional scope>): <description — at least 10 characters>
Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert
Examples:
feat(payments): add Stripe webhook handler
fix: resolve null pointer in user session lookup
chore: update Node.js to v20 in CI workflow
- $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.
#!/usr/bin/env bash # io.thecodeforge — pre-receive hook: enforce branch protection on the server # This hook runs ONCE for the entire push. Reject any non-zero exit. # # INSTALLATION: place in the server's .git/hooks/pre-receive # or configure via Git hosting platform (GitHub branch protection, GitLab push rules) set -e # Read each ref being pushed from stdin # Format: <old-sha> <new-sha> <ref-name> while read OLD_SHA NEW_SHA REF_NAME; do # ───────────────────────────────────────────── # RULE 1: Reject direct pushes to main # ───────────────────────────────────────────── if [ "$REF_NAME" = "refs/heads/main" ]; then echo "ERROR: Direct pushes to main are not allowed." echo "Please create a pull request instead." exit 1 fi # ───────────────────────────────────────────── # RULE 2: Reject force-pushes (non-fast-forward) # ───────────────────────────────────────────── # A force-push is when the new commit is not a descendant of the old commit # $OLD_SHA is all zeros for new branches — skip the check in that case if [ "$OLD_SHA" != "0000000000000000000000000000000000000000" ]; then if ! git merge-base --is-ancestor "$OLD_SHA" "$NEW_SHA" 2>/dev/null; then echo "ERROR: Force-push to $REF_NAME rejected." echo "Use 'git revert' instead of 'git push --force'." exit 1 fi fi # ───────────────────────────────────────────── # RULE 3: Enforce branch naming convention # ───────────────────────────────────────────── # Branches must start with: feature/, fix/, hotfix/, release/, chore/ if ! echo "$REF_NAME" | grep -qE '^refs/heads/(feature|fix|hotfix|release|chore)/'; then # Allow main, develop, and staging if ! echo "$REF_NAME" | grep -qE '^refs/heads/(main|develop|staging)$'; then echo "ERROR: Branch name '$REF_NAME' does not follow naming convention." echo "Allowed prefixes: feature/, fix/, hotfix/, release/, chore/" echo "Allowed exact names: main, develop, staging" exit 1 fi fi done # If we reach here, all rules passed. echo "pre-receive: All checks passed. Push accepted." exit 0
ERROR: Direct pushes to main are not allowed.
Please create a pull request instead.
To github.com:io/thecodeforge/payments-service.git
! [remote rejected] main -> main (pre-receive hook declined)
# Force-pushing to a feature branch:
ERROR: Force-push to refs/heads/feature/payment-retry rejected.
Use 'git revert' instead of 'git push --force'.
# Pushing a branch with bad name:
ERROR: Branch name 'refs/heads/my-experiment' does not follow naming convention.
Allowed prefixes: feature/, fix/, hotfix/, release/, chore/
- 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.| Aspect | client-side hooks (pre-commit, commit-msg) | server-side hooks (pre-receive, update) |
|---|---|---|
| Where they run | On the developer's local machine | On the Git server (GitHub Actions, GitLab, Gitea, etc.) |
| Who controls them | Individual developer (or shared via core.hooksPath / Husky) | Repository/platform administrator |
| Can they be bypassed? | Yes — git commit --no-verify skips them | No — the server enforces them regardless of client |
| Typical use case | Linting, formatting, commit message validation | Enforcing branch protection, triggering CI/CD, rejecting force-pushes |
| Setup complexity | Low — shell script + chmod | Higher — requires server access or platform configuration |
| Failure impact | Only affects the single developer's workflow | Blocks the push for the entire team if misconfigured |
| Shared automatically on clone? | No — requires Husky or core.hooksPath setup | Yes — lives on the server, applies to all contributors |
🎯 Key Takeaways
- A git hook is just an executable script in
.git/hooks/with a recognised filename — Git's contract with it is purely via exit codes: 0 means success, anything else cancels the operation. - Keep pre-commit hooks fast by linting only staged files using
git diff --cached --name-only— slow hooks get disabled by frustrated developers, which is worse than having no hook. .git/hooks/is never cloned or committed — share hooks across your team usinggit config core.hooksPath(language-agnostic) or Husky'spreparenpm script (Node.js projects).- Server-side hooks like
pre-receiveare the only truly un-bypassable enforcement — client-side hooks can always be skipped with--no-verify, so critical rules belong on the server. - Every hook script should start with
set -eto ensure command failures stop the script immediately, preventing false-positive hook passes.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QCan you explain the difference between client-side and server-side git hooks, and give an example of when you'd use each?
- QIf a developer on your team runs
git commit --no-verify, what happens to your pre-commit hook, and how would you enforce the same checks so they can't be bypassed? - QIn a commit-msg hook, how does the script actually access the commit message that the developer typed — and why does getting this wrong cause the hook to silently fail?
- QYour pre-commit hook runs the full test suite and takes 3 minutes. After a week, every developer is using --no-verify. How do you redesign the hook strategy?
- QHow would you share git hooks across a team? Compare core.hooksPath and Husky in terms of setup, automation, and language compatibility.
Frequently Asked Questions
How do I bypass a git hook when I really need to commit broken code?
Run git commit --no-verify (or -n for short) to skip all client-side hooks for that single commit. Use this sparingly — it's useful for WIP commits on a personal branch, but bypassing hooks on shared branches defeats the team's quality gates. Server-side hooks cannot be bypassed this way.
Can I write a git hook in Python or Node.js instead of bash?
Absolutely. The only requirements are that the file is executable and starts with a valid shebang line pointing to the interpreter — for example #!/usr/bin/env python3 or #!/usr/bin/env node. The rest of the script can use any language available on the developer's machine.
What's the difference between pre-commit and pre-push — when should I use each?
Use pre-commit for fast, cheap checks that give instant feedback on every commit — linting and formatting are ideal. Use pre-push for slower, more comprehensive checks like running your full test suite, because it only fires when you push, not on every local commit. This balance keeps pre-commit fast enough that nobody wants to skip it.
Can I undo a git hook that went wrong?
If a pre-commit hook blocks a valid commit, you can bypass it once with git commit --no-verify. If a commit-msg hook rejects a message, fix the message format and try again. If a pre-push hook blocks a push, bypass with git push --no-verify (use sparingly). To permanently remove a hook: delete the file from .git/hooks/ or the shared hooks directory.
How do I test a git hook without triggering a real commit or push?
For commit-msg: echo 'test message' > /tmp/test-msg && .git/hooks/commit-msg /tmp/test-msg. For pre-commit: run it directly with bash -x .git/hooks/pre-commit to see debug output. For pre-receive: create a test repository and push to it to trigger the hook.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.