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

Git Hooks Explained — Automate, Enforce and Protect Your Codebase

In Plain English 🔥
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.
⚡ Quick Answer
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.

Every team has that one recurring problem: someone commits broken code at 5pm on a Friday, the CI pipeline catches it twenty minutes later, and now the whole team is blocked. Or the commit message says 'fix stuff' for the third day in a row, making the git log useless for anyone trying to trace a bug six months down the line. These aren't failures of skill — they're failures of process. Git hooks exist to take these human-error moments off the table entirely by automating enforcement at the exact point where mistakes get introduced: the git command itself.

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.

explore-hooks-directory.sh · BASH
123456789101112
#!/usr/bin/env bash

# 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
▶ 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

#!/bin/sh
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
⚠️
Watch Out: Hooks Don't Transfer When You CloneThe `.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. We'll cover how to share hooks properly using Husky or a custom hooks path later in this article.

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/hooks/pre-commit · BASH
1234567891011121314151617181920212223242526272829303132333435363738
#!/usr/bin/env bash
# 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

# 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)$')

# 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.
# --no-eslintrc is omitted intentionally — we want the project config to apply.
npx eslint $STAGED_JS_FILES

# Capture ESLint's exit code immediately after it runs.
ESLINT_EXIT_CODE=$?

if [ $ESLINT_EXIT_CODE -ne 0 ]; then
  echo "---"
  echo "pre-commit: ESLint found errors. Commit aborted."
  echo "Fix the issues above, then stage your changes again with 'git add'."
  exit 1  # Non-zero exit code = failure, Git CANCELS the commit
fi

echo "---"
echo "pre-commit: All checks passed. Proceeding with commit."
exit 0  # Explicit success — Git creates the commit
▶ 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'.
⚠️
Pro Tip: Lint Only What's StagedNotice the script uses `git diff --cached --name-only` instead of linting every file in the project. 'Cached' means 'the staging area' — so you only check what's actually going into this commit. This keeps the hook fast even in large codebases and avoids failing a commit because of unrelated files the developer hasn't touched.

Enforcing Commit Message Standards With the commit-msg Hook

Messy commit messages are a silent tax on your team. When you're 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 — see the Gotchas section.

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.

.git/hooks/commit-msg · BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243
#!/usr/bin/env bash
# commit-msg hook: enforces the Conventional Commits specification.
# 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

# 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")

# 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.
# The -E flag enables extended regex in grep.
if ! echo "$COMMIT_MESSAGE" | grep -qE "$CONVENTIONAL_COMMIT_REGEX"; then
  echo ""
  echo "  ✖ COMMIT REJECTED — Message does not follow Conventional Commits format."
  echo ""
  echo "  Your message: \"$COMMIT_MESSAGE\""
  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
🔥
Interview Gold: The $1 Argument in commit-msgInterviewers love asking how `commit-msg` receives the message. The answer is: Git writes the message to a temporary file and passes the file path as `$1`. You read it with `cat "$1"` — you never read from stdin or a git command. Knowing this tells an interviewer you've actually written one, not just read about it.

Sharing Hooks Across Your Team — The Real-World Problem and Two Solutions

Here's the painful reality that makes hooks feel like a waste of time for most teams: .git/hooks/ is local-only. It's never committed, never pushed, never cloned. So if you spend an hour writing the perfect pre-commit hook and a new developer joins your team tomorrow, they have zero protection. Your hooks exist only on your machine.

Two practical solutions dominate the industry:

Solution 1 — Git's core.hooksPath config: Git lets you point it to any directory as the hooks directory. You can create a /hooks folder at your project root, commit it, and then configure Git to use it. Each team member runs one setup command and they're protected.

Solution 2 — Husky (for JavaScript/Node projects): Husky is the de-facto standard in the JavaScript ecosystem. It manages hooks as config in package.json, installs them automatically via the prepare npm lifecycle script, and integrates perfectly with lint-staged to lint only staged files. For non-Node projects, core.hooksPath is the cleaner choice.

Both approaches solve the same problem — making hooks a shared team contract rather than a personal preference. The key is picking one and documenting it in your README so every developer runs the setup step after cloning.

team-hooks-setup.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
#!/usr/bin/env bash
# This script demonstrates BOTH approaches to sharing Git hooks with a team.
# Pick ONE for your project — don't use both simultaneously.

# ─────────────────────────────────────────────
# APPROACH 1: Git's core.hooksPath (language-agnostic)
# ─────────────────────────────────────────────

# Create a 'hooks' directory at the project root (this WILL be committed to git)
mkdir -p hooks

# Copy your hook scripts into this directory
# (We'll write a simple pre-commit as an example)
cat > hooks/pre-commit << 'EOF'
#!/usr/bin/env bash
echo "[shared hook] Running pre-commit checks..."
# Add your lint/test commands here
exit 0
EOF

# Make the hook executable — git won't run non-executable scripts
chmod +x hooks/pre-commit

# Tell git to look in our committed 'hooks' folder instead of .git/hooks
# Each developer runs this ONCE after cloning the repo
git config core.hooksPath hooks

echo "Git hooks path set to: $(git config core.hooksPath)"

# Commit the hooks directory so the team gets it
git add hooks/
git commit -m "chore: add shared git hooks via core.hooksPath"

# ─────────────────────────────────────────────
# APPROACH 2: Husky (JavaScript / Node.js projects)
# ─────────────────────────────────────────────

# Install husky as a dev dependency
npm install --save-dev husky

# Initialise husky — creates .husky/ directory and adds 'prepare' script to package.json
# The 'prepare' script runs automatically on 'npm install', so every developer
# who clones the repo and runs 'npm install' gets the hooks automatically.
npx husky init

# Create a pre-commit hook via husky
echo "npx lint-staged" > .husky/pre-commit

# Husky hooks are real files in .husky/ — commit them
git add .husky/
git commit -m "chore: configure husky pre-commit hook with lint-staged"

echo ""
echo "Setup complete. Every developer who runs 'npm install' will now have the hooks active."
▶ Output
Git hooks path set to: hooks
[main (root-commit) a3f9c12] chore: add shared git hooks via core.hooksPath
1 file changed, 4 insertions(+)
create mode 100755 hooks/pre-commit

added 1 package, and audited 2 packages in 1.2s
husky - Git hooks installed

[main b7d1e04] chore: configure husky pre-commit hook with lint-staged
2 files changed, 5 insertions(+)
create mode 100755 .husky/pre-commit

Setup complete. Every developer who runs 'npm install' will now have the hooks active.
⚠️
Pro Tip: Document the Setup Step in Your READMEEven with Husky's automatic `prepare` script, add a 'Getting Started' section to your README that says 'run `npm install` to activate git hooks.' New developers don't always read `package.json` before asking 'why isn't linting running for me?' — one line in the README prevents that question entirely.
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.

⚠ Common Mistakes to Avoid

  • Mistake 1: 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.
  • Mistake 2: 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.
  • Mistake 3: 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.

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?

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.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousLinux System Performance TuningNext →ArgoCD for GitOps
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged