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