Git Hooks

Git Hooks & Automation Pipelines

Programmatic quality gates that catch issues before they become problems

Hero image for Git Hooks & Automation Pipelines

Git Hooks & Automation Pipelines

Part 7 of the Agent-Ready Development Series


The Last Line of Defense

We’ve set up branch protection, PR templates, and CI pipelines. But there’s a gap: the time between “developer writes code” and “CI catches the problem.”

That gap can be hours. Sometimes days if PRs sit in review.

Git hooks close that gap. They catch problems before code leaves the developer’s machine. And for AI assistants, hooks are even more valuable—they provide immediate feedback that shapes better output.


The Hook Hierarchy

There are two levels of automation:

Local Hooks (Pre-commit, Pre-push)

Run on the developer’s machine before code is shared.

  • Fast feedback: Seconds, not minutes
  • Developer experience: Catch issues early
  • AI benefit: Immediate correction opportunity

Remote Hooks (CI/CD)

Run on the server after code is pushed.

  • Comprehensive: Can run expensive tests
  • Environment-controlled: Consistent execution
  • Authority: Ultimate source of truth

Both are necessary. Local hooks for speed, remote for thoroughness.


Setting Up Git Hooks with Husky

is the de facto standard for managing Git hooks in JavaScript projects:

Installation

npm install --save-dev husky lint-staged
npx husky init

This creates:

.husky/
├── _/
│   ├── .gitignore
│   └── husky.sh
└── pre-commit

Pre-commit Hook

# .husky/pre-commit
# (Husky v9+: hook files are just the commands — the old shebang and
#  `. "$(dirname "$0")/_/husky.sh"` sourcing line are deprecated and removed in v10.)
npx lint-staged

Configuration

// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml}": [
      "prettier --write"
    ],
    "*.test.{ts,tsx}": [
      "vitest related --run"
    ]
  }
}

What this does:

  1. TypeScript files: Auto-fix lint issues, format code
  2. Config/docs: Format consistently
  3. Test files: Run related tests

Only staged files are processed—fast even in large codebases.


The Complete Hook Arsenal

Pre-commit: Fast Checks

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

echo "🔍 Running pre-commit checks..."

# Lint and format staged files
npx lint-staged

# Type check (fast, only changed files)
npx tsc --noEmit --incremental

# Check for console.log statements (optional)
if git diff --cached --name-only | xargs grep -l 'console.log' 2>/dev/null; then
  echo "⚠️  Warning: console.log statements detected"
  # Uncomment to make this a blocking error:
  # exit 1
fi

echo "✅ Pre-commit checks passed"

Commit-msg: Validate Messages

#!/bin/sh
# .husky/commit-msg

npx --no -- commitlint --edit "$1"

With config:

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci']
    ],
    'subject-case': [2, 'always', 'lower-case'],
    'header-max-length': [2, 'always', 72]
  }
};

Pre-push: Comprehensive Checks

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

echo "🚀 Running pre-push checks..."

# Full type check
npm run typecheck

# Run tests
npm run test

# Build to catch any build errors
npm run build

echo "✅ Pre-push checks passed"

Agent Hooks (Claude Code)

Hooks are tool-specific — unlike the standard, there’s no cross-vendor standard for them yet. Claude Code’s implementation is the most mature, so I’ll use it here; the idea — deterministic shell commands fired on agent events — transfers to other tools.

One thing to get right: Claude Code hooks are not scripts the agent discovers by filename. You register them in .claude/settings.json, mapping an event (like PreToolUse) to a matcher and a command. The command receives a JSON payload on stdin and signals back through its exit code — exit 2 blocks the action and feeds your stderr message back to Claude — or a small JSON object on stdout.

Register hooks in settings.json

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": ".claude/hooks/protect-files.py" }] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": ".claude/hooks/typecheck.py" }] }
    ]
  }
}

PreToolUse: block edits to protected files

The command gets the event JSON on stdin — including tool_name and tool_input. Exit 2 to block the action and send the reason back to Claude:

#!/usr/bin/env python3
# .claude/hooks/protect-files.py  (referenced from settings.json)
import sys, json

data = json.load(sys.stdin)        # { session_id, hook_event_name, tool_name, tool_input, ... }
tool = data.get("tool_name", "")
path = data.get("tool_input", {}).get("file_path", "")

PROTECTED = (".env", ".env.production", "package-lock.json")
if tool in ("Edit", "Write") and path.endswith(PROTECTED):
    print(f"Refusing to touch {path}: protected file.", file=sys.stderr)
    sys.exit(2)                    # exit 2 = block; stderr is shown to Claude

sys.exit(0)                        # exit 0 = allow

PostToolUse: type-check after edits

PostToolUse runs after the tool succeeds. Re-run a type check and, if it fails, exit 2 so Claude sees the errors and fixes them:

#!/usr/bin/env python3
# .claude/hooks/typecheck.py
import sys, json, subprocess

data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
if path.endswith((".ts", ".tsx")):
    r = subprocess.run(["npx", "tsc", "--noEmit"], capture_output=True, text=True)
    if r.returncode != 0:
        print(r.stdout or r.stderr, file=sys.stderr)
        sys.exit(2)                # feed the type errors back to Claude

sys.exit(0)

There’s more surface than this — UserPromptSubmit can inject context, and hooks can return a structured hookSpecificOutput object (for example a permissionDecision of allow / deny / ask) instead of relying on exit codes. Read it from the source, not a blog (this one included): the Claude Code hooks reference.


Automation Pipeline Design

Beyond hooks, your CI pipeline should have layers:

Layer 1: Instant Checks (< 30 seconds)

# .github/workflows/ci.yml
lint:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci
    - run: npm run lint
    - run: npm run typecheck

Layer 2: Fast Tests (< 5 minutes)

test:
  runs-on: ubuntu-latest
  needs: lint
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci
    - run: npm run test:unit

Layer 3: Integration Tests (< 15 minutes)

integration:
  runs-on: ubuntu-latest
  needs: test
  services:
    postgres:
      image: postgres:15
      env:
        POSTGRES_PASSWORD: test
      options: >-
        --health-cmd pg_isready
        --health-interval 10s
        --health-timeout 5s
        --health-retries 5
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci
    - run: npm run test:integration
      env:
        DATABASE_URL: postgresql://postgres:test@localhost:5432/test

Layer 4: E2E Tests (Optional, expensive)

e2e:
  runs-on: ubuntu-latest
  needs: integration
  if: github.ref == 'refs/heads/main'  # Only on main branch
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci
    - run: npx playwright install --with-deps
    - run: npm run test:e2e

Hook Best Practices

1. Fast Local, Thorough Remote

Local hooks:
  - Lint (< 5s)
  - Format (< 2s)
  - Type check (< 10s)
  - Unit tests for changed files (< 10s)
  Total: < 30 seconds

Remote CI:
  - Full lint
  - Full type check
  - All unit tests
  - Integration tests
  - E2E tests
  - Security scans
  - Build verification
  Total: 5-15 minutes

2. Provide Bypass for Emergencies

# Skip pre-commit hooks (use sparingly!)
git commit --no-verify -m "hotfix: emergency production fix"

Document when this is acceptable (never for regular development).

3. Cache Aggressively

# .github/workflows/ci.yml
- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      node_modules
      .turbo
    key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-modules-

4. Fail Fast, Fail Loud

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: npm run lint
        continue-on-error: false  # Stop immediately on failure

5. Parallel Where Possible

jobs:
  lint:
    runs-on: ubuntu-latest
    # No dependencies - runs immediately

  typecheck:
    runs-on: ubuntu-latest
    # No dependencies - runs in parallel with lint

  test:
    runs-on: ubuntu-latest
    needs: [lint, typecheck]  # Waits for both

The Feedback Loop

When an AI assistant triggers a hook failure, it gets immediate feedback:

$ git commit -m "add user feature"

🔍 Running pre-commit checks...

✖ eslint found 2 errors:
  src/user.ts:15:5 - 'userId' is defined but never used
  src/user.ts:23:10 - Unexpected console.log statement

❌ Pre-commit checks failed

AI Assistant: I see the pre-commit checks failed. Let me fix these issues:
1. Removing unused 'userId' variable
2. Removing console.log statement

[Makes corrections]

$ git commit -m "add user feature"

🔍 Running pre-commit checks...
✅ Pre-commit checks passed
[main abc123] add user feature

The hook provides:

  1. Specific error locations
  2. Clear error messages
  3. Immediate feedback

The AI corrects and tries again. This loop happens in seconds, not hours.


Monitoring Hook Health

Track how often hooks catch issues:

# .github/workflows/hook-metrics.yml
name: Hook Metrics

on:
  push:
    branches: [main]

jobs:
  collect:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 100

      - name: Count commits with no-verify
        run: |
          count=$(git log --oneline -100 | grep -c "no-verify\|--no-v" || true)
          echo "no_verify_commits=$count" >> $GITHUB_OUTPUT

      - name: Report
        if: steps.collect.outputs.no_verify_commits > 5
        run: |
          echo "⚠️ High number of --no-verify commits detected"

Quick Wins: What to Do Today

Minimum (15 minutes)

  1. Install Husky: npm install --save-dev husky && npx husky init
  2. Add pre-commit lint: echo "npx eslint ." > .husky/pre-commit
  3. Test it: Make a linting error, try to commit

Better (1 hour)

  1. Configure lint-staged for smart file selection
  2. Add commit message validation
  3. Add pre-push type checking

Complete (3 hours)

  1. Full pre-commit hook suite
  2. Claude Code hooks for AI safety
  3. Multi-stage CI pipeline
  4. Hook bypass documentation
  5. Metrics collection

Coming Next

In Part 8, we’ll explore Monorepos & Multi-Project Architecture—scaling agent-ready practices across large, complex codebases with multiple applications and shared code.


Hook management is deeply integrated into PopKit. PopKit’s hook system provides sophisticated context injection, tool filtering, and quality gates specifically designed for AI-assisted development. The /popkit:dev workflow automatically ensures your changes pass all configured hooks before creating PRs.


← Part 6: Documentation as Context | Part 8: Monorepo Architecture →