SuperAPI AI Directory

git-hooks

Coding & development

View repository

Updated 10-15 07:47

About this skill

Git Hooks

Automate code quality, enforce conventions, and prevent bad commits with Git hooks.

Hook Types and Use Cases

Hook                  Trigger                        Common Use
─────────────────────────────────────────────────────────────────
pre-commit            Before commit is created        Lint, format, secret scan
prepare-commit-msg    After default msg, before edit  Add ticket ID, template
commit-msg            After user writes message       Enforce conventional commits
pre-push              Before push to remote           Full test suite, build check
pre-rebase            Before rebase starts            Prevent rebase on protected branches
post-merge            After merge completes           Reinstall deps, run migrations
post-checkout         After checkout/switch           Rebuild, clear caches

Husky v9 (Node.js Projects)

# Install and initialize
npm install -D husky
npx husky init
# Creates .husky/ directory with pre-commit hook
# Adds "prepare": "husky" to package.json

# Create hooks — each is a plain shell script
echo "npx lint-staged" > .husky/pre-commit
echo "npm test" > .husky/pre-push
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg

# v9 key differences from v8:
# - No husky.sh sourcing needed — hooks are plain scripts
# - Uses core.hooksPath internally via "prepare" script
# - If hooks don't run after clone: npm run prepare

lint-staged Configuration

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml,yaml}": "prettier --write",
    "*.css": ["stylelint --fix", "prettier --write"],
    "*.py": ["ruff check --fix", "ruff format"],
    "*.go": ["gofmt -w", "go vet ./..."]
  }
}

Advanced lint-staged (Function Config)

// lint-staged.config.mjs
export default {
  // Run tsc on whole project when any TS file changes
  '*.ts': () => 'tsc --noEmit',
  // Pass filenames explicitly
  '*.{js,ts}': (filenames) => [
    `eslint --fix ${filenames.join(' ')}`,
    `prettier --write ${filenames.join(' ')}`,
  ],
  // Run related tests only
  'tests/**/*.ts': 'jest --findRelatedTests',
}

Lefthook (Modern Alternative)

npm install -D lefthook    # or: brew install lefthook
npx lefthook install
# lefthook.yml
pre-commit:
  parallel: true
  commands:
    lint:
      glob: "*.{js,ts,tsx}"
      run: npx eslint --fix {staged_files}
    format:
      glob: "*.{js,ts,tsx,json,md}"
      run: npx prettier --write {staged_files}
    types:
      glob: "*.{ts,tsx}"
      run: npx tsc --noEmit

commit-msg:
  commands:
    validate:
      run: 'echo "{1}" | npx commitlint --config .commitlintrc.yml'

pre-push:
  commands:
    test:
      run: npm test

Lefthook advantages: YAML config, built-in parallel execution, glob filtering, {staged_files} variable, Go binary (no Node dependency), native monorepo root: support.

Pre-commit Framework (Python/Polyglot)

pip install pre-commit
pre-commit install
pre-commit install --hook-type pre-push
# .pre-commit-config.yaml
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
        args: ['--maxkb=500']
      - id: detect-private-key
      - id: check-merge-conflict

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

  - repo: local
    hooks:
      - id: pytest
        name: pytest
        entry: pytest
        language: system
        pass_filenames: false
        always_run: true
        stages: [pre-push]
pre-commit run --all-files       # Run all hooks on all files
pre-commit autoupdate            # Update hook versions

Custom Hook Scripts

Conventional Commits Enforcement

#!/bin/sh
# .husky/commit-msg  OR  .githooks/commit-msg
commit_msg=$(cat "$1")
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?: .{1,72}"

if ! echo "$commit_msg" | grep -qE "$pattern"; then
  echo "ERROR: Invalid commit message format."
  echo "Expected: type(scope): description"
  echo "Example: feat(auth): add OAuth2 login flow"
  exit 1
fi

Branch Naming Enforcement

#!/bin/sh
# .githooks/pre-push
branch=$(git symbolic-ref --short HEAD)
pattern="^(feature|bugfix|hotfix|release|chore)/[a-z0-9._-]+$"

if ! echo "$branch" | grep -qE "$pattern"; then
  echo "ERROR: Branch '$branch' doesn't match pattern."
  echo "Use: feature/desc, bugfix/desc, hotfix/desc"
  exit 1
fi

Secret Detection

#!/bin/sh
# .githooks/pre-commit
patterns="(PRIVATE.KEY|password\s*=|secret\s*=|api[_-]?key\s*=|AWS_SECRET|BEGIN RSA)"
staged=$(git diff --cached --name-only --diff-filter=ACM)
for file in $staged; do
  if git show ":$file" | grep -qEi "$patterns"; then
    echo "ERROR: Possible secret detected in $file"
    exit 1
  fi
done

File Size Limit

#!/bin/sh
# .githooks/pre-commit — block files over 1MB
max_size=1048576
for file in $(git diff --cached --name-only --diff-filter=ACM); do
  size=$(git cat-file -s ":$file" 2>/dev/null || echo 0)
  [ "$size" -gt "$max_size" ] && echo "ERROR: $file exceeds 1MB limit." && exit 1
done

Merge Conflict Markers

#!/bin/sh
# .githooks/pre-commit — block commits with unresolved conflicts
for file in $(git diff --cached --name-only --diff-filter=ACM); do
  if git show ":$file" | grep -qE "^(<<<<<<<|=======|>>>>>>>)"; then
    echo "ERROR: Merge conflict markers in $file"; exit 1
  fi
done

Hooks in Node.js / Python

#!/usr/bin/env node
// .githooks/commit-msg — validate conventional commits
const fs = require('fs');
const msg = fs.readFileSync(process.argv[2], 'utf8').trim();
if (!/^(feat|fix|docs|chore|refactor|test|perf|ci)(\(.+\))?: .{1,72}/.test(msg)) {
  console.error('Invalid commit message. Use: type(scope): description');
  process.exit(1);
}
#!/usr/bin/env python3
# .githooks/pre-commit — syntax-check staged Python files
import subprocess, sys
staged = subprocess.run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
    capture_output=True, text=True).stdout.strip().split("\n")
for f in [x for x in staged if x.endswith(".py")]:
    if subprocess.run(["python", "-m", "py_compile", f]).returncode != 0:
        print(f"Syntax error in {f}"); sys.exit(1)

Sharing Hooks with Team

# Husky: hooks in .husky/, auto-installed via "prepare": "husky"

# core.hooksPath: language-agnostic, works everywhere
mkdir .githooks && chmod +x .githooks/*
git config core.hooksPath .githooks
# Automate: { "scripts": { "prepare": "git config core.hooksPath .githooks" } }

# Shared .gitconfig (opt-in per developer)
# Commit .gitconfig with [core] hooksPath = .githooks
# Developer runs: git config --local include.path ../.gitconfig

Bypassing Hooks

git commit --no-verify -m "hotfix: emergency production fix"
git push --no-verify

# Appropriate: emergency hotfixes, WIP commits, broken hook blocking work
# NOT appropriate: skipping failing tests regularly, bypassing secret detection

Debugging Hooks

git config core.hooksPath          # check active hooks dir (blank = .git/hooks/)
git rev-parse --git-path hooks     # resolved path
bash -x .husky/pre-commit          # trace execution
chmod +x .githooks/pre-commit      # fix permissions

# Common issues: missing chmod +x, wrong shebang, nvm not loaded
# (hooks don't source .bashrc), wrong core.hooksPath
# Fix nvm: add to hook: export NVM_DIR="$HOME/.nvm" && . "$NVM_DIR/nvm.sh"
# Debug env: add temporarily: env > /tmp/hook-env.txt

Monorepo Patterns

Lefthook Monorepo

# lefthook.yml
pre-commit:
  commands:
    frontend-lint:
      root: packages/frontend/
      glob: "*.{ts,tsx}"
      run: npx eslint --fix {staged_files}
    backend-lint:
      root: packages/backend/
      glob: "*.py"
      run: ruff check --fix {staged_files}

lint-staged Monorepo

// lint-staged.config.mjs (root)
export default {
  'packages/frontend/**/*.{ts,tsx}': (files) =>
    `cd packages/frontend && npx eslint --fix ${files.join(' ')}`,
  'packages/backend/**/*.py': (files) =>
    `cd packages/backend && ruff check --fix ${files.join(' ')}`,
}

Affected-Only Pre-push

#!/bin/sh
# .husky/pre-push — only test changed packages
changed=$(git diff --name-only @{push}..)
echo "$changed" | grep -q "^packages/frontend/" && (cd packages/frontend && npm test)
echo "$changed" | grep -q "^packages/backend/" && (cd packages/backend && npm test)

CI/CD Hook Equivalents

# GitHub Actions — CI safety net mirrors local hooks
name: Code Quality
on: [push, pull_request]
jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: npm ci
      - run: npx eslint . && npx prettier --check .
      - run: npm test
      - run: npx commitlint --from ${{ github.event.pull_request.base.sha }}