The Problem You Didn’t Know You Had
You just pushed code. Three minutes later, CI fails because you forgot to run tests. Or your code has a syntax error. Or you committed a debug statement. Or worse — a hardcoded password made it into the repo.
Git hooks fix this. They’re scripts that run before (or after) git operations. Locally. On your machine. No CI wait. No red build. Just instant feedback.
Most developers don’t use them. They should.
What Are Git Hooks?
Git hooks live in .git/hooks/ and run automatically when you do a git operation. The main ones you care about:
pre-commit— runs before you commit. Can stop the commit if it fails.commit-msg— validates your commit message (enforce conventional commits, etc.)pre-push— runs before push. Your last chance to catch mistakes.
They’re just shell scripts. Nothing fancy.
The Setup
First, don’t edit files in .git/hooks/ directly — they don’t get committed. Instead, create a hooks/ directory in your repo root and commit it:
mkdir -p hookschmod +x hooks/*Then configure git to use them:
git config core.hooksPath hooksDone. Now .git/hooks/ symlinks to your committed hooks/ directory.
A Practical pre-commit Hook
Here’s what I use. Save as hooks/pre-commit:
#!/bin/bashset -e
# Colors for outputRED='\033[0;31m'GREEN='\033[0;32m'NC='\033[0m'
echo "Running pre-commit checks..."
# Check for hardcoded secretsif git diff --cached | grep -E '(password|api_key|secret|token|AWS_SECRET)' -i; then echo -e "${RED}Error: Potential secret detected in staged changes${NC}" exit 1fi
# No console.log in JavaScriptif git diff --cached -- '*.js' '*.ts' | grep -E '^\+.*console\.(log|debug|error)'; then echo -e "${RED}Error: console.log detected. Remove before commit.${NC}" exit 1fi
# Run tests (example for Node.js)if [ -f package.json ]; then npm run test -- --bail 2>/dev/null || { echo -e "${RED}Tests failed. Fix before committing.${NC}" exit 1 }fi
# Lint staged filesif command -v eslint &> /dev/null; then eslint --fix $(git diff --cached --name-only -- '*.js' '*.ts') 2>/dev/null || truefi
echo -e "${GREEN}Pre-commit checks passed!${NC}"exit 0Make it executable: chmod +x hooks/pre-commit
Now when you run git commit, it’ll:
- Search for hardcoded secrets
- Reject console.log statements
- Run tests
- Lint and auto-fix your code
If any check fails, the commit is blocked. Locally. No CI pain.
A pre-push Hook Example
Paranoia: validate that you’re pushing to the right branch and that tests still pass:
#!/bin/bashset -e
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Don't push to main without a PR (enforce workflow)if [[ "$BRANCH" == "main" ]]; then echo "Cannot push directly to main. Use a PR." exit 1fi
echo "Running pre-push checks on $BRANCH..."
# Quick testnpm run test -- --bail || { echo "Tests failed. Not pushing." exit 1}
echo "All checks passed. Pushing $BRANCH..."exit 0Save as hooks/pre-push and chmod +x it.
Common Hook Pitfalls
Hooks run in a weird environment. No bash aliases, no shell functions, no cd from previous shells. Use absolute paths and full command names.
# Bad—relies on aliasgit_log
# Good—explicit/usr/bin/git logThe —no-verify escape hatch. Anyone can bypass hooks with git commit --no-verify. It’s there for emergencies, not daily use. If people use it constantly, the hook isn’t solving the real problem.
Subshells and exit codes matter. If your hook fails silently, the commit goes through anyway:
# Bad—errors are swallowednpm run test > /dev/null 2>&1
# Good—errors bubble upnpm run test || exit 1A Sample Hook for Python Projects
#!/bin/bashset -e
# Format with blackif command -v black &> /dev/null; then black --quiet $(git diff --cached --name-only -- '*.py')fi
# Check types with mypyif command -v mypy &> /dev/null; then mypy $(git diff --cached --name-only -- '*.py') || { echo "Type check failed." exit 1 }fi
# No debug breakpointsif git diff --cached -- '*.py' | grep -E '^\+.*(pdb|breakpoint|ipdb)'; then echo "Debug statement detected. Remove before commit." exit 1fi
echo "Pre-commit checks passed!"exit 0Why You Should Care
Developer experience: Instant feedback beats waiting 5 minutes for CI.
Cost: Fewer CI runs = fewer credits spent. Fewer builds = faster feedback loops.
Team trust: You stop committing surprises.
Onboarding: New devs run git config core.hooksPath hooks once and inherit all your standards.
Real Talk
Hooks aren’t foolproof — you can always git commit --no-verify to bypass them. Don’t. If you’re tempted, ask yourself why the check exists.
Also: hooks run on your machine. They won’t catch issues that only appear in CI (dependency versions, environment-specific bugs). But they catch 80% of the stupid stuff before it leaves your laptop.
Start with pre-commit. Add pre-push when you’re paranoid. Invest 20 minutes now to save weeks of “why didn’t CI catch this?”
Your future self will thank you. Probably at 2 AM when you’re debugging instead of sleeping.