Skip to content
Home DevOps Git Hooks Explained — Automate, Enforce and Protect Your Codebase

Git Hooks Explained — Automate, Enforce and Protect Your Codebase

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Git → Topic 9 of 19
Git hooks explained for intermediate developers: what they are, when to use them, real-world pre-commit and post-merge examples, and common mistakes to avoid.
⚙️ Intermediate — basic DevOps knowledge assumed
In this tutorial, you'll learn
Git hooks explained for intermediate developers: what they are, when to use them, real-world pre-commit and post-merge examples, and common mistakes to avoid.
  • 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 using git config core.hooksPath (language-agnostic) or Husky's prepare npm script (Node.js projects).
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE
Git Hooks Triage Cheat Sheet
Fast recovery for hook execution failures, bypass detection, and sharing issues.
🟡Hook not running at all — git commit succeeds silently
Immediate ActionCheck file permissions and shebang line.
Commands
ls -la .git/hooks/pre-commit (check for executable permission)
head -1 .git/hooks/pre-commit (verify shebang: #!/usr/bin/env bash)
Fix Nowchmod +x .git/hooks/pre-commit. If using core.hooksPath: check git config core.hooksPath points to the right directory.
🟡commit-msg hook regex never matches — message always rejected or always accepted empty
Immediate ActionVerify you are reading from $1 (file path), not stdin.
Commands
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)
Fix NowCOMMIT_MESSAGE=$(cat "$1") is correct. Never read from stdin in commit-msg.
🟡Entire team bypassing hooks with --no-verify
Immediate ActionHooks are too slow. Audit execution time and move heavy checks to pre-push or CI.
Commands
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)
Fix Nowpre-commit under 5 seconds: lint staged files only. Full tests go to pre-push or CI.
🟡Hook runs but git commit still succeeds despite errors
Immediate ActionCheck if the hook exits with non-zero code on failure.
Commands
bash -x .git/hooks/pre-commit (run hook with debug output to trace execution)
echo $? (check exit code after the hook runs)
Fix NowAdd 'set -e' at top of script. Or explicitly: if [ $? -ne 0 ]; then exit 1; fi after each command.
🟡Shared hooks not working after teammate clones the repo
Immediate Actioncore.hooksPath is a local config — it does not transfer on clone.
Commands
git config core.hooksPath (check if hooks path is configured)
ls -la hooks/ (verify the shared hooks directory exists and is committed)
Fix NowRun: git config core.hooksPath hooks. Add to README: 'Run ./setup.sh after cloning.'
Production Incidentpre-commit Hook Takes 3 Minutes: Entire Team Bypasses It Within a WeekA team added a pre-commit hook that ran the full test suite (3 minutes) on every commit. Within a week, every developer was using `git commit --no-verify` habitually. Broken code reached the remote daily. The hook provided zero protection.
SymptomCI pipeline failures increased from 2 per week to 12 per week after the pre-commit hook was introduced. Developers reported that commits were taking too long. Commit velocity dropped 40%. The git log showed commit messages with no conventional format enforcement — the commit-msg hook was also being bypassed.
AssumptionThe team lead assumed that running the full test suite on every commit would catch bugs earlier and reduce CI failures. They did not consider the developer experience cost of a 3-minute commit. They assumed developers would wait because 'it is for quality.'
Root cause1. The pre-commit hook ran 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.
Fix1. Immediate: removed the full test suite from pre-commit. Replaced with ESLint (5 seconds) and Prettier formatting check (2 seconds). 2. Moved the full test suite to pre-push: runs before code is shared, but not on every local commit. 3. Added commit-msg hook back (instant — regex check). Developers stopped bypassing because it added zero latency. 4. Documented the principle in team wiki: 'pre-commit under 5 seconds, pre-push under 30 seconds, full tests in CI.' 5. Added a CI check that runs on every PR: if pre-commit was bypassed (detectable by lint errors in committed code), the PR fails.
Key Lesson
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.
Production Debug GuideSystematic recovery paths for hook execution failures, bypass detection, and sharing issues.
Hook is not running at all — git commit succeeds silently without any hook execution1. Check if the hook file is executable: 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-msg hook never matches — regex validation always fails or always passes empty1. The most common bug: reading commit message from stdin instead of from the file at $1. 2. Check your script: 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/#.*//').
Developers bypassing hooks check: the file at $1 may with --no-verify — hooks not providing protection1. You cannot prevent --no-verify on client-side hooks. This is by design. 2. Move critical enforcement to server-side hooks or CI checks. 3. Detect bypass in CI: run the same lint/format checks in the CI pipeline. If the code fails CI lint, the developer bypassed pre-commit. 4. Consider a pre-push hook as a middle ground: harder to bypass than pre-commit (developers push less frequently than they commit).
Hook runs but exits with wrong code — git continues despite errors1. Check if the hook explicitly calls 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.
Shared hooks via core.hooksPath not working after clone1. core.hooksPath is a local git config — it does not transfer on clone. 2. Every developer must run 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.

io/thecodeforge/git/ExploreHooks.sh · BASH
1234567891011121314151617181920
#!/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
▶ Output
Initialized empty Git repository in /home/user/my-demo-project/.git/

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
⚠ Hooks Don't Transfer When You Clone
The .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).
📊 Production Insight
The sample hook files in .git/hooks/ are Git's documentation in executable form. Every team member should read pre-commit.sample and pre-push.sample at least once — they contain comments explaining when the hook runs, what arguments Git passes, and what exit codes mean. The samples are more useful than any blog post because they are the exact contract Git expects.
🎯 Key Takeaway
A hook is an executable script in .git/hooks/ with a recognised filename. Git's contract is purely via exit codes: 0 means success, non-zero cancels the operation. Hooks are never shared on clone — use core.hooksPath or Husky to distribute them to the team.

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.

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.

io/thecodeforge/git/hooks/pre-commit · BASH
123456789101112131415161718192021222324252627282930
#!/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
▶ Output
pre-commit: Running ESLint on staged files...
---

/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'.
💡Lint Only What Is Staged
  • 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.
📊 Production Insight
The 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.
🎯 Key Takeaway
Client-side hooks run locally before code leaves your machine. pre-commit is for fast checks (lint, format) — keep it under 5 seconds. commit-msg enforces message conventions. pre-push runs heavier checks (full tests). All client-side hooks can be bypassed with --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.

io/thecodeforge/git/hooks/commit-msg · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
#!/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
▶ Output
COMMIT REJECTED — Message does not follow Conventional Commits format.

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
Mental Model
The $1 Argument in commit-msg
Git passes a file path, not the message text. Read from the file, never from stdin.
  • $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
📊 Production Insight
Conventional Commits enforcement via commit-msg hooks is the foundation of automated release pipelines. Tools like semantic-release and standard-version parse commit messages to determine version bumps: 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.
🎯 Key Takeaway
commit-msg hooks enforce commit message conventions before the commit is recorded. Git passes the message as a file path via $1 — read it with 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.

io/thecodeforge/git/hooks/pre-receive · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
#!/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
▶ Output
# Pushing directly to main:
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/
Mental Model
Client-Side Hooks Are Convenience. Server-Side Hooks Are Enforcement.
If it can be bypassed with --no-verify, it is not enforcement. It is convenience.
  • 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
📊 Production Insight
Most teams use GitHub branch protection rules or GitLab push rules instead of raw server-side hooks. These platforms provide a GUI for common enforcement: require PR reviews, block force-pushes, require signed commits, restrict who can push to main. The trade-off: platform rules are less flexible than custom scripts. If you need custom logic (enforce branch naming, reject commits with certain file patterns, require specific CI checks to pass), you need actual server-side hooks or CI-based enforcement.
🎯 Key Takeaway
Server-side hooks (pre-receive, update) are the only truly un-bypassable enforcement. Client-side hooks can always be skipped with --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.
🗂 Client-Side vs Server-Side Hooks
Choose based on whether the rule must be enforceable or is just convenient.
Aspectclient-side hooks (pre-commit, commit-msg)server-side hooks (pre-receive, update)
Where they runOn the developer's local machineOn the Git server (GitHub Actions, GitLab, Gitea, etc.)
Who controls themIndividual developer (or shared via core.hooksPath / Husky)Repository/platform administrator
Can they be bypassed?Yes — git commit --no-verify skips themNo — the server enforces them regardless of client
Typical use caseLinting, formatting, commit message validationEnforcing branch protection, triggering CI/CD, rejecting force-pushes
Setup complexityLow — shell script + chmodHigher — requires server access or platform configuration
Failure impactOnly affects the single developer's workflowBlocks the push for the entire team if misconfigured
Shared automatically on clone?No — requires Husky or core.hooksPath setupYes — 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 using git config core.hooksPath (language-agnostic) or Husky's prepare npm script (Node.js projects).
  • Server-side hooks like pre-receive are 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 -e to ensure command failures stop the script immediately, preventing false-positive hook passes.

⚠ Common Mistakes to Avoid

    Forgetting to make the hook executable
    Symptom

    you create the hook file, but git silently ignores it and commits anyway with no errors.

    Fix

    run chmod +x .git/hooks/pre-commit (or whatever hook you created). Git checks the execute permission bit before running any hook — a non-executable file is treated as if it doesn't exist.

    Reading the commit message from stdin in commit-msg
    Symptom

    your regex check never matches because $COMMIT_MESSAGE is always empty or contains garbage.

    Fix

    Git passes the message as a file path via $1, not via stdin. Always use COMMIT_MESSAGE=$(cat "$1") to read it. This is the single most common commit-msg hook bug.

    Putting slow operations in pre-commit
    Symptom

    developers start running git commit --no-verify to skip the hook, which defeats the entire purpose.

    Fix

    keep pre-commit under 5 seconds by linting only staged files (git diff --cached --name-only) and running unit tests only in pre-push. Reserve full test suite execution for CI. A hook developers bypass is worse than no hook at all.

    Not using set -e in hook scripts
    Symptom

    a command in the hook fails but the script continues and exits 0, telling Git the hook passed.

    Fix

    add set -e at the top of every hook script so any command failure stops the script immediately with a non-zero exit code.

    Relying on client-side hooks for critical enforcement
    Symptom

    developers bypass with --no-verify and broken code reaches the remote.

    Fix

    client-side hooks are convenience, not enforcement. Move critical rules to server-side hooks or CI checks.

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.

🔥
Naren Founder & Author

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.

← PreviousResolving Git Merge ConflictsNext →Git Clone: Clone a Repository Step by Step
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged