Part of Git Workflow Series

This post is part of our Git workflow series. For the complete branching strategy and team workflow, see Git Branching Strategy for Small Teams.

Why Use Git Hooks?

Remember that time you committed code with a console.log, pushed it, and CI failed? Or forgot to run tests locally and broke the build? Or wrote a commit message like “fix stuff” and your team lead gave you that look?

Git hooks prevent all of that. They’re automated scripts that run at specific points in your Git workflow - before commits, before pushes, after merges, etc. Think of them as your personal code review bouncer that catches mistakes before they embarrass you.

What hooks actually prevent:

  1. Bad code hitting the repo

    • Linting errors (trailing whitespace, unused imports, formatting issues)
    • Failing tests (unit tests, integration tests)
    • Security issues (hardcoded secrets, vulnerable dependencies)
    • Build failures (syntax errors, type errors)
  2. Messy commit history

    • Invalid commit message formats (fix stufffix: resolve login timeout)
    • Missing issue references (enforces feat: add dashboard (#123))
    • Commits to protected branches (prevents direct commits to main)
  3. Wasted CI/CD minutes

    • If hooks catch issues locally, CI doesn’t even run
    • Saves time, money, and embarrassment
    • Faster feedback loop (seconds vs minutes)
  4. Team inconsistency

    • Everyone runs the same checks automatically
    • No “it works on my machine” when you skipped linting
    • Enforces team standards without manual reminders

The hooks we actually use:

HookWhen It RunsWhat It ChecksSkip It?
pre-commitBefore commit is savedLinting, formatting, testsgit commit --no-verify
commit-msgAfter commit message writtenMessage format, lengthgit commit --no-verify
pre-pushBefore pushing to remoteFull test suite, buildgit push --no-verify

Pro tip: You can skip hooks with --no-verify, but only do it if you know what you’re doing. Like when fixing a typo in documentation and you don’t want to wait for the full test suite.

Here’s how to set them up:

Pre-commit Hook

Automatically check code quality before commits:

#!/bin/bash
# .git/hooks/pre-commit

echo "Running pre-commit checks..."

# 1. Lint code
npm run lint
if [ $? -ne 0 ]; then
    echo "❌ Linting failed. Please fix errors before committing."
    exit 1
fi

# 2. Run tests
npm test
if [ $? -ne 0 ]; then
    echo "❌ Tests failed. Please fix failing tests before committing."
    exit 1
fi

# 3. Check commit message format (if using commitlint)
npm run commitlint --edit $1
if [ $? -ne 0 ]; then
    echo "❌ Commit message doesn't follow convention."
    exit 1
fi

echo "✅ Pre-commit checks passed!"
exit 0

Commit Message Hook

Validate commit message format:

#!/bin/bash
# .git/hooks/commit-msg

commit_msg=$(cat "$1")

# Check format: <type>(<scope>): <subject>
if ! echo "$commit_msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|build)(\(.+\))?: .{1,50}"; then
    echo "❌ Invalid commit message format!"
    echo ""
    echo "Format: <type>(<scope>): <subject>"
    echo ""
    echo "Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build"
    echo ""
    echo "Example: feat(auth): add JWT authentication"
    exit 1
fi

echo "✅ Commit message format is valid!"
exit 0

Using Husky for Git Hooks

Instead of manually creating hook files in .git/hooks/, use Husky for easier hook management across your team:

# Install husky
npm install --save-dev husky

# Initialize husky
npx husky install

# Add pre-commit hook
npx husky add .husky/pre-commit "npm test"

# Add commit-msg hook
npx husky add .husky/commit-msg "npx commitlint --edit $1"

Why Husky?

  • Shareable hooks - .git/hooks/ is not tracked by Git, but .husky/ is
  • Easy installation - New team members get hooks automatically with npm install
  • Cross-platform - Works on Windows, Mac, and Linux
  • Simple syntax - Just add scripts, no bash knowledge needed
Make Husky Install Automatic

Add this to your package.json to install hooks automatically:

{
  "scripts": {
    "prepare": "husky install"
  }
}

Now npm install automatically sets up hooks for new developers.

Integrating Hooks with CI/CD

Git hooks work best when paired with CI/CD. The hooks catch issues locally (fast feedback), while CI/CD catches anything that slipped through (final validation).

The strategy:

  1. Pre-commit hook → Quick checks (linting, fast unit tests)
  2. Pre-push hook → Slower checks (full test suite)
  3. CI/CD pipeline → Everything + deployment

This way, you catch most issues in seconds locally, but still have CI/CD as a safety net.

Example GitLab CI/CD pipeline:

# .gitlab-ci.yml

stages:
  - lint
  - test
  - build
  - deploy

# Run on all branches
lint:
  stage: lint
  script:
    - npm run lint
  only:
    - branches

# Run on all branches
test:
  stage: test
  script:
    - npm test
  only:
    - branches

# Build on all branches
build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
  only:
    - branches

# Deploy develop → dev environment
deploy_dev:
  stage: deploy
  script:
    - npm run deploy:dev
  environment:
    name: development
    url: https://dev.example.com
  only:
    - develop

# Deploy staging → staging environment
deploy_staging:
  stage: deploy
  script:
    - npm run deploy:staging
  environment:
    name: staging
    url: https://staging.example.com
  only:
    - staging

# Deploy main → production (manual)
deploy_production:
  stage: deploy
  script:
    - npm run deploy:prod
  environment:
    name: production
    url: https://example.com
  only:
    - main
  when: manual

The workflow:

  1. Developer makes changes
  2. Pre-commit hook runs → Catches linting errors in 5 seconds
  3. Developer fixes issues, commits again
  4. Pre-push hook runs → Catches failing tests in 30 seconds
  5. Developer pushes to remote
  6. CI/CD pipeline runs → Full validation + deployment
Don't Duplicate Work

If your pre-commit hook already runs linting and tests, your CI/CD should still run them too (safety net), but consider making local checks faster by only running changed files:

# Pre-commit: Only lint changed files (fast)
npx lint-staged

# CI/CD: Lint entire codebase (thorough)
npm run lint

Common Hook Patterns

1. Prevent Commits to Protected Branches

Stop developers from accidentally committing directly to main:

#!/bin/bash
# .git/hooks/pre-commit

branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')

if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
    echo "❌ Direct commits to $branch are not allowed!"
    echo "Create a feature branch instead:"
    echo "  git checkout -b feature/my-feature"
    exit 1
fi

2. Check for Hardcoded Secrets

Prevent accidentally committing API keys or passwords:

#!/bin/bash
# .git/hooks/pre-commit

# Check for common secret patterns
if git diff --cached | grep -qE '(api_key|API_KEY|password|PASSWORD|secret|SECRET)\s*='; then
    echo "❌ Possible hardcoded secret detected!"
    echo "Use environment variables instead."
    exit 1
fi

3. Auto-Format Code Before Commit

Automatically format code with Prettier before committing:

#!/bin/bash
# .git/hooks/pre-commit

# Format staged files
npx prettier --write $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|css|md)$')

# Re-stage formatted files
git add $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|css|md)$')

