Skip to content

Git Hooks - Automation That Guards Your Code 🪝

ROLE: HPN (DevOps Automation).
AUDIENCE: Engineers muốn tự động hóa quy trình và enforce standards.

Git Hooks là các scripts tự động chạy trước/sau các Git events. Chúng là gateway guardians - chặn code xấu trước khi nó vào repository, enforce conventions, và tự động hóa các task lặp đi lặp lại.


🎯 Mục tiêu

Sau module này, bạn sẽ:

  • Hiểu Git Hooks hoạt động thế nào
  • Thiết lập pre-commit để chặn code không đạt chuẩn
  • Enforce Conventional Commits với commit-msg hook
  • Chạy tests tự động trước khi push với pre-push
  • Viết hooks thực tế bằng Bash và Python

📍 Hook Basics

Hooks nằm ở đâu?

.git/hooks/
├── pre-commit.sample
├── commit-msg.sample
├── pre-push.sample
├── post-merge.sample
└── ... (nhiều samples khác)

Kích hoạt Hook

bash
# Remove .sample extension và chmod +x
cd .git/hooks
cp pre-commit.sample pre-commit
chmod +x pre-commit

Hook Lifecycle


🔑 Key Hooks Reference

Client-side Hooks

HookTriggerUse Case
pre-commitTrước khi commitLint, format, security checks
prepare-commit-msgSau khi message được tạoAuto-add ticket numbers
commit-msgSau khi user nhập messageValidate message format
post-commitSau khi commit hoàn tấtNotifications, logging
pre-pushTrước khi pushRun tests, type checks
post-checkoutSau checkout/switchInstall dependencies
post-mergeSau mergeRebuild, update deps

Server-side Hooks

HookTriggerUse Case
pre-receiveTrước khi chấp nhận pushPolicy enforcement
updateMỗi branch được pushPer-branch rules
post-receiveSau khi push hoàn tấtCI/CD trigger, deploy

🛡️ pre-commit - The First Line of Defense

Purpose

Chạy trước mỗi commit. Nếu script exit với code khác 0, commit bị chặn.

Use Cases

  • Linting (ESLint, Pylint, Flake8)
  • Formatting (Prettier, Black)
  • Security scanning (detect secrets, private keys)
  • Code quality (no console.log, no TODO in production)

Example 1: Block TODO Comments

bash
#!/bin/bash
# .git/hooks/pre-commit
# Block commits containing TODO or FIXME

echo "🔍 Checking for TODO/FIXME comments..."

# Get staged files (only the ones being committed)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|py|go|java)$')

if [ -z "$STAGED_FILES" ]; then
    exit 0
fi

# Search for TODO/FIXME in staged files
FOUND_ISSUES=0
for FILE in $STAGED_FILES; do
    # Check staged content, not working directory
    if git show ":$FILE" | grep -inE "(TODO|FIXME)" > /dev/null 2>&1; then
        echo "❌ Found TODO/FIXME in: $FILE"
        git show ":$FILE" | grep -inE "(TODO|FIXME)" | head -5
        FOUND_ISSUES=1
    fi
done

if [ $FOUND_ISSUES -eq 1 ]; then
    echo ""
    echo "⛔ Commit blocked! Remove TODO/FIXME comments first."
    echo "💡 Hoặc dùng: git commit --no-verify để bypass (không khuyến khích)"
    exit 1
fi

echo "✅ No TODO/FIXME found. Proceeding..."
exit 0

Example 2: Detect Private Keys & Secrets

bash
#!/bin/bash
# .git/hooks/pre-commit
# Security: Block commits containing secrets

echo "🔐 Scanning for secrets and private keys..."

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

if [ -z "$STAGED_FILES" ]; then
    exit 0
fi

# Patterns to detect
PATTERNS=(
    # AWS
    "AKIA[0-9A-Z]{16}"
    # Private keys
    "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
    # Generic secrets
    "(password|secret|api_key|apikey|access_token)\s*[:=]\s*['\"][^'\"]{8,}['\"]"
    # GitHub tokens
    "ghp_[a-zA-Z0-9]{36}"
    "gho_[a-zA-Z0-9]{36}"
)

FOUND_SECRETS=0

for FILE in $STAGED_FILES; do
    for PATTERN in "${PATTERNS[@]}"; do
        if git show ":$FILE" 2>/dev/null | grep -iE "$PATTERN" > /dev/null 2>&1; then
            echo "🚨 SECURITY WARNING in: $FILE"
            echo "   Pattern matched: $PATTERN"
            FOUND_SECRETS=1
        fi
    done
done

if [ $FOUND_SECRETS -eq 1 ]; then
    echo ""
    echo "⛔ COMMIT BLOCKED - Secrets detected!"
    echo "📝 Actions:"
    echo "   1. Remove the secrets from your code"
    echo "   2. Use environment variables instead"
    echo "   3. Add sensitive files to .gitignore"
    exit 1
fi

echo "✅ No secrets detected. Proceeding..."
exit 0

Example 3: Python Version with Comprehensive Checks

python
#!/usr/bin/env python3
"""
.git/hooks/pre-commit
Comprehensive pre-commit hook in Python
"""

import subprocess
import sys
import re

# Configuration
BLOCKED_PATTERNS = [
    (r'TODO|FIXME|XXX', 'Unfinished work marker'),
    (r'console\.log\(', 'Debug console.log'),
    (r'debugger;', 'Debugger statement'),
    (r'binding\.pry', 'Ruby debugger'),
    (r'import pdb', 'Python debugger'),
]

SECRET_PATTERNS = [
    (r'AKIA[0-9A-Z]{16}', 'AWS Access Key'),
    (r'-----BEGIN (?:RSA |EC )?PRIVATE KEY-----', 'Private Key'),
    (r'ghp_[a-zA-Z0-9]{36}', 'GitHub Personal Token'),
    (r'sk-[a-zA-Z0-9]{48}', 'OpenAI API Key'),
]

def get_staged_files():
    """Get list of staged files."""
    result = subprocess.run(
        ['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'],
        capture_output=True, text=True
    )
    return [f for f in result.stdout.strip().split('\n') if f]

def get_staged_content(filepath):
    """Get the staged content of a file."""
    result = subprocess.run(
        ['git', 'show', f':{filepath}'],
        capture_output=True, text=True
    )
    return result.stdout

def check_patterns(content, patterns, filepath):
    """Check content against patterns."""
    issues = []
    for line_num, line in enumerate(content.split('\n'), 1):
        for pattern, description in patterns:
            if re.search(pattern, line, re.IGNORECASE):
                issues.append({
                    'file': filepath,
                    'line': line_num,
                    'description': description,
                    'content': line.strip()[:60]
                })
    return issues

def main():
    print("🔍 Running pre-commit checks...")
    
    staged_files = get_staged_files()
    if not staged_files:
        print("✅ No files to check.")
        return 0
    
    all_issues = []
    security_issues = []
    
    for filepath in staged_files:
        content = get_staged_content(filepath)
        
        # Check for blocked patterns
        issues = check_patterns(content, BLOCKED_PATTERNS, filepath)
        all_issues.extend(issues)
        
        # Check for secrets
        secrets = check_patterns(content, SECRET_PATTERNS, filepath)
        security_issues.extend(secrets)
    
    # Report security issues (higher priority)
    if security_issues:
        print("\n🚨 SECURITY ISSUES DETECTED:")
        for issue in security_issues:
            print(f"  ❌ {issue['file']}:{issue['line']}")
            print(f"     {issue['description']}")
        print("\n⛔ Commit BLOCKED - Remove secrets before committing!")
        return 1
    
    # Report code quality issues
    if all_issues:
        print("\n⚠️  Code quality issues:")
        for issue in all_issues:
            print(f"  ⚡ {issue['file']}:{issue['line']} - {issue['description']}")
        print(f"\n⛔ Commit BLOCKED - Fix {len(all_issues)} issue(s) first.")
        print("💡 Use 'git commit --no-verify' to bypass (not recommended)")
        return 1
    
    print(f"✅ All {len(staged_files)} files passed checks!")
    return 0

if __name__ == '__main__':
    sys.exit(main())
)]

📝 commit-msg - Enforce Message Standards

Conventional Commits Format

<type>(<scope>): <description>

[optional body]

[optional footer(s)]

Types: feat, fix, docs, style, refactor, test, chore

Bash Implementation

#!/bin/bash

.git/hooks/commit-msg

Enforce Conventional Commits

COMMIT_MSG_FILE=1COMMITMSG=(cat "$COMMIT_MSG_FILE")

Conventional Commits regex

PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(([a-z0-9-]+))?: .{1,72}$"

Get first line only

FIRST_LINE=(echo"COMMIT_MSG" | head -1)

if ! echo "FIRSTLINE"|grepqE"PATTERN"; then echo "" echo "❌ Commit message không đúng format Conventional Commits!" echo "" echo "📝 Format: <type>(<scope>): <description>" echo "" echo "🏷️ Valid types:" echo " feat - New feature" echo " fix - Bug fix" echo " docs - Documentation" echo " style - Formatting (no code change)" echo " refactor - Code restructuring" echo " test - Adding tests" echo " chore - Maintenance" echo " perf - Performance" echo " ci - CI/CD changes" echo "" echo "📌 Examples:" echo " feat(auth): add OAuth2 login" echo " fix: resolve memory leak in cache" echo " docs(readme): update installation guide" echo "" echo "❓ Your message: "$FIRST_LINE"" echo "" exit 1 fi

Check description length (max 72 chars for first line)

DESC_LENGTH=${#FIRST_LINE} if [ DESCLENGTHgt72];thenecho"Firstlinetoolong(DESC_LENGTH chars). Max 72." exit 1 fi

echo "✅ Commit message valid: $FIRST_LINE" exit 0 } exit 0


### Python Implementation

```python
#!/usr/bin/env python3
"""
.git/hooks/commit-msg
Enforce Conventional Commits with Python
"""

import sys
import re

VALID_TYPES = [
    'feat', 'fix', 'docs', 'style', 'refactor',
    'test', 'chore', 'perf', 'ci', 'build', 'revert'
]

PATTERN = re.compile(
    r'^(?P<type>' + '|'.join(VALID_TYPES) + r')'
    r'(?:\((?P<scope>[a-z0-9-]+)\))?'
    r': '
    r'(?P<description>.{1,72})$'
)

def main():
    commit_msg_file = sys.argv[1]
    
    with open(commit_msg_file, 'r') as f:
        commit_msg = f.read()
    
    first_line = commit_msg.split('\n')[0]
    
    # Skip merge commits
    if first_line.startswith('Merge'):
        return 0
    
    match = PATTERN.match(first_line)
    
    if not match:
        print("\n❌ Invalid commit message format!")
        print(f"\n   Your message: \"{first_line}\"")
        print("\n📝 Expected: &lt;type&gt;(&lt;scope&gt;): &lt;description&gt;")
        print(f"\n🏷️  Valid types: {', '.join(VALID_TYPES)}")
        print("\n📌 Examples:")
        print("   feat(auth): add OAuth2 login")
        print("   fix: resolve memory leak")
        return 1
    
    print(f"✅ Valid: [{match.group('type')}] {match.group('description')}")
    return 0

if __name__ == '__main__':
    sys.exit(main())

🚀 pre-push - The Final Gate

Run Tests Before Push

bash
#!/bin/bash
# .git/hooks/pre-push
# Run tests before allowing push

echo "🧪 Running tests before push..."

# Determine project type and run appropriate tests
if [ -f "package.json" ]; then
    echo "📦 Node.js project detected"
    npm test
    TEST_EXIT=$?
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
    echo "🐍 Python project detected"
    python -m pytest -q
    TEST_EXIT=$?
elif [ -f "go.mod" ]; then
    echo "🐹 Go project detected"
    go test ./...
    TEST_EXIT=$?
else
    echo "⚠️  No test configuration found. Skipping."
    TEST_EXIT=0
fi

if [ $TEST_EXIT -ne 0 ]; then
    echo ""
    echo "⛔ Push BLOCKED - Tests failed!"
    echo "💡 Fix failing tests before pushing."
    exit 1
fi

echo "✅ All tests passed. Pushing..."
exit 0

Prevent Push to Protected Branches

bash
#!/bin/bash
# .git/hooks/pre-push
# Block direct push to main/master

PROTECTED_BRANCHES="^(main|master|production)$"

while read local_ref local_sha remote_ref remote_sha; do
    # Extract branch name
    BRANCH=$(echo "$remote_ref" | sed 's|refs/heads/||')
    
    if echo "$BRANCH" | grep -qE "$PROTECTED_BRANCHES"; then
        echo "⛔ Direct push to '$BRANCH' is NOT allowed!"
        echo "📝 Please create a Pull Request instead."
        exit 1
    fi
done

exit 0

🔧 Sharing Hooks with Team

Problem

.git/hooks/ không được tracked bởi Git → Không thể share với team.

Solution: Tracked Hooks Directory

bash
# 1. Tạo thư mục hooks trong repo
mkdir -p .githooks

# 2. Copy hooks vào đó
cp .git/hooks/pre-commit .githooks/

# 3. Config Git để dùng thư mục này
git config core.hooksPath .githooks

# 4. Commit thư mục
git add .githooks
git commit -m "chore: add shared Git hooks"

Team Setup Script

bash
#!/bin/bash
# setup-hooks.sh
# Team members run this after clone

echo "🔧 Setting up Git hooks..."

git config core.hooksPath .githooks

# Make hooks executable
chmod +x .githooks/*

echo "✅ Git hooks configured!"
echo "📍 Hooks location: .githooks/"

🛠️ Modern Alternative: Husky (Node.js)

Nếu dùng Node.js, Husky là cách hiện đại hơn:

bash
# Install
npm install husky --save-dev

# Enable Git hooks
npx husky install

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

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

📊 Quick Reference

HookExit 0Exit Non-ZeroBypass
pre-commitAllow commitBlock commit--no-verify
commit-msgAccept messageReject message--no-verify
pre-pushAllow pushBlock push--no-verify

💡 Key Takeaways

HPN'S INSIGHT

"Hooks là automation đầu tiên bạn nên setup. Chúng chặn 90% lỗi ngớ ngẩn trước khi chúng vào history."

  1. Exit code quyết định tất cả: 0 = pass, non-zero = block
  2. Check staged content: Dùng git show :file, không phải working directory
  3. Share hooks với team: Dùng core.hooksPath hoặc Husky
  4. Escape hatch: --no-verify để bypass (dùng có trách nhiệm)
  5. Layer your defenses: pre-commit (lint) → commit-msg (format) → pre-push (tests)

PRODUCTION RULE

Mọi repo production phải có ít nhất:

  • pre-commit: Lint + Secret scanning
  • commit-msg: Conventional Commits
  • pre-push: Tests phải pass