Git Hooks

Difficulty: Intermediate

Question

What are Git hooks? How do you use them to enforce code quality in a team?

Answer

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.

Code examples

Setting Up Husky + lint-staged

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

Custom Git Hooks

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

Advanced Hook Patterns

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

Key points

Concepts covered

Git Hooks, pre-commit, pre-push, Husky, lint-staged, Automation