What Happens Without Git Hooks
I once worked on a team of 8, and every few days someone would push code that turned the CI pipeline red — either because they forgot to run the linter, or because their commit messages were things like fix, test, aaaaa. About 3–4 times per sprint. Having to leave “please reformat this” comments during code review wasted time and created unnecessary friction.
Git hooks solve exactly that — automatically blocking bad code before it enters the repo, no reminders needed.
What Are Git Hooks and Where Do They Live?
Git hooks are just ordinary scripts — bash, Python, Node.js, whatever — that Git calls automatically at specific moments: before a commit, before a push, after a merge… each event has its corresponding hook.
Every Git repo comes with a .git/hooks/ directory containing .sample template files. To activate a hook, create a file with the correct name (drop the .sample extension) and make it executable:
ls .git/hooks/
# applypatch-msg.sample commit-msg.sample pre-commit.sample pre-push.sample ...
# Create a new hook
touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
Client-Side vs Server-Side Hooks
These two groups operate at completely different layers — mix them up and your setup won’t do anything.
Client-Side Hooks — Run on the Developer’s Machine
Run on each developer’s machine, before or after local operations:
- pre-commit: runs before a commit is created — use it for linting, formatting, or running quick unit tests
- commit-msg: runs after the commit message is entered — use it to validate format (Conventional Commits, Jira ticket IDs, etc.)
- pre-push: runs before pushing — use it to run a more complete test suite
- post-commit: runs after a successful commit — typically used for notifications or logging
One drawback to know upfront: .git/hooks/ is not tracked by Git, so every developer must install the hooks manually after cloning. Without a setup script, new team members will skip this step without realizing it.
Server-Side Hooks — Run on the Remote Repo
Run on the server (GitHub Actions isn’t a hook, but GitLab/Gitea self-hosted supports them):
- pre-receive: runs before the server accepts a push — can reject the entire push
- post-receive: runs after a successful push — commonly used for deployment or notifications
- update: similar to pre-receive but runs per-branch
Server-side hooks can’t be bypassed by anyone — developers can’t use --no-verify to get around them. The trade-off is that you need server access. GitHub/GitLab.com don’t allow custom server hooks, so you’d have to rely on CI/CD instead.
Manual Hooks vs Husky — Which Should You Use?
This is the question I get most from junior devs setting things up for the first time: “which one should I use?” The answer really depends on your tech stack more than personal preference.
Manual Hooks: Simple, No Dependencies
Write scripts directly into .git/hooks/, or create a hooks/ directory in the repo with a setup.sh script that symlinks them:
# hooks/setup.sh
#!/bin/bash
cp hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "Git hooks installed!"
Pros: no Node.js required, no extra package dependencies, works with any language (Python, Bash, Ruby…).
Cons: you have to remember to run setup.sh after cloning, which is easy to forget.
Husky: Auto-Installs on npm install
Husky is an npm package that integrates hooks into the package.json lifecycle. When a dev runs npm install, hooks are installed automatically — no extra steps needed.
npm install --save-dev husky
npx husky init
Pros: zero-config for the team, hooks are committed to the repo (.husky/), nobody misses them.
Cons: requires Node.js and npm — adding it to a non-JS project feels like overkill.
My rule of thumb: use Husky for JavaScript/TypeScript projects, and manual hooks with a setup script in a Makefile or README for Python/Go/polyglot projects.
Practical Implementation: The 3 Most Important Hooks
1. pre-commit: Block Bad Code Before It’s Committed
#!/bin/bash
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
# Get list of staged Python files
STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
if [ -n "$STAGED_PY" ]; then
# Run flake8 linter
echo "Linting Python files..."
flake8 $STAGED_PY
if [ $? -ne 0 ]; then
echo "Linting failed. Fix errors before committing."
exit 1
fi
# Run black format check
black --check $STAGED_PY
if [ $? -ne 0 ]; then
echo "Format check failed. Run 'black .' to fix."
exit 1
fi
fi
echo "All checks passed!"
exit 0
This hook only checks staged files, not the entire repo — so it typically finishes in 1–2 seconds and doesn’t noticeably slow down your commit flow.
2. commit-msg: Enforce Commit Message Format
Conventional Commits (feat:, fix:, docs:…) enable auto-generated changelogs and make history easier to read. This hook validates the pattern:
#!/bin/bash
# .git/hooks/commit-msg
MSG_FILE=$1
MSG=$(cat $MSG_FILE)
# Pattern: type(scope): description
# Example: feat(auth): add OAuth2 login
PATTERN='^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}$'
if ! echo "$MSG" | grep -qE "$PATTERN"; then
echo "ERROR: Commit message does not match the required format!"
echo "Expected: feat|fix|docs|style|refactor|test|chore(scope): description"
echo "Example: feat(auth): add OAuth2 login"
echo ""
echo "Your message: $MSG"
exit 1
fi
exit 0
3. pre-push: Run Tests Before Pushing
#!/bin/bash
# .git/hooks/pre-push
BRANCH=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')
# Only run the full test suite when pushing to main or develop
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "develop" ]; then
echo "Running test suite before push to $BRANCH..."
python -m pytest tests/ -q --tb=short
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
fi
exit 0
Setup with Husky + lint-staged (for JS Projects)
npm install --save-dev husky lint-staged
npx husky init
Add to package.json:
{
"lint-staged": {
"*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{css,md}": "prettier --write"
},
"scripts": {
"prepare": "husky"
}
}
File .husky/pre-commit:
#!/bin/sh
npx lint-staged
Tips to Keep Hooks From Becoming a Burden
- Only check staged files, not the whole repo — use
git diff --cached --name-only. If a hook takes more than 5 seconds, developers will find ways to bypass it. - Leave an escape hatch for emergencies:
git commit --no-verify -m "hotfix: emergency". Sometimes you need to push fast and fix later — locking things down 100% tends to backfire more than it helps. - Show clear error messages on failure: don’t just
exit 1, print a specific error and the command to fix it. - Commit the
hooks/directory to the repo along with aninstall-hooks.shscript — so onboarding new team members doesn’t require any extra questions.
After applying pre-commit linting and commit-msg validation to that team, the number of “fix format/lint” comments in PRs dropped from around 4–5 per PR to nearly zero. Reviews shifted almost entirely to logic and architecture. Merge conflicts also became less frequent because code was consistently formatted before merging.
Git hooks don’t replace CI/CD — integration tests and end-to-end tests still belong in the pipeline. But for simple checks that finish in a few seconds, putting them in a client-side hook saves far more time than waiting 5–10 minutes for a pipeline to tell you something’s broken.

