Difficulty: Intermediate
What are Git hooks? How do you use them to enforce code quality in a team?
Git hooks are scripts that Git executes automatically before or after specific events like commit, push, or merge. They live in the .git/hooks/ directory and can be written in any scripting language (bash, Python, Node.js, etc.).
Common client-side hooks: - pre-commit: Runs before a commit is created. Used for linting, formatting, and running tests. - commit-msg: Validates commit message format (e.g., Conventional Commits). - pre-push: Runs before pushing. Used for running full test suites. - prepare-commit-msg: Pre-populates commit messages.
Server-side hooks: - pre-receive: Runs on the server before accepting a push. Can enforce branch protection. - post-receive: Triggers after push is accepted. Used for deployment or notifications.
The challenge with Git hooks is that they are not version-controlled (they live in .git/). Tools like Husky solve this by letting you define hooks in package.json or config files that are committed to the repo. Combined with lint-staged, you can run linters only on staged files for speed.
# Install Husky and lint-staged
npm install --save-dev husky lint-staged
# Initialize Husky
npx husky init
# Creates .husky/ directory with a pre-commit hook
# Edit .husky/pre-commit
#!/bin/sh
npx lint-staged
# Add lint-staged config to package.json
# package.json:
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,json,md}": [
"prettier --write"
]
}
}
# Now on every commit:
# 1. Only staged files are checked (fast!)
# 2. ESLint fixes auto-fixable issues
# 3. Prettier formats the code
# 4. Fixed files are re-staged automatically
# 5. If any errors remain, commit is blocked
Husky makes hooks version-controlled. lint-staged runs linters only on staged files, keeping pre-commit fast even in large projects.
# .husky/commit-msg - enforce Conventional Commits
#!/bin/sh
commit_msg=$(cat "$1")
pattern="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}quot;
if ! echo "$commit_msg" | grep -qE "$pattern"; then
echo "ERROR: Commit message must follow Conventional Commits format"
echo "Example: feat(auth): add login endpoint"
echo "Types: feat, fix, docs, style, refactor, test, chore"
exit 1
fi
# .husky/pre-push - run tests before pushing
#!/bin/sh
echo "Running tests before push..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
# Native Git hook (without Husky)
# Create: .git/hooks/pre-commit
#!/bin/bash
echo "Running pre-commit checks..."
npm run lint
npm run typecheck
# Make executable: chmod +x .git/hooks/pre-commit
commit-msg hooks enforce message conventions. pre-push hooks catch issues before they reach the remote. Native hooks work but are not shared with the team.
# Prevent committing to main directly
# .husky/pre-commit
#!/bin/sh
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
echo "ERROR: Direct commits to $branch are not allowed."
echo "Create a feature branch and submit a PR."
exit 1
fi
npx lint-staged
# Check for debug statements
#!/bin/sh
if git diff --cached --diff-filter=ACM | grep -n 'console\.log\|debugger\|TODO'; then
echo "WARNING: Found debug statements in staged files."
echo "Remove them or use --no-verify to bypass."
exit 1
fi
# Skip hooks when needed (use sparingly)
git commit --no-verify -m "hotfix: emergency patch"
git push --no-verify
# List available hook types
ls .git/hooks/
# applypatch-msg pre-applypatch pre-commit
# commit-msg pre-merge-commit pre-push
# post-update pre-rebase prepare-commit-msg
Branch protection in hooks prevents accidental commits to main. Debug statement detection catches common mistakes. Use --no-verify only for genuine emergencies.
Git Hooks, pre-commit, pre-push, Husky, lint-staged, Automation