Overview

The earlier code quality issues are caught, the cheaper they are to fix. Catching issues at commit time is faster than at CI time, and catching them at CI time is faster than fixing them after they cause production incidents. Git Hooks provide the ability to automatically run checks at critical points — commit and push — including code formatting, linting, commit message validation, and test execution. This article covers everything from Git Hooks fundamentals to toolchain practices, building a code quality automation system from local to CI.

References: Git Hooks Official Documentation, pre-commit Official Site

I. The Git Hooks System

1.1 Hooks Overview

Git Hooks are scripts that Git automatically executes at specific operations (such as commit, push, merge). They are stored in the .git/hooks/ directory:

Hook NameTrigger TimingCommon Use CasesSkippable
pre-commitBefore commit executionCode formatting, lint checks--no-verify
prepare-commit-msgBefore editing commit messageAuto-generate commit message template-
commit-msgAfter commit message is writtenValidate commit message format--no-verify
post-commitAfter commit completesNotifications, statisticsNo
pre-pushBefore push executionRun tests, prevent pushing to protected branches--no-verify
pre-merge-commitBefore merge completesPre-merge checks-
post-mergeAfter merge completesRestore dependencies, update submodulesNo
pre-rebaseBefore rebase executionPrevent rebasing pushed commits-
post-checkoutAfter branch switchRestore environmentNo

1.2 Native Hook Example

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

set -euo pipefail

echo "Running pre-commit checks..."

# 1. Check for leftover debug code
if git diff --cached | grep -E '^\+.*\b(debugger|console\.log|print\(|fmt\.Println)\b'; then
    echo "✗ Debug code found, please remove before committing"
    exit 1
fi

# 2. Check for unprocessed TODO/FIXME
if git diff --cached | grep -E '^\+.*\b(TODO|FIXME|HACK|XXX)\b'; then
    echo "⚠ TODO/FIXME found, please confirm if action is needed"
    # Don't block the commit, just warn
fi

# 3. Check for trailing whitespace
if git diff --cached | grep -E '^\+.*[ \t]+$'; then
    echo "✗ Trailing whitespace found, please clean up"
    # Auto-fix
    git diff --cached --name-only | xargs sed -i 's/[[:space:]]*$//'
    echo "Trailing whitespace auto-cleaned, please re-run git add"
    exit 1
fi

# 4. Check file permissions
while IFS= read -r file; do
    if [[ -f "$file" ]] && [[ -x "$file" ]] && [[ "$file" != *.sh ]]; then
        echo "✗ $file should not have execute permission"
        exit 1
    fi
done < <(git diff --cached --name-only --diff-filter=ACM)

echo "✓ Pre-commit checks passed"

1.3 Limitations of Native Hooks

Problem 1: .git/hooks/ is not under version control, cannot be shared across the team
Problem 2: Each developer needs to manually install hooks
Problem 3: Managing dependencies and execution order between hooks is difficult
Problem 4: Multi-language projects require configuring multiple tools, manual management is complex

The solution is to use Hook management tools: pre-commit (Python ecosystem) and Husky (Node.js ecosystem).

II. pre-commit

2.1 Installation

# Install via pip
pip install pre-commit

# macOS
brew install pre-commit

# Verify
pre-commit --version

2.2 Configuration File

Create .pre-commit-config.yaml in the project root:

# .pre-commit-config.yaml
default_language_version:
  python: python3
  node: 20.0.0

# Exclude paths
exclude: |
  (?x)^(
      vendor/.*|
      third_party/.*|
      .*\.pb\.go|
      .*\.gen\.go|
      docs/.*
  )$  

repos:
  # === General checks ===
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace        # Remove trailing whitespace
      - id: end-of-file-fixer          # Ensure files end with a newline
      - id: check-yaml                 # YAML syntax check
      - id: check-json                 # JSON syntax check
      - id: check-merge-conflict       # Check for merge conflict markers
      - id: check-added-large-files    # Prevent committing large files
        args: ['--maxkb=500']
      - id: check-case-conflict        # Check for filename case conflicts
      - id: check-symlinks             # Check symbolic links
      - id: detect-private-key         # Detect private keys
      - id: mixed-line-ending          # Normalize line endings
        args: ['--fix=lf']
      - id: requirements-txt-fixer     # Fix requirements.txt

  # === Python projects ===
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.0
    hooks:
      - id: ruff                        # Lint
        args: ['--fix']
      - id: ruff-format                 # Format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]
        args: ['--config-file=pyproject.toml']

  # === Go projects ===
  - repo: https://github.com/dnephin/pre-commit-golang
    rev: v0.5.1
    hooks:
      - id: go-fmt
      - id: go-imports
      - id: go-mod-tidy
      - id: go-unit-tests
        args: ['-short']

  - repo: https://github.com/golangci/golangci-lint
    rev: v1.59.0
    hooks:
      - id: golangci-lint
        args: ['--config=.golangci.yml']

  # === JavaScript/TypeScript projects ===
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v9.3.0
    hooks:
      - id: eslint
        files: \.(js|ts|jsx|tsx)$
        types: [file]
        args: ['--fix']

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        types_or: [javascript, ts, css, html, json, yaml, markdown]

  # === Shell scripts ===
  - repo: https://github.com/scop/pre-commit-shfmt
    rev: v3.8.0
    hooks:
      - id: shfmt
        args: ['-i', '4', '-w']

  - repo: https://github.com/koalaman/shellcheck-precommit
    rev: v0.10.0
    hooks:
      - id: shellcheck
        args: ['-x']

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

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

  # === Commit message checks ===
  - repo: https://github.com/compilerla/conventional-pre-commit
    rev: v3.2.0
    hooks:
      - id: conventional-pre-commit
        stages: [commit-msg]
        args: [feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert]

  # === Dockerfile ===
  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint-docker
        args: ['--ignore', 'DL3008']

  # === Markdown ===
  - repo: https://github.com/igorshubovych/markdownlint-cli
    rev: v0.41.0
    hooks:
      - id: markdownlint
        args: ['--fix']

2.3 Installation and Usage

# Install hooks to .git/hooks/
pre-commit install

# Also install commit-msg hook
pre-commit install --hook-type commit-msg

# Manually run all checks
pre-commit run --all-files

# Run only a specific hook
pre-commit run trailing-whitespace --all-files

# Check only staged files
pre-commit run

# Update hook versions
pre-commit autoupdate

# Clean cache
pre-commit clean
pre-commit gc

# Show status
pre-commit run --show-stages

2.4 Local Hook Configuration

Developers can add local custom hooks alongside .pre-commit-config.yaml:

repos:
  # ... other hooks ...

  # Local custom hooks
  - repo: local
    hooks:
      - id: go-build
        name: Go Build
        entry: go build ./...
        language: system
        files: \.go$
        pass_filenames: false

      - id: go-test
        name: Go Test
        entry: go test -short ./...
        language: system
        files: \.go$
        pass_filenames: false
        stages: [push]  # Only run on pre-push

      - id: custom-check
        name: Custom Check
        entry: ./scripts/custom-check.sh
        language: script
        files: \.(go|py|js|ts)$

2.5 Running pre-commit in CI

# .github/workflows/pre-commit.yml
name: Pre-commit Checks
on:
  pull_request:
  push:
    branches: [main]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - name: Install pre-commit
        run: pip install pre-commit
      - name: Cache pre-commit
        uses: actions/cache@v4
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
      - name: Run pre-commit
        run: pre-commit run --all-files

III. Husky

3.1 Installation

# Initialize npm project (if not already)
npm init -y

# Install Husky
npm install --save-dev husky

# Initialize Husky
npx husky init
# This creates the .husky/ directory and adds a prepare script to package.json

3.2 Configuring Hooks

# Create pre-commit hook
echo 'npx lint-staged' > .husky/pre-commit

# Create commit-msg hook
echo 'npx --no-install commitlint --edit $1' > .husky/commit-msg

# Create pre-push hook
echo 'npm test' > .husky/pre-push

3.3 lint-staged

lint-staged only checks staged files, avoiding running checks on the entire project:

// package.json
{
  "lint-staged": {
    "*.{js,ts,jsx,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss,html,json,md,yaml,yml}": [
      "prettier --write"
    ],
    "*.py": [
      "ruff check --fix",
      "ruff format"
    ],
    "*.go": [
      "gofmt -w",
      "goimports -w"
    ],
    "*.sh": [
      "shfmt -w",
      "shellcheck"
    ],
    "Dockerfile": [
      "hadolint"
    ]
  }
}

3.4 Complete Node.js Project Configuration

// package.json
{
  "name": "my-project",
  "version": "1.0.0",
  "scripts": {
    "prepare": "husky",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "eslint": "^9.0.0",
    "prettier": "^3.0.0",
    "@commitlint/cli": "^19.0.0",
    "@commitlint/config-conventional": "^19.0.0"
  },
  "lint-staged": {
    "*.{js,ts}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yaml}": ["prettier --write"]
  }
}
// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',     // New feature
        'fix',      // Bug fix
        'docs',     // Documentation changes
        'style',    // Code formatting (no functional impact)
        'refactor', // Refactoring (no functional impact)
        'perf',     // Performance optimization
        'test',     // Tests
        'build',    // Build system or external dependency changes
        'ci',       // CI configuration changes
        'chore',    // Miscellaneous (no source code or test changes)
        'revert'    // Revert
      ]
    ],
    'subject-max-length': [2, 'always', 72],
    'body-max-line-length': [1, 'always', 100],
  }
};

3.5 .husky Directory Structure

.husky/
├── _/                    # Husky internal files (do not modify)
│   ├── h
│   └── husky.sh
├── pre-commit            # pre-commit hook
├── commit-msg            # commit-msg hook
├── pre-push              # pre-push hook
└── post-merge            # post-merge hook
#!/usr/bin/env sh
# .husky/pre-commit
# Husky v9+ format (no need to source husky.sh)

# Run lint-staged
npx lint-staged

# Run type checking
npx tsc --noEmit

# Check for console.log
if git diff --cached | grep -E '^\+.*console\.log'; then
    echo "✗ Please remove console.log"
    exit 1
fi

IV. commitlint

4.1 Commit Message Convention

Conventional Commits is the most widely used commit message convention:

<type>(<scope>): <subject>

<body>

<footer>
TypeDescriptionExample
featNew featurefeat(auth): add OAuth2 login
fixBug fixfix(api): fix user list pagination error
docsDocumentationdocs: update README installation steps
styleFormattingstyle: unify indentation to 2 spaces
refactorRefactoringrefactor(db): refactor connection pool management
perfPerformanceperf(query): optimize N+1 slow query
testTeststest(auth): add login unit tests
buildBuildbuild: upgrade Go to 1.22
ciCIci: add GitHub Actions workflow
choreMiscellaneouschore: update dependency versions
revertRevertrevert: revert feat(auth) commit

4.2 commitlint Configuration

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Type enum
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']
    ],
    // Type must be lowercase
    'type-case': [2, 'always', 'lower-case'],
    // Type must not be empty
    'type-empty': [2, 'never'],
    // Scope format
    'scope-case': [2, 'always', 'lower-case'],
    // Subject must not be empty
    'subject-empty': [2, 'never'],
    // Subject must not end with .
    'subject-full-stop': [2, 'never', '.'],
    // Subject case
    'subject-case': [0],  // Disabled (allows non-English)
    // Subject max length
    'subject-max-length': [2, 'always', 72],
    // Subject min length
    'subject-min-length': [2, 'always', 3],
    // Header max length
    'header-max-length': [2, 'always', 100],
    // Body max line length
    'body-max-line-length': [1, 'always', 100],
    // Footer max line length
    'footer-max-line-length': [1, 'always', 100],
  }
};

4.3 Validating Commit Messages

# Install
npm install --save-dev @commitlint/cli @commitlint/config-conventional

# Validate commit message
echo "feat: add new feature" | npx commitlint
# ✓ passes

echo "update something" | npx commitlint
# ✗ fails: missing type

echo "FEAT: add feature" | npx commitlint
# ✗ fails: type must be lowercase

# Validate from file
npx commitlint --edit .git/COMMIT_EDITMSG

# Validate from git log
git log --format=%s | npx commitlint

4.4 Interactive Commit Tool: commitizen

# Install
npm install --save-dev commitizen cz-conventional-changelog

# Configure
echo '{"config": {"commitizen": {"path": "cz-conventional-changelog"}}}' >> package.json

# Use interactive commit
npx cz
# ? Select the type of change that you're committing:
# ❯ feat:     A new feature
#   fix:      A bug fix
#   docs:     Documentation only changes
#   ...
# ? What is the scope of this change (e.g. component or file name)?
# ? Write a short, imperative tense description of the change:
# ? Provide a longer description of the change:
# ? Are there any breaking changes?
# ? Does this change affect any open issues?

V. Automated Test Triggering

5.1 Running Fast Tests in pre-commit

#!/usr/bin/env sh
# .husky/pre-commit

# Fast checks (< 10 seconds)
npx lint-staged

# Type checking
npx tsc --noEmit

# Run only affected tests
# Get staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|js)$')

if [ -n "$STAGED_FILES" ]; then
    echo "Running related tests..."
    npx jest --findRelatedTests $STAGED_FILES --passWithNoTests
fi

5.2 Running Full Tests in pre-push

#!/usr/bin/env sh
# .husky/pre-push

echo "Running pre-push checks..."

# 1. Full test suite
echo "Running test suite..."
npm test
if [ $? -ne 0 ]; then
    echo "✗ Tests failed, push blocked"
    echo "To skip, use: git push --no-verify"
    exit 1
fi

# 2. Check if pushing to a protected branch
PROTECTED_BRANCHES="main master release/*"
BRANCH=$(git rev-parse --abbrev-ref HEAD)

for protected in $PROTECTED_BRANCHES; do
    if [[ "$BRANCH" == $protected ]]; then
        echo "⚠ You are pushing to a protected branch: $BRANCH"
        echo "Confirm push? (y/N)"
        read -r response
        if [ "$response" != "y" ] && [ "$response" != "Y" ]; then
            echo "Push cancelled"
            exit 1
        fi
    fi
done

# 3. Check if main branch sync is needed
if [[ "$BRANCH" != "main" ]] && [[ "$BRANCH" != "master" ]]; then
    MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | awk '{print $NF}')
    BEHIND=$(git rev-list --count HEAD..origin/$MAIN_BRANCH 2>/dev/null || echo 0)
    if [ "$BEHIND" -gt 0 ]; then
        echo "⚠ Current branch is behind $MAIN_BRANCH by $BEHIND commit(s)"
        echo "Consider rebasing: git rebase origin/$MAIN_BRANCH"
    fi
fi

echo "✓ Pre-push checks passed"

5.3 Go Project Test Hook

#!/usr/bin/env bash
# .husky/pre-commit (Go project)

set -euo pipefail

# Get staged Go files
STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' || true)

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

echo "Running Go code checks..."

# 1. Format
echo "  Formatting code..."
gofmt -w $STAGED_GO_FILES
git add $STAGED_GO_FILES

# 2. goimports
if command -v goimports &>/dev/null; then
    echo "  Organizing imports..."
    goimports -w $STAGED_GO_FILES
    git add $STAGED_GO_FILES
fi

# 3. golangci-lint
echo "  Running golangci-lint..."
golangci-lint run --new-from-rev=HEAD $STAGED_GO_FILES

# 4. Build check
echo "  Build check..."
go build ./...

# 5. Run related tests
echo "  Running tests..."
for file in $STAGED_GO_FILES; do
    # Find corresponding test file
    dir=$(dirname "$file")
    pkg=$(basename "$dir")
    test_file="${dir}/${pkg}_test.go"
    if [ -f "$test_file" ]; then
        go test -short -count=1 "./${dir}/..."
    fi
done

echo "✓ Go code checks passed"

VI. CI and Local Hook Coordination

6.1 Layered Check Strategy

Local pre-commit:  Formatting + fast lint + type checking      (< 10s)
Local pre-push:     Full test suite                               (< 60s)
CI (PR):           Full lint + tests + security scan + build     (< 5min)
CI (main):         Deploy to staging + integration tests          (< 10min)

6.2 Single Source of Configuration

Avoid configuration drift between local and CI by using the same configuration:

# .github/workflows/quality.yml
name: Code Quality
on: [pull_request]

jobs:
  # Same checks as pre-commit
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install pre-commit
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
      - run: pre-commit run --all-files

  # CI-only checks
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Trivy
        run: |
          trivy fs --severity HIGH,CRITICAL .          
      - name: Run GoSec
        run: |
          go install github.com/securego/gosec/v2/cmd/gosec@latest
          gosec ./...          

  # Test coverage
  test-coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go test -race -coverprofile=coverage.out ./...
      - name: Check coverage
        run: |
          COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
          echo "Coverage: $COVERAGE"
          # Minimum coverage requirement
          if (( $(echo "$COVERAGE < 70.0" | bc -l) )); then
            echo "✗ Coverage below 70%"
            exit 1
          fi          

6.3 Managing Hook Skips

--no-verify is a double-edged sword: sometimes you need to skip (e.g., hotfixes), but abuse renders hooks useless.

# .husky/pre-commit
#!/usr/bin/env sh

# Allow skipping via commit message containing [skip lint]
COMMIT_MSG=$(cat .git/COMMIT_EDITMSG 2>/dev/null || echo "")
if echo "$COMMIT_MSG" | grep -q '\[skip lint\]'; then
    echo "⚠ Skipping lint checks (commit message contains [skip lint])"
    exit 0
fi

npx lint-staged
# CI: detect commits that skipped hooks
name: Verify Hooks
on: [pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Check for --no-verify commits
        run: |
          # Check if PR commits skipped hooks
          # If pre-commit finds issues in CI but not locally,
          # the developer likely used --no-verify
          if pre-commit run --all-files 2>&1 | grep -q "Failed"; then
            echo "✗ Code checks failed (local checks may have been skipped with --no-verify)"
            exit 1
          fi          

VII. Team Standards Enforcement

7.1 Quick Project Initialization

#!/usr/bin/env bash
# setup-git-hooks.sh - One-click Git Hooks configuration for projects

set -euo pipefail

PROJECT_TYPE="${1:-go}"  # go, node, python, mixed

echo "Configuring Git Hooks ($PROJECT_TYPE project)..."

# 1. Copy configuration files
case "$PROJECT_TYPE" in
    go)
        cat > .pre-commit-config.yaml << 'EOF'
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-merge-conflict
      - id: check-added-large-files
        args: ['--maxkb=500']
      - id: detect-private-key

  - repo: https://github.com/dnephin/pre-commit-golang
    rev: v0.5.1
    hooks:
      - id: go-fmt
      - id: go-imports
      - id: go-mod-tidy

  - repo: https://github.com/golangci/golangci-lint
    rev: v1.59.0
    hooks:
      - id: golangci-lint

  - repo: local
    hooks:
      - id: go-build
        name: Go Build
        entry: go build ./...
        language: system
        files: \.go$
        pass_filenames: false
EOF
        ;;
    node)
        npm install --save-dev husky lint-staged eslint prettier \
            @commitlint/cli @commitlint/config-conventional
        npx husky init
        echo 'npx lint-staged' > .husky/pre-commit
        echo 'npx --no-install commitlint --edit $1' > .husky/commit-msg
        ;;
    python)
        cat > .pre-commit-config.yaml << 'EOF'
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.0
    hooks:
      - id: ruff
        args: ['--fix']
      - id: ruff-format
EOF
        ;;
esac

# 2. Install pre-commit (if used)
if [ -f .pre-commit-config.yaml ]; then
    pip install pre-commit 2>/dev/null || true
    pre-commit install
    pre-commit install --hook-type commit-msg
    echo "✓ pre-commit installed"
fi

# 3. Add commitlint (if using Husky)
if [ -d .husky ]; then
    cat > commitlint.config.js << 'EOF'
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'subject-max-length': [2, 'always', 72],
  }
};
EOF
    echo "✓ commitlint configured"
fi

# 4. Add to .gitignore
grep -qxF '.husky/_/' .gitignore 2>/dev/null || echo '.husky/_/' >> .gitignore

echo ""
echo "Git Hooks configuration complete!"
echo "  - Code formatting and lint run automatically on commit"
echo "  - Commit messages must follow Conventional Commits"
echo "  - To skip: git commit --no-verify"

7.2 Team Onboarding Documentation

# Code Quality Tooling Setup

## Initial Setup

1. Install dependencies:
   ```bash
   # Python tools
   pip install pre-commit

   # Node.js tools (if project includes frontend code)
   npm install
  1. Install Git Hooks:

    pre-commit install
    pre-commit install --hook-type commit-msg
    # or
    npm run prepare  # Husky
    
  2. Verify:

    pre-commit run --all-files
    

Commit Message Format

<type>(<scope>): <subject>

<body>

<footer>

Types: feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert

Examples:

feat(auth): add OAuth2 login support
fix(api): fix user list pagination error (#123)
docs: update deployment documentation

Skipping Checks

Skip in emergencies:

git commit --no-verify -m "hotfix: urgent production issue"

Note: CI will re-run checks. Skipping local checks does not skip CI.


### 7.3 Gradual Rollout Strategy

Phase 1 (Weeks 1-2): Observation period

  • Install hooks but set to warning mode (don’t block commits)
  • Collect common issues, count violations

Phase 2 (Weeks 3-4): Auto-formatting

  • Enable auto-formatting (gofmt, prettier, ruff format)
  • Enable trailing-whitespace, end-of-file-fixer
  • Developer experience: formatting auto-corrected on commit, no extra burden

Phase 3 (Weeks 5-6): Lint enforcement

  • Enable lint checks, block non-compliant commits
  • Provide fix guides and documentation
  • Allow [skip lint] for emergencies

Phase 4 (Weeks 7-8): Commit message standards

  • Enable commitlint
  • Pair with commitizen interactive tool to lower the barrier

Phase 5 (Ongoing): CI enforcement

  • CI runs the same checks as local
  • Gradually increase coverage thresholds
  • Integrate security scanning into the pipeline

## VIII. Common Issues and Solutions

### 8.1 Hooks Are Too Slow

```yaml
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      # Only check staged files, not the entire project
      - id: eslint-staged
        name: ESLint (staged only)
        entry: npx eslint
        language: system
        files: \.(js|ts)$
        # pre-commit only passes staged files by default

      # Separate fast and slow checks
      - id: type-check
        name: TypeScript Type Check
        entry: npx tsc --noEmit
        language: system
        files: \.ts$
        pass_filenames: false
        stages: [push]  # Move to pre-push

8.2 Hooks Pass Locally but Fail in CI

# Common cause: local cache causing inconsistent results

# Clean pre-commit cache
pre-commit clean
pre-commit gc

# Reinstall
pre-commit install
pre-commit run --all-files

# Ensure no cache is used in CI
pre-commit run --all-files --show-diff-on-failure

8.3 Multi-Language Project Configuration

# .pre-commit-config.yaml - Multi-language project
repos:
  # General checks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks: &default_hooks
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-merge-conflict
      - id: detect-private-key

  # Python code
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.0
    hooks:
      - id: ruff
        args: ['--fix']
      - id: ruff-format

  # Go code
  - repo: https://github.com/dnephin/pre-commit-golang
    rev: v0.5.1
    hooks:
      - id: go-fmt
      - id: go-imports

  # Shell scripts
  - repo: https://github.com/koalaman/shellcheck-precommit
    rev: v0.10.0
    hooks:
      - id: shellcheck

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

Summary

Git Hooks are the first line of defense in code quality automation. Their value lies in intercepting issues before they enter the codebase. Key takeaways:

  1. Hooks are a native capability; tools are the management layer: Git has a built-in Hooks mechanism, but .git/hooks/ is not under version control. pre-commit and Husky solve the problem of “configuration sharing and automated installation”
  2. pre-commit is ideal for multi-language projects: Through .pre-commit-config.yaml, it uniformly manages checking tools for all languages, with a rich community ecosystem — Go/Python/Shell/JSON/YAML all have ready-made hooks
  3. Husky + lint-staged is the Node.js standard: Husky manages hook installation, lint-staged checks only staged files — together they enable efficient incremental checking
  4. commitlint standardizes commit messages: The Conventional Commits format not only makes git log cleaner but also enables automatic changelog generation and semantic versioning
  5. Layered checks avoid slowing down development: pre-commit handles formatting and fast lint (< 10s), pre-push handles full tests (< 60s), CI handles security scanning and coverage checks (< 5min). Each layer does only what it should
  6. CI is the ultimate safeguard: Local hooks can be skipped with --no-verify, but CI cannot. CI runs the same check configuration as local, ensuring that even if local checks were skipped, issues are caught at the PR stage
  7. Gradual rollout is key: Don’t enable all checks at once. Observe → auto-fix → enforce lint → commit standards → CI enforcement — phase it in to give the team time to adapt

The ultimate goal of Git Hooks is not to block commits, but to ensure that every commit automatically meets team standards. When formatting, linting, and testing become automatic behaviors at commit time, developers don’t need to “remember” the rules — the rules enforce themselves.

References & Acknowledgments

This article referenced the following materials during writing. We thank the original authors for their contributions:

  1. Git Hooks Official Documentation — Git-scm, referenced for Git Hooks Official Documentation
  2. pre-commit Official Site — Pre-commit, referenced for pre-commit Official Site