Automate Code Quality with Git Hooks

David Childs

Implement powerful Git hooks for automated code quality checks, security scanning, and workflow enforcement to catch issues before they reach your repository.

Git hooks are your first line of defense against bad code. After implementing hooks across dozens of projects, I've seen how they transform team productivity by catching issues before they become problems. Here's your comprehensive guide to implementing Git hooks that actually improve your workflow instead of slowing it down.

Understanding Git Hooks

Hook Lifecycle and Types

# hook_manager.py
import os
import sys
import subprocess
from enum import Enum
from typing import Dict, List, Callable

class HookType(Enum):
    # Client-side hooks
    PRE_COMMIT = "pre-commit"
    PREPARE_COMMIT_MSG = "prepare-commit-msg"
    COMMIT_MSG = "commit-msg"
    POST_COMMIT = "post-commit"
    PRE_REBASE = "pre-rebase"
    POST_REWRITE = "post-rewrite"
    POST_CHECKOUT = "post-checkout"
    POST_MERGE = "post-merge"
    PRE_PUSH = "pre-push"
    
    # Server-side hooks
    PRE_RECEIVE = "pre-receive"
    UPDATE = "update"
    POST_RECEIVE = "post-receive"

class GitHookManager:
    def __init__(self, repo_path: str = "."):
        self.repo_path = repo_path
        self.hooks_dir = os.path.join(repo_path, ".git", "hooks")
        self.hooks: Dict[HookType, List[Callable]] = {}
    
    def install_hook(self, hook_type: HookType, script_content: str):
        """Install a Git hook"""
        hook_path = os.path.join(self.hooks_dir, hook_type.value)
        
        # Make hooks directory if it doesn't exist
        os.makedirs(self.hooks_dir, exist_ok=True)
        
        # Write hook script
        with open(hook_path, 'w') as f:
            f.write("#!/bin/bash\n")
            f.write(script_content)
        
        # Make executable
        os.chmod(hook_path, 0o755)
        
        print(f"Installed {hook_type.value} hook")
    
    def create_hook_runner(self):
        """Create a universal hook runner"""
        runner_content = '''#!/usr/bin/env python3
import sys
import os
import subprocess
import json

def run_hook_scripts(hook_name):
    """Run all scripts for a specific hook"""
    hooks_dir = os.path.join(".git", "hooks", hook_name + ".d")
    
    if not os.path.exists(hooks_dir):
        return 0
    
    # Get all executable scripts in hooks directory
    scripts = sorted([
        os.path.join(hooks_dir, f) 
        for f in os.listdir(hooks_dir)
        if os.path.isfile(os.path.join(hooks_dir, f)) 
        and os.access(os.path.join(hooks_dir, f), os.X_OK)
    ])
    
    # Run each script
    for script in scripts:
        print(f"Running {script}...")
        result = subprocess.run([script] + sys.argv[1:], capture_output=False)
        
        if result.returncode != 0:
            print(f"Hook {script} failed with exit code {result.returncode}")
            return result.returncode
    
    return 0

if __name__ == "__main__":
    hook_name = os.path.basename(sys.argv[0])
    sys.exit(run_hook_scripts(hook_name))
'''
        return runner_content

Pre-commit Hooks

Comprehensive Pre-commit Checks

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

set -e

echo "Running pre-commit checks..."

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

# Check for debugging statements
check_debug_statements() {
    echo "Checking for debug statements..."
    
    # Python
    if git diff --cached --name-only | grep -q "\.py$"; then
        if git diff --cached | grep -E "^\+" | grep -q "import pdb\|pdb\.set_trace\|print\("; then
            echo -e "${RED}✗ Found debug statements in Python files${NC}"
            git diff --cached --name-only | xargs grep -n "import pdb\|pdb\.set_trace" || true
            return 1
        fi
    fi
    
    # JavaScript
    if git diff --cached --name-only | grep -q "\.js$\|\.ts$"; then
        if git diff --cached | grep -E "^\+" | grep -q "console\.\|debugger"; then
            echo -e "${YELLOW}⚠ Found console statements in JavaScript files${NC}"
            git diff --cached --name-only | xargs grep -n "console\.\|debugger" || true
        fi
    fi
    
    echo -e "${GREEN}✓ No debug statements found${NC}"
    return 0
}

# Check for large files
check_large_files() {
    echo "Checking for large files..."
    
    MAX_SIZE=5242880  # 5MB in bytes
    
    for file in $(git diff --cached --name-only); do
        if [ -f "$file" ]; then
            size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)
            if [ $size -gt $MAX_SIZE ]; then
                echo -e "${RED}✗ File $file is larger than 5MB ($size bytes)${NC}"
                return 1
            fi
        fi
    done
    
    echo -e "${GREEN}✓ No large files${NC}"
    return 0
}

# Check for secrets and credentials
check_secrets() {
    echo "Checking for secrets..."
    
    # Patterns to check
    patterns=(
        "password.*=.*['\"]"
        "api[_-]?key.*=.*['\"]"
        "secret.*=.*['\"]"
        "token.*=.*['\"]"
        "PRIVATE KEY"
        "aws_access_key_id"
        "aws_secret_access_key"
    )
    
    for pattern in "${patterns[@]}"; do
        if git diff --cached | grep -iE "^\+" | grep -iE "$pattern"; then
            echo -e "${RED}✗ Possible secret detected matching pattern: $pattern${NC}"
            return 1
        fi
    done
    
    # Check with detect-secrets if available
    if command -v detect-secrets &> /dev/null; then
        git diff --cached --name-only | xargs detect-secrets-hook --baseline .secrets.baseline
    fi
    
    echo -e "${GREEN}✓ No secrets detected${NC}"
    return 0
}

# Run linting
run_linting() {
    echo "Running linters..."
    
    # Python files
    if git diff --cached --name-only | grep -q "\.py$"; then
        echo "Linting Python files..."
        git diff --cached --name-only --diff-filter=ACM | grep "\.py$" | xargs -r pylint
        git diff --cached --name-only --diff-filter=ACM | grep "\.py$" | xargs -r black --check
        git diff --cached --name-only --diff-filter=ACM | grep "\.py$" | xargs -r mypy
    fi
    
    # JavaScript/TypeScript files
    if git diff --cached --name-only | grep -q "\.js$\|\.ts$\|\.jsx$\|\.tsx$"; then
        echo "Linting JavaScript/TypeScript files..."
        npx lint-staged
    fi
    
    # Go files
    if git diff --cached --name-only | grep -q "\.go$"; then
        echo "Linting Go files..."
        git diff --cached --name-only --diff-filter=ACM | grep "\.go$" | xargs -r gofmt -l
        git diff --cached --name-only --diff-filter=ACM | grep "\.go$" | xargs -r golint
    fi
    
    echo -e "${GREEN}✓ Linting passed${NC}"
    return 0
}

# Run tests for changed files
run_tests() {
    echo "Running tests..."
    
    # Get changed files
    changed_files=$(git diff --cached --name-only --diff-filter=ACM)
    
    # Python tests
    if echo "$changed_files" | grep -q "\.py$"; then
        pytest --testmon --quiet
    fi
    
    # JavaScript tests
    if echo "$changed_files" | grep -q "\.js$\|\.ts$"; then
        npm test -- --findRelatedTests $changed_files --passWithNoTests
    fi
    
    echo -e "${GREEN}✓ Tests passed${NC}"
    return 0
}

# Main execution
main() {
    # Run all checks
    check_debug_statements || exit 1
    check_large_files || exit 1
    check_secrets || exit 1
    run_linting || exit 1
    run_tests || exit 1
    
    echo -e "${GREEN}✓ All pre-commit checks passed${NC}"
}

main

Pre-commit Framework Configuration

# .pre-commit-config.yaml
default_language_version:
  python: python3.9

repos:
  # General
  - 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-json
      - id: check-xml
      - id: check-toml
      - id: check-added-large-files
        args: ['--maxkb=5000']
      - id: check-case-conflict
      - id: check-merge-conflict
      - id: detect-private-key
      - id: mixed-line-ending
        args: ['--fix=lf']

  # Python
  - repo: https://github.com/psf/black
    rev: 23.1.0
    hooks:
      - id: black
        language_version: python3

  - repo: https://github.com/PyCQA/flake8
    rev: 6.0.0
    hooks:
      - id: flake8
        args: ['--max-line-length=100', '--ignore=E203,W503']

  - repo: https://github.com/PyCQA/isort
    rev: 5.12.0
    hooks:
      - id: isort
        args: ['--profile', 'black']

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.0.1
    hooks:
      - id: mypy
        additional_dependencies: [types-all]

  # JavaScript/TypeScript
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.35.0
    hooks:
      - id: eslint
        files: \.[jt]sx?$
        types: [file]
        additional_dependencies:
          - eslint@8.35.0
          - eslint-config-prettier@8.6.0

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v2.8.4
    hooks:
      - id: prettier
        types_or: [javascript, jsx, ts, tsx, json, yaml, markdown]

  # Security
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.4.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']

  - repo: https://github.com/PyCQA/bandit
    rev: 1.7.4
    hooks:
      - id: bandit
        args: ['-r', 'src/']

  # Docker
  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint-docker

  # Terraform
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.77.1
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint

Commit Message Hooks

Commit Message Validation

#!/usr/bin/env python3
# .git/hooks/commit-msg

import sys
import re

def validate_commit_message(message):
    """Validate commit message format"""
    
    # Conventional Commits pattern
    pattern = r'^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(\w+\))?: .{1,50}'
    
    # Check first line
    lines = message.split('\n')
    if not lines:
        return False, "Empty commit message"
    
    first_line = lines[0]
    
    # Check format
    if not re.match(pattern, first_line):
        return False, f"Invalid format. Expected: type(scope): subject\nGot: {first_line}"
    
    # Check length
    if len(first_line) > 72:
        return False, f"First line too long ({len(first_line)} > 72 characters)"
    
    # Check for issue reference
    if len(lines) > 2:
        body = '\n'.join(lines[2:])
        if not re.search(r'#\d+', body) and not re.search(r'[A-Z]+-\d+', body):
            print("Warning: No issue reference found in commit message")
    
    return True, "Valid commit message"

def main():
    # Read commit message
    commit_msg_file = sys.argv[1]
    with open(commit_msg_file, 'r') as f:
        message = f.read()
    
    # Validate
    valid, reason = validate_commit_message(message)
    
    if not valid:
        print(f"❌ Commit message validation failed: {reason}")
        print("\nExpected format:")
        print("  <type>(<scope>): <subject>")
        print("  <BLANK LINE>")
        print("  <body>")
        print("  <BLANK LINE>")
        print("  <footer>")
        print("\nExample:")
        print("  feat(auth): add OAuth2 support")
        print("  ")
        print("  Implemented OAuth2 authentication flow")
        print("  ")
        print("  Closes #123")
        sys.exit(1)
    
    print("✅ Commit message validation passed")
    sys.exit(0)

if __name__ == "__main__":
    main()

Pre-push Hooks

Pre-push Validation

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

set -e

echo "Running pre-push checks..."

# Read stdin for push information
while read local_ref local_sha remote_ref remote_sha; do
    # Check if we're pushing to protected branches
    if [[ "$remote_ref" =~ ^refs/heads/(main|master|production)$ ]]; then
        echo "Pushing to protected branch: $remote_ref"
        
        # Run comprehensive tests
        echo "Running full test suite..."
        npm test
        
        # Check code coverage
        echo "Checking code coverage..."
        npm run test:coverage
        coverage_percent=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
        if (( $(echo "$coverage_percent < 80" | bc -l) )); then
            echo "❌ Code coverage is below 80% ($coverage_percent%)"
            exit 1
        fi
        
        # Build the project
        echo "Building project..."
        npm run build
        
        # Run security audit
        echo "Running security audit..."
        npm audit --audit-level=moderate
        
        # Check for TODO comments
        if git diff $remote_sha..$local_sha | grep -i "TODO\|FIXME\|XXX"; then
            echo "⚠️  Warning: TODO/FIXME comments found"
            read -p "Continue pushing with TODO comments? (y/n) " -n 1 -r
            echo
            if [[ ! $REPLY =~ ^[Yy]$ ]]; then
                exit 1
            fi
        fi
    fi
    
    # Check commit authors
    commits=$(git rev-list $remote_sha..$local_sha)
    for commit in $commits; do
        author_email=$(git show -s --format='%ae' $commit)
        if [[ ! "$author_email" =~ @yourcompany\.com$ ]]; then
            echo "❌ Commit $commit has non-company email: $author_email"
            echo "Please use your company email for commits"
            exit 1
        fi
    done
done

echo "✅ Pre-push checks passed"

Server-side Hooks

Pre-receive Hook for Policy Enforcement

#!/usr/bin/env python3
# hooks/pre-receive

import sys
import subprocess
import re
import json

class PreReceiveValidator:
    def __init__(self):
        self.errors = []
        self.warnings = []
    
    def validate_push(self, old_sha, new_sha, ref_name):
        """Validate incoming push"""
        
        print(f"Validating push to {ref_name}")
        
        # Check branch protection
        if not self.check_branch_protection(ref_name):
            return False
        
        # Get commits
        commits = self.get_commits(old_sha, new_sha)
        
        # Validate each commit
        for commit in commits:
            self.validate_commit(commit)
        
        # Check file size limits
        self.check_file_sizes(old_sha, new_sha)
        
        # Check for forbidden files
        self.check_forbidden_files(old_sha, new_sha)
        
        # Scan for secrets
        self.scan_for_secrets(old_sha, new_sha)
        
        return len(self.errors) == 0
    
    def check_branch_protection(self, ref_name):
        """Check if push is allowed to this branch"""
        
        protected_branches = ['refs/heads/main', 'refs/heads/production']
        
        if ref_name in protected_branches:
            # Check if user has permission
            user = subprocess.check_output(['whoami']).decode().strip()
            allowed_users = ['ci-bot', 'release-manager']
            
            if user not in allowed_users:
                self.errors.append(f"Direct push to {ref_name} not allowed for user {user}")
                return False
        
        return True
    
    def get_commits(self, old_sha, new_sha):
        """Get list of commits being pushed"""
        
        if old_sha == '0' * 40:
            # New branch
            cmd = ['git', 'rev-list', new_sha]
        else:
            cmd = ['git', 'rev-list', f'{old_sha}..{new_sha}']
        
        output = subprocess.check_output(cmd).decode()
        return output.strip().split('\n') if output.strip() else []
    
    def validate_commit(self, commit_sha):
        """Validate individual commit"""
        
        # Get commit info
        commit_info = subprocess.check_output(
            ['git', 'show', '--format=%ae%n%s%n%b', '-s', commit_sha]
        ).decode()
        
        lines = commit_info.strip().split('\n')
        email = lines[0]
        subject = lines[1] if len(lines) > 1 else ''
        
        # Check commit message format
        if not re.match(r'^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)', subject):
            self.warnings.append(f"Commit {commit_sha[:7]} doesn't follow conventional format")
        
        # Check author email
        if not email.endswith('@yourcompany.com'):
            self.errors.append(f"Commit {commit_sha[:7]} has invalid email: {email}")
        
        # Check for signed commits
        signature = subprocess.run(
            ['git', 'verify-commit', commit_sha],
            capture_output=True
        )
        
        if signature.returncode != 0:
            self.warnings.append(f"Commit {commit_sha[:7]} is not signed")
    
    def check_file_sizes(self, old_sha, new_sha):
        """Check for large files"""
        
        MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
        
        # Get list of new/modified files
        if old_sha == '0' * 40:
            cmd = ['git', 'ls-tree', '-r', new_sha]
        else:
            cmd = ['git', 'diff-tree', '-r', old_sha, new_sha]
        
        output = subprocess.check_output(cmd).decode()
        
        for line in output.strip().split('\n'):
            if not line:
                continue
            
            parts = line.split()
            if len(parts) >= 4:
                size = int(parts[3]) if old_sha != '0' * 40 else 0
                
                if size > MAX_FILE_SIZE:
                    filename = parts[-1]
                    self.errors.append(f"File {filename} exceeds size limit ({size} bytes)")
    
    def check_forbidden_files(self, old_sha, new_sha):
        """Check for forbidden file patterns"""
        
        forbidden_patterns = [
            r'\.env$',
            r'\.pem$',
            r'\.key$',
            r'\.p12$',
            r'\.pfx$',
            r'node_modules/',
            r'__pycache__/',
            r'\.pyc$'
        ]
        
        # Get file list
        if old_sha == '0' * 40:
            cmd = ['git', 'ls-tree', '-r', '--name-only', new_sha]
        else:
            cmd = ['git', 'diff', '--name-only', old_sha, new_sha]
        
        files = subprocess.check_output(cmd).decode().strip().split('\n')
        
        for file in files:
            for pattern in forbidden_patterns:
                if re.search(pattern, file):
                    self.errors.append(f"Forbidden file pattern detected: {file}")
    
    def scan_for_secrets(self, old_sha, new_sha):
        """Scan for potential secrets"""
        
        # Get diff
        if old_sha == '0' * 40:
            cmd = ['git', 'show', new_sha]
        else:
            cmd = ['git', 'diff', old_sha, new_sha]
        
        diff = subprocess.check_output(cmd).decode()
        
        # Secret patterns
        patterns = [
            (r'["\']?[Aa][Ww][Ss][_]?[Aa]ccess[_]?[Kk]ey[_]?[Ii][Dd]["\']?\s*[:=]\s*["\']?[A-Z0-9]{20}["\']?', 'AWS Access Key'),
            (r'["\']?[Aa][Ww][Ss][_]?[Ss]ecret[_]?[Aa]ccess[_]?[Kk]ey["\']?\s*[:=]\s*["\']?[A-Za-z0-9/+=]{40}["\']?', 'AWS Secret Key'),
            (r'["\']?[Aa]pi[_]?[Kk]ey["\']?\s*[:=]\s*["\']?[A-Za-z0-9]{32,}["\']?', 'API Key'),
            (r'-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----', 'Private Key'),
        ]
        
        for pattern, name in patterns:
            if re.search(pattern, diff):
                self.errors.append(f"Potential {name} detected in diff")

def main():
    validator = PreReceiveValidator()
    
    # Read push info from stdin
    for line in sys.stdin:
        old_sha, new_sha, ref_name = line.strip().split()
        
        if not validator.validate_push(old_sha, new_sha, ref_name):
            print("\n❌ Push rejected due to validation errors:")
            for error in validator.errors:
                print(f"  - {error}")
            
            if validator.warnings:
                print("\n⚠️  Warnings:")
                for warning in validator.warnings:
                    print(f"  - {warning}")
            
            sys.exit(1)
    
    print("✅ All validations passed")
    sys.exit(0)

if __name__ == "__main__":
    main()

Custom Hook Development

Hook Development Framework

# hook_framework.py
import abc
import subprocess
import json
import yaml
from typing import List, Dict, Any
from dataclasses import dataclass

@dataclass
class HookContext:
    """Context passed to hooks"""
    files: List[str]
    commits: List[str]
    branch: str
    remote: str
    config: Dict[str, Any]

class Hook(abc.ABC):
    """Base class for Git hooks"""
    
    def __init__(self, name: str):
        self.name = name
        self.config = self.load_config()
    
    def load_config(self) -> Dict:
        """Load hook configuration"""
        try:
            with open('.githooks.yml', 'r') as f:
                config = yaml.safe_load(f)
                return config.get(self.name, {})
        except FileNotFoundError:
            return {}
    
    @abc.abstractmethod
    def run(self, context: HookContext) -> bool:
        """Run the hook"""
        pass
    
    def get_changed_files(self) -> List[str]:
        """Get list of changed files"""
        output = subprocess.check_output(
            ['git', 'diff', '--cached', '--name-only']
        ).decode()
        return output.strip().split('\n') if output.strip() else []

class LintHook(Hook):
    """Linting hook"""
    
    def run(self, context: HookContext) -> bool:
        files_by_type = self.group_files_by_type(context.files)
        
        for file_type, files in files_by_type.items():
            if not self.lint_files(file_type, files):
                return False
        
        return True
    
    def group_files_by_type(self, files: List[str]) -> Dict[str, List[str]]:
        """Group files by extension"""
        groups = {}
        
        for file in files:
            ext = file.split('.')[-1] if '.' in file else 'no_ext'
            if ext not in groups:
                groups[ext] = []
            groups[ext].append(file)
        
        return groups
    
    def lint_files(self, file_type: str, files: List[str]) -> bool:
        """Lint files based on type"""
        
        linters = {
            'py': ['pylint', 'black --check'],
            'js': ['eslint'],
            'ts': ['tslint'],
            'go': ['gofmt -l', 'golint'],
            'rs': ['cargo fmt -- --check'],
        }
        
        if file_type not in linters:
            return True
        
        for linter in linters[file_type]:
            cmd = linter.split() + files
            result = subprocess.run(cmd, capture_output=True)
            
            if result.returncode != 0:
                print(f"Linting failed for {file_type} files")
                print(result.stdout.decode())
                return False
        
        return True

class TestHook(Hook):
    """Testing hook"""
    
    def run(self, context: HookContext) -> bool:
        # Run tests based on changed files
        test_commands = self.determine_test_commands(context.files)
        
        for cmd in test_commands:
            print(f"Running: {' '.join(cmd)}")
            result = subprocess.run(cmd, capture_output=False)
            
            if result.returncode != 0:
                print(f"Tests failed")
                return False
        
        return True
    
    def determine_test_commands(self, files: List[str]) -> List[List[str]]:
        """Determine which tests to run based on changed files"""
        
        commands = []
        
        # Python tests
        if any(f.endswith('.py') for f in files):
            commands.append(['pytest', '--testmon'])
        
        # JavaScript tests
        if any(f.endswith('.js') or f.endswith('.ts') for f in files):
            commands.append(['npm', 'test', '--', '--findRelatedTests'] + files)
        
        # Go tests
        if any(f.endswith('.go') for f in files):
            packages = set(f.rsplit('/', 1)[0] for f in files if '/' in f)
            for pkg in packages:
                commands.append(['go', 'test', f'./{pkg}/...'])
        
        return commands

Hook Distribution and Management

Hook Installation Script

#!/bin/bash
# install-hooks.sh

set -e

HOOKS_DIR=".git/hooks"
SHARED_HOOKS_DIR=".githooks"

echo "Installing Git hooks..."

# Create hooks directory if it doesn't exist
mkdir -p "$HOOKS_DIR"

# Install pre-commit framework
install_precommit() {
    echo "Installing pre-commit framework..."
    pip install pre-commit
    pre-commit install
    pre-commit install --hook-type commit-msg
    pre-commit install --hook-type pre-push
}

# Install custom hooks
install_custom_hooks() {
    echo "Installing custom hooks..."
    
    # Copy shared hooks to .git/hooks
    if [ -d "$SHARED_HOOKS_DIR" ]; then
        for hook in "$SHARED_HOOKS_DIR"/*; do
            hook_name=$(basename "$hook")
            cp "$hook" "$HOOKS_DIR/$hook_name"
            chmod +x "$HOOKS_DIR/$hook_name"
            echo "  ✓ Installed $hook_name"
        done
    fi
}

# Install Husky (for Node.js projects)
install_husky() {
    if [ -f "package.json" ]; then
        echo "Installing Husky..."
        npm install --save-dev husky
        npx husky install
        
        # Add hooks
        npx husky add .husky/pre-commit "npm test"
        npx husky add .husky/commit-msg 'npx commitlint --edit "$1"'
    fi
}

# Configure Git to use core.hooksPath
configure_hooks_path() {
    echo "Configuring Git hooks path..."
    git config core.hooksPath .githooks
}

# Main installation
main() {
    # Check if we're in a git repository
    if [ ! -d ".git" ]; then
        echo "Error: Not in a Git repository"
        exit 1
    fi
    
    # Install based on project type
    if [ -f ".pre-commit-config.yaml" ]; then
        install_precommit
    fi
    
    if [ -d "$SHARED_HOOKS_DIR" ]; then
        install_custom_hooks
    fi
    
    if [ -f "package.json" ]; then
        install_husky
    fi
    
    # Configure hooks path
    configure_hooks_path
    
    echo ""
    echo "✅ Git hooks installed successfully!"
    echo ""
    echo "Hooks installed:"
    ls -la "$HOOKS_DIR" | grep -E "^-rwx"
}

main

Hook Testing

Hook Test Framework

# test_hooks.py
import unittest
import tempfile
import os
import subprocess
from pathlib import Path

class TestGitHooks(unittest.TestCase):
    def setUp(self):
        """Set up test repository"""
        self.test_dir = tempfile.mkdtemp()
        self.repo_dir = Path(self.test_dir) / 'test_repo'
        self.repo_dir.mkdir()
        
        # Initialize git repo
        subprocess.run(['git', 'init'], cwd=self.repo_dir)
        subprocess.run(['git', 'config', 'user.email', 'test@example.com'], cwd=self.repo_dir)
        subprocess.run(['git', 'config', 'user.name', 'Test User'], cwd=self.repo_dir)
    
    def test_pre_commit_hook(self):
        """Test pre-commit hook"""
        
        # Install pre-commit hook
        hook_content = '''#!/bin/bash
if grep -q "TODO" "$@"; then
    echo "TODOs not allowed"
    exit 1
fi
exit 0
'''
        hook_path = self.repo_dir / '.git' / 'hooks' / 'pre-commit'
        hook_path.write_text(hook_content)
        hook_path.chmod(0o755)
        
        # Create file with TODO
        test_file = self.repo_dir / 'test.txt'
        test_file.write_text('TODO: fix this')
        
        # Try to commit
        subprocess.run(['git', 'add', 'test.txt'], cwd=self.repo_dir)
        result = subprocess.run(
            ['git', 'commit', '-m', 'test'],
            cwd=self.repo_dir,
            capture_output=True
        )
        
        # Should fail
        self.assertNotEqual(result.returncode, 0)
        self.assertIn(b'TODOs not allowed', result.stdout + result.stderr)
    
    def test_commit_msg_hook(self):
        """Test commit message hook"""
        
        # Install commit-msg hook
        hook_content = '''#!/bin/bash
if ! grep -qE "^(feat|fix|docs)" "$1"; then
    echo "Invalid commit message format"
    exit 1
fi
exit 0
'''
        hook_path = self.repo_dir / '.git' / 'hooks' / 'commit-msg'
        hook_path.write_text(hook_content)
        hook_path.chmod(0o755)
        
        # Create and stage file
        test_file = self.repo_dir / 'test.txt'
        test_file.write_text('content')
        subprocess.run(['git', 'add', 'test.txt'], cwd=self.repo_dir)
        
        # Try with invalid message
        result = subprocess.run(
            ['git', 'commit', '-m', 'bad message'],
            cwd=self.repo_dir,
            capture_output=True
        )
        self.assertNotEqual(result.returncode, 0)
        
        # Try with valid message
        result = subprocess.run(
            ['git', 'commit', '-m', 'feat: good message'],
            cwd=self.repo_dir,
            capture_output=True
        )
        self.assertEqual(result.returncode, 0)

Best Practices Checklist

  • Use pre-commit framework for consistency
  • Keep hooks fast (< 10 seconds)
  • Provide clear error messages
  • Allow bypassing with --no-verify for emergencies
  • Test hooks thoroughly
  • Document hook requirements
  • Version control shared hooks
  • Use server-side hooks for enforcement
  • Implement progressive enhancement
  • Cache results when possible
  • Run hooks in CI/CD as backup
  • Provide hook installation scripts
  • Use language-specific tools
  • Monitor hook performance
  • Regular hook maintenance

Conclusion

Git hooks are powerful tools for maintaining code quality and enforcing standards. The key is finding the right balance between automation and developer productivity. Start with essential checks, gradually add more as your team adapts, and always provide clear feedback when hooks fail. Well-implemented hooks catch bugs before they're committed, enforce standards automatically, and save countless hours of code review.

Share this article

DC

David Childs

Consulting Systems Engineer with over 10 years of experience building scalable infrastructure and helping organizations optimize their technology stack.

Related Articles