Git Hooks for Automation: Catch Mistakes Before They Embarrass You

Git Hooks for Automation: Catch Mistakes Before They Embarrass You

· 8 min read
gitlab-for-your-team
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: A Practical Case.

Why Use Git Hooks?

You commit a console.log. CI fails. You forgot tests. Your message says “fix stuff.” Your team lead gives you that look.

Git hooks stop that. They’re scripts that run at specific points in your workflow — before commits, before pushes, after merges. They catch mistakes before anyone sees them.

What hooks prevent:

  1. Bad code hitting the repo

    • Linting errors, unused imports, formatting issues
    • Failing tests
    • Hardcoded secrets, vulnerable dependencies
    • Syntax errors, type errors
  2. Messy commit history

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

    • Issues caught locally, CI never runs on broken code
    • Feedback in seconds, not minutes
  4. Team inconsistency

    • Same checks run for everyone, automatically
    • No “works on my machine” excuses
    • Standards enforced without nagging

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 when you know what you’re doing. Like fixing a docs typo when you don’t want the full test suite.

Pre-commit Hook

Checks 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

Validates 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

Manual hooks in .git/hooks/ work but aren’t shareable. Husky fixes that.

# 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 - .git/hooks/ isn’t tracked by Git; .husky/ is
  • Automatic - New team members get hooks via npm install
  • Cross-platform - Works on Windows, Mac, and Linux
  • Simple - Just add scripts, no bash expertise needed
💡 Make Husky Install Automatic

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

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

Now npm install sets up hooks for every developer automatically.

Integrating Hooks with CI/CD

Hooks catch issues locally (fast). CI/CD catches anything that slips through (safety net). Use both.

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

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 catches lint errors in 5 seconds
  3. Fix, commit again
  4. Pre-push hook catches failing tests in 30 seconds
  5. Push to remote
  6. CI/CD pipeline runs full validation + deployment
Don't Duplicate Work

Your CI should still run lint and tests (safety net), but make 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

Block direct commits 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

Catch API keys and passwords before commit:

#!/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

Run Prettier on staged files:

#!/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

Force commits to reference an issue:

#!/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: Hook exists but doesn’t execute.

Solutions:

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

Hook Runs But Always Fails

Problem: 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

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

Hooks Work Locally But Not for Team

Problem: .git/hooks/ isn’t tracked by Git.

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

Conclusion

Git hooks are your first line of defense. Set them up once, stop debugging CI failures.

Quick setup checklist:

  • Install Husky: npm install --save-dev husky
  • Initialize hooks: npx husky install
  • Add pre-commit 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: "prepare": "husky install" in package.json
  • Test: make a commit and verify hooks run
  • Commit .husky/ to share with the team

Catch mistakes locally before they hit review or break CI.

Hands-On Implementation Guide

Want to see this in a real project? The practical guide walks through Husky, lint-staged, and commitlint in an actual Astro blog:

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

Covers:

  • Step-by-step Husky installation with terminal output
  • Commitlint config for conventional commits
  • Lint-staged for faster pre-commit checks (6-10x speedup)
  • GitLab CI/CD integration
  • Real troubleshooting from issues we hit
  • Before/after time savings metrics

This is what we run on blog.stack101 — battle-tested and production-ready.

Related reading: