Navigation

DevOps & Deployment

Git Hooks for Automated Code Quality Checks Guide 2025

Learn how to implement Git hooks for automated code quality checks. Enforce coding standards, run tests, and maintain code quality automatically with pre-commit and pre-push hooks.

Table Of Contents

Introduction

Every development team struggles with maintaining consistent code quality. Code reviews catch many issues, but what if you could prevent problematic code from even being committed? Git hooks provide the perfect solution—automated scripts that run at specific points in your Git workflow.

Git hooks act as your first line of defense against code quality issues. They can automatically format code, run tests, check for security vulnerabilities, and enforce coding standards before changes ever leave a developer's machine. This proactive approach saves time, reduces review cycles, and maintains high code quality standards across your entire team.

In this comprehensive guide, you'll learn how to implement Git hooks that automatically enforce code quality checks, making your development workflow more efficient and your codebase more maintainable.

Understanding Git Hooks

What Are Git Hooks?

Git hooks are scripts that Git executes before or after events such as commit, push, and receive. They're stored in the .git/hooks directory of every Git repository and can be written in any scripting language—Bash, Python, Node.js, or any executable script.

Types of Git Hooks

Git provides two types of hooks:

Client-Side Hooks:

  • pre-commit: Runs before a commit is created
  • prepare-commit-msg: Runs before the commit message editor is opened
  • commit-msg: Validates the commit message
  • post-commit: Runs after a commit is created
  • pre-push: Runs before pushing to a remote repository
  • pre-rebase: Runs before a rebase operation

Server-Side Hooks:

  • pre-receive: Runs before accepting pushed commits
  • update: Similar to pre-receive but runs once per branch
  • post-receive: Runs after push is complete

How Git Hooks Work

When you initialize a Git repository, Git creates sample hook scripts in .git/hooks/:

ls .git/hooks/
# applypatch-msg.sample
# commit-msg.sample
# pre-commit.sample
# pre-push.sample
# pre-rebase.sample
# ...

To activate a hook, remove the .sample extension and ensure the script is executable:

mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

Setting Up Your First Git Hook

Creating a Simple Pre-Commit Hook

Let's create a basic pre-commit hook that checks for debugging statements:

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

# Check for console.log statements in JavaScript files
if git diff --cached --name-only | grep -E '\.js$' | xargs grep -n 'console\.log'; then
    echo "Error: Found console.log statements in JavaScript files"
    echo "Please remove them before committing"
    exit 1
fi

exit 0

Making Hooks Executable

chmod +x .git/hooks/pre-commit

Testing Your Hook

# Add a file with console.log
echo "console.log('debug');" > test.js
git add test.js
git commit -m "Test commit"
# Error: Found console.log statements in JavaScript files

Common Code Quality Checks

Linting and Code Style

ESLint for JavaScript/TypeScript

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

# Run ESLint on staged JavaScript/TypeScript files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx)$')

if [ -n "$STAGED_FILES" ]; then
    echo "Running ESLint..."
    npx eslint $STAGED_FILES --fix
    
    # Add fixed files back to staging
    git add $STAGED_FILES
    
    # Re-run to check if issues remain
    if ! npx eslint $STAGED_FILES; then
        echo "ESLint failed. Please fix errors before committing."
        exit 1
    fi
fi

Python Black Formatter

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

# Format Python files with Black
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')

if [ -n "$STAGED_PY_FILES" ]; then
    echo "Running Black formatter..."
    black $STAGED_PY_FILES
    git add $STAGED_PY_FILES
fi

Running Tests

Pre-Push Test Runner

#!/bin/sh
# .git/hooks/pre-push

echo "Running tests before push..."

# Run different test suites based on changed files
if git diff --name-only @{u}... | grep -E '\.(js|ts)$' > /dev/null; then
    echo "Running JavaScript tests..."
    if ! npm test; then
        echo "Tests failed. Push aborted."
        exit 1
    fi
fi

if git diff --name-only @{u}... | grep '\.py$' > /dev/null; then
    echo "Running Python tests..."
    if ! python -m pytest; then
        echo "Tests failed. Push aborted."
        exit 1
    fi
fi

echo "All tests passed!"

Security Scanning

Detecting Secrets

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

# Check for potential secrets
PATTERNS=(
    'password\s*=\s*["\'][^"\']+["\']'
    'api[_-]?key\s*=\s*["\'][^"\']+["\']'
    'secret\s*=\s*["\'][^"\']+["\']'
    'token\s*=\s*["\'][^"\']+["\']'
    'BEGIN RSA PRIVATE KEY'
    'BEGIN DSA PRIVATE KEY'
    'BEGIN EC PRIVATE KEY'
    'BEGIN PGP PRIVATE KEY'
)

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

for pattern in "${PATTERNS[@]}"; do
    if echo "$STAGED_FILES" | xargs grep -E -i "$pattern" 2>/dev/null; then
        echo "Error: Potential secret detected!"
        echo "Please remove sensitive information before committing."
        exit 1
    fi
done

Commit Message Validation

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

# Enforce conventional commit format
commit_regex='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{1,50}'

if ! grep -qE "$commit_regex" "$1"; then
    echo "Invalid commit message format!"
    echo "Format: <type>(<scope>): <subject>"
    echo "Example: feat(auth): add login functionality"
    echo ""
    echo "Types: feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert"
    exit 1
fi

# Check message length
if [ $(head -1 "$1" | wc -c) -gt 72 ]; then
    echo "Commit message too long. Keep under 72 characters."
    exit 1
fi

Advanced Hook Implementation

Multi-Language Support Hook

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

# Color output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

echo -e "${YELLOW}Running pre-commit checks...${NC}"

# Track overall status
PASS=true

# JavaScript/TypeScript checks
JS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx)$')
if [ -n "$JS_FILES" ]; then
    echo -e "${YELLOW}Checking JavaScript/TypeScript files...${NC}"
    
    # ESLint
    if ! npx eslint $JS_FILES; then
        PASS=false
    fi
    
    # Prettier
    npx prettier --write $JS_FILES
    git add $JS_FILES
fi

# Python checks
PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
if [ -n "$PY_FILES" ]; then
    echo -e "${YELLOW}Checking Python files...${NC}"
    
    # Flake8
    if ! flake8 $PY_FILES; then
        PASS=false
    fi
    
    # Black
    black $PY_FILES
    git add $PY_FILES
fi

# Go checks
GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$')
if [ -n "$GO_FILES" ]; then
    echo -e "${YELLOW}Checking Go files...${NC}"
    
    # gofmt
    UNFORMATTED=$(gofmt -l $GO_FILES)
    if [ -n "$UNFORMATTED" ]; then
        echo "Go files must be formatted with gofmt:"
        echo "$UNFORMATTED"
        PASS=false
    fi
    
    # go vet
    if ! go vet ./...; then
        PASS=false
    fi
fi

# Final status
if [ "$PASS" = true ]; then
    echo -e "${GREEN}All checks passed!${NC}"
    exit 0
else
    echo -e "${RED}Some checks failed. Please fix issues before committing.${NC}"
    exit 1
fi

Performance Optimization

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

# Only check modified files
get_staged_files() {
    git diff --cached --name-only --diff-filter=ACM | grep -E "$1"
}

# Run checks in parallel
run_parallel_checks() {
    local pids=()
    
    # JavaScript lint in background
    if [ -n "$(get_staged_files '\.(js|jsx|ts|tsx)$')" ]; then
        npx eslint $(get_staged_files '\.(js|jsx|ts|tsx)$') &
        pids+=($!)
    fi
    
    # Python lint in background
    if [ -n "$(get_staged_files '\.py$')" ]; then
        flake8 $(get_staged_files '\.py$') &
        pids+=($!)
    fi
    
    # Wait for all background jobs
    local failed=0
    for pid in "${pids[@]}"; do
        if ! wait $pid; then
            failed=1
        fi
    done
    
    return $failed
}

# Execute checks
if ! run_parallel_checks; then
    echo "Pre-commit checks failed!"
    exit 1
fi

Using Git Hook Managers

Husky (JavaScript/Node.js)

Installation

npm install --save-dev husky
npx husky install
npm pkg set scripts.prepare="husky install"

Configuration

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

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

Package.json Integration

{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint . --fix",
    "test": "jest"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"]
  }
}

Pre-commit Framework (Python)

Installation

pip install pre-commit

Configuration (.pre-commit-config.yaml)

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.4.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
      - id: check-merge-conflict
      
  - repo: https://github.com/psf/black
    rev: 23.1.0
    hooks:
      - id: black
        
  - repo: https://github.com/pycqa/flake8
    rev: 6.0.0
    hooks:
      - id: flake8
        
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.36.0
    hooks:
      - id: eslint
        files: \.(js|jsx|ts|tsx)$

Setup

# Install hooks
pre-commit install

# Run on all files
pre-commit run --all-files

# Update hooks
pre-commit autoupdate

Lefthook (Multi-language)

Configuration (lefthook.yml)

pre-commit:
  parallel: true
  commands:
    eslint:
      glob: "*.{js,jsx,ts,tsx}"
      run: npx eslint {staged_files}
    prettier:
      glob: "*.{js,jsx,ts,tsx,json,md}"
      run: npx prettier --write {staged_files} && git add {staged_files}
    python-black:
      glob: "*.py"
      run: black {staged_files} && git add {staged_files}
    go-fmt:
      glob: "*.go"
      run: gofmt -w {staged_files} && git add {staged_files}

pre-push:
  commands:
    test-js:
      run: npm test
    test-python:
      run: python -m pytest
    audit:
      run: npm audit

commit-msg:
  commands:
    commitlint:
      run: npx commitlint --edit

Best Practices for Git Hooks

Keep Hooks Fast

#!/bin/bash
# Set timeout for long-running checks

timeout_check() {
    timeout 30s "$@"
    local status=$?
    
    if [ $status -eq 124 ]; then
        echo "Check timed out after 30 seconds"
        return 1
    fi
    
    return $status
}

# Use timeout for potentially slow operations
timeout_check npm run lint
timeout_check npm test

Make Hooks Configurable

#!/bin/bash
# Allow developers to skip hooks when needed

# Check for skip environment variable
if [ "$SKIP_HOOKS" = "1" ]; then
    echo "Skipping pre-commit hooks (SKIP_HOOKS=1)"
    exit 0
fi

# Check for --no-verify flag
if [ "$GIT_SKIP_HOOKS" = "1" ]; then
    exit 0
fi

# Run normal hooks...

Provide Clear Error Messages

#!/bin/bash

# Function for formatted output
print_error() {
    echo -e "\033[0;31m✗ $1\033[0m"
}

print_success() {
    echo -e "\033[0;32m✓ $1\033[0m"
}

print_info() {
    echo -e "\033[0;34mℹ $1\033[0m"
}

# Example usage
if ! npm run lint; then
    print_error "Linting failed"
    print_info "Run 'npm run lint:fix' to automatically fix issues"
    print_info "Or commit with --no-verify to skip hooks"
    exit 1
fi

print_success "All checks passed!"

Version Control Your Hooks

# Create hooks directory in your project
mkdir .githooks

# Copy hooks
cp .git/hooks/pre-commit .githooks/

# Create setup script
cat > setup-hooks.sh << 'EOF'
#!/bin/bash
echo "Setting up Git hooks..."

# Create symlinks
ln -sf ../../.githooks/pre-commit .git/hooks/pre-commit
ln -sf ../../.githooks/pre-push .git/hooks/pre-push

chmod +x .git/hooks/*
echo "Git hooks installed successfully!"
EOF

chmod +x setup-hooks.sh

Troubleshooting Common Issues

Hook Not Executing

# Check if hook is executable
ls -la .git/hooks/pre-commit

# Make executable
chmod +x .git/hooks/pre-commit

# Check shebang line
head -1 .git/hooks/pre-commit
# Should be: #!/bin/sh or #!/bin/bash

Hook Failing on CI/CD

# Detect CI environment
if [ -n "$CI" ] || [ -n "$CONTINUOUS_INTEGRATION" ]; then
    echo "Running in CI environment, skipping interactive checks"
    exit 0
fi

Windows Compatibility

#!/bin/sh
# Handle Windows line endings

# Convert CRLF to LF for staged files
if [ "$OS" = "Windows_NT" ]; then
    git config core.autocrlf true
fi

# Use cross-platform commands
# Instead of: grep -E
# Use: git grep -E

Integration with Development Workflows

IDE Integration

VS Code Settings

{
  "git.enableCommitSigning": true,
  "git.confirmSync": false,
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

JetBrains IDE Configuration

<!-- .idea/git_hooks.xml -->
<component name="GitHooks">
  <option name="preCommitHook" value=".githooks/pre-commit" />
  <option name="prePushHook" value=".githooks/pre-push" />
</component>

CI/CD Pipeline Integration

# GitHub Actions
name: Code Quality
on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Run pre-commit hooks
        uses: pre-commit/action@v3.0.0
        
      - name: Run tests
        run: |
          npm install
          npm test

FAQ Section

Q: Can I skip Git hooks temporarily?

Yes, use git commit --no-verify or git push --no-verify to skip hooks for a single operation. You can also set SKIP_HOOKS=1 environment variable if your hooks support it.

Q: How do I share Git hooks with my team?

Store hooks in a directory like .githooks/ in your repository and provide a setup script. Alternatively, use hook managers like Husky or pre-commit that automatically install hooks.

Q: Do Git hooks work with GUI Git clients?

Most GUI clients respect Git hooks, but some may not show hook output clearly. Test your hooks with the specific GUI tools your team uses.

Q: Can hooks modify staged files?

Yes, hooks can modify files, but you must re-stage them using git add within the hook script for changes to be included in the commit.

Q: What happens if a hook script fails?

If a pre-commit or pre-push hook exits with a non-zero status, Git cancels the operation. Post-hooks don't affect the Git operation since they run after completion.

Q: How do I debug Git hooks?

Add debug output using echo statements, check the hook's exit status with echo $?, and test hooks manually by running them directly from the command line.

Conclusion

Git hooks are powerful tools that automate code quality checks and enforce development standards. By implementing the hooks and practices outlined in this guide, you'll create a more robust development workflow that catches issues early and maintains consistent code quality.

Key takeaways:

  • Start simple with basic linting and formatting hooks
  • Use hook managers to simplify setup and maintenance
  • Keep hooks fast to avoid disrupting developer flow
  • Provide clear error messages and recovery options
  • Share hooks across your team for consistent standards

Begin implementing Git hooks in your projects today. Start with a simple pre-commit hook for linting, then gradually add more sophisticated checks as your team becomes comfortable with the workflow.

Have you implemented Git hooks in your projects? Share your experiences and custom hook scripts in the comments below!

Share this article

Add Comment

No comments yet. Be the first to comment!

More from DevOps & Deployment