4. Require Issue Number in Commit Message

Enforce linking commits to issue tracker:

#!/bin/bash
# .git/hooks/commit-msg

commit_msg=$(cat "$1")

# Check for issue number (#123)
if ! echo "$commit_msg" | grep -qE '\(#[0-9]+\)'; then
    echo "❌ Commit message must reference an issue number!"
    echo "Example: feat: add user login (#123)"
    exit 1
fi

Troubleshooting Hooks

Hook Not Running

Problem: Created hook but it’s not executing.

Solutions:

  1. Make hook executable: chmod +x .git/hooks/pre-commit
  2. Check shebang line: #!/bin/bash must be first line
  3. Verify hook name: pre-commit not pre-commit.sh

Hook Runs But Always Fails

Problem: Hook script has errors.

Debug:

# Run hook manually to see errors
bash .git/hooks/pre-commit

# Add debug output
echo "Debug: Current directory is $(pwd)"
echo "Debug: Git status:"
git status

Need to Skip Hook Temporarily

Solution:

# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "fix: emergency hotfix"

# Skip pre-push hook
git push --no-verify

Warning: Only use --no-verify when absolutely necessary. You’re bypassing safety checks.

Hooks Work Locally But Not for Team

Problem: Hooks in .git/hooks/ aren’t tracked by Git.

Solution: Use Husky (see Using Husky section above). Husky stores hooks in .husky/ which IS tracked by Git.

Conclusion

Git hooks are your first line of defense against bad code reaching your repository. Set them up once, and they’ll save you countless hours of debugging CI failures and fixing messy commits.

Quick setup checklist:

  • Install Husky: npm install --save-dev husky
  • Initialize hooks: npx husky install
  • Add pre-commit hook for linting: npx husky add .husky/pre-commit "npm run lint"
  • Add commit-msg validation: npx husky add .husky/commit-msg "npx commitlint --edit $1"
  • Add auto-install to package.json: "prepare": "husky install"
  • Test hooks: Make a commit and verify they run
  • Commit .husky/ directory to share with team

Now your hooks will catch mistakes locally before they embarrass you in code review or break CI. You’re welcome.

Hands-On Implementation Guide

Want to see a real-world implementation of these concepts? Check out our practical guide that walks through setting up Husky, lint-staged, and commitlint in an actual Astro blog project:

Implementing Git Hooks with Husky: A Real-World Example →

The guide includes:

  • Step-by-step Husky installation with actual terminal output
  • Configuring commitlint for conventional commits
  • Setting up lint-staged for faster pre-commit checks (6-10x speedup!)
  • Integrating hooks with GitLab CI/CD pipeline
  • Troubleshooting based on real issues we encountered
  • Before/after metrics showing time savings

This is the implementation we use on blog.stack101 - battle-tested and production-ready.

Related reading: