Git Hooks Explained — Automate, Enforce and Protect Your Codebase
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.
#!/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
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.
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 # 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
---
/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'.
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.
#!/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
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
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.
#!/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."
[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.
| 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.
⚠ 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_MESSAGEis always empty or contains garbage. Fix: Git passes the message as a file path via$1, not via stdin. Always useCOMMIT_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-verifyto 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 inpre-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.
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.