Home/Blog/Git Hooks Automation: Husky, lint-staged, and Pre-commit Frameworks
Software Engineering

Git Hooks Automation: Husky, lint-staged, and Pre-commit Frameworks

Automate code quality with Git hooks using Husky, lint-staged, and pre-commit frameworks. Learn to enforce formatting, linting, testing, and commit message standards.

By Inventive HQ Team
Git Hooks Automation: Husky, lint-staged, and Pre-commit Frameworks

Git hooks are your first line of defense against bad commits. They automatically run scripts at key points in your Git workflow—before commits, before pushes, after merges. This guide covers how to set up and manage Git hooks using modern tools like Husky, lint-staged, and pre-commit.

Understanding Git Hooks

┌─────────────────────────────────────────────────────────────┐
│                    GIT HOOK LIFECYCLE                        │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   git commit                                                 │
│       │                                                      │
│       ├── pre-commit ────► Lint, format, test staged files  │
│       │                                                      │
│       ├── prepare-commit-msg ────► Modify commit message    │
│       │                                                      │
│       ├── commit-msg ────► Validate commit message format   │
│       │                                                      │
│       └── post-commit ────► Notifications, cleanup          │
│                                                              │
│   git push                                                   │
│       │                                                      │
│       ├── pre-push ────► Run tests, check branch name       │
│       │                                                      │
│       └── post-push ────► Deploy notifications              │
│                                                              │
│   git merge / git pull                                       │
│       │                                                      │
│       └── post-merge ────► Install deps, rebuild            │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Hook Types and Use Cases

HookTimingCommon Uses
pre-commitBefore commit is createdLint, format, run fast tests
prepare-commit-msgAfter default messageAdd ticket numbers, templates
commit-msgAfter message is enteredValidate message format
post-commitAfter commit is completeNotifications
pre-pushBefore push to remoteRun full tests, check branch
post-mergeAfter merge completesInstall dependencies, rebuild
post-checkoutAfter branch checkoutInstall deps, clear caches

Native Git Hooks (Manual Setup)

Git hooks live in .git/hooks/. To create one manually:

# Create pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh

# Run linter
npm run lint
if [ $? -ne 0 ]; then
  echo "Linting failed. Please fix errors before committing."
  exit 1
fi

# Run tests
npm test
if [ $? -ne 0 ]; then
  echo "Tests failed. Please fix before committing."
  exit 1
fi

exit 0
EOF

# Make executable
chmod +x .git/hooks/pre-commit

Problems with manual hooks:

  • .git/hooks/ is not version controlled
  • Each developer must set up manually
  • No standard way to share across team
  • Difficult to maintain across projects

Husky for JavaScript Projects

Husky makes Git hooks easy to share and maintain in npm projects.

Installation

# Install Husky
npm install husky --save-dev

# Initialize Husky
npx husky init

# This creates:
# - .husky/ directory (committed to repo)
# - .husky/pre-commit (sample hook)
# - Adds "prepare": "husky" to package.json

Configuration

# Create pre-commit hook
echo "npm run lint && npm test" > .husky/pre-commit

# Create commit-msg hook
echo "npx commitlint --edit \$1" > .husky/commit-msg

# Create pre-push hook
echo "npm run test:all" > .husky/pre-push

.husky/pre-commit example:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# Run lint-staged for fast linting
npx lint-staged

# Run type checking
npm run typecheck

Husky v9+ Changes

Husky v9 simplified configuration:

.husky/
├── _/
│   └── husky.sh        # Internal script
├── pre-commit          # Your hook scripts
├── commit-msg
└── pre-push

Each hook file is a simple shell script—no JSON configuration needed.

lint-staged for Fast Hooks

lint-staged runs linters only on staged files, making hooks fast.

Installation

npm install lint-staged --save-dev

Configuration

package.json:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss}": [
      "stylelint --fix",
      "prettier --write"
    ],
    "*.{json,md,yaml}": [
      "prettier --write"
    ]
  }
}

Or separate config file (lint-staged.config.js):

module.exports = {
  // TypeScript/JavaScript files
  '*.{ts,tsx,js,jsx}': [
    'eslint --fix --max-warnings=0',
    'prettier --write',
    // Run tests related to staged files
    'jest --bail --findRelatedTests --passWithNoTests',
  ],

  // Style files
  '*.{css,scss,less}': [
    'stylelint --fix',
    'prettier --write',
  ],

  // Other files
  '*.{json,md,yaml,yml}': [
    'prettier --write',
  ],

  // Package files - run additional checks
  'package.json': [
    'npmPkgJsonLint .',
  ],
};

Combining Husky + lint-staged

.husky/pre-commit:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

Result:

┌─────────────────────────────────────────────────────────────┐
│                    LINT-STAGED WORKFLOW                      │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   git commit                                                 │
│       │                                                      │
│       ▼                                                      │
│   Husky triggers pre-commit hook                            │
│       │                                                      │
│       ▼                                                      │
│   lint-staged reads staged files                            │
│       │                                                      │
│       ▼                                                      │
│   ┌─────────────────────────────────────┐                   │
│   │ Staged files: src/utils.ts         │                   │
│   │              src/App.tsx           │                   │
│   │              styles/main.css       │                   │
│   └─────────────────────────────────────┘                   │
│       │                                                      │
│       ▼                                                      │
│   Match patterns and run commands:                          │
│   ├── *.ts, *.tsx → eslint, prettier                       │
│   └── *.css → stylelint, prettier                          │
│       │                                                      │
│       ▼                                                      │
│   ┌─────────────────────────────────────┐                   │
│   │ ✓ 3 files linted in 1.2s           │                   │
│   │ (instead of 500 files in 30s)      │                   │
│   └─────────────────────────────────────┘                   │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Pre-commit Framework (Python-based)

The pre-commit framework supports any language and has a large hook library.

Installation

# Install pre-commit
pip install pre-commit
# Or: brew install pre-commit

# Install hooks for repo
cd your-repo
pre-commit install

Configuration

.pre-commit-config.yaml:

repos:
  # Pre-commit hooks library
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-json
      - id: check-added-large-files
        args: ['--maxkb=500']
      - id: detect-private-key
      - id: check-merge-conflict

  # Prettier for formatting
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier
        types_or: [javascript, jsx, ts, tsx, css, json, yaml, markdown]

  # ESLint
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.56.0
    hooks:
      - id: eslint
        files: \.[jt]sx?$
        types: [file]
        additional_dependencies:
          - [email protected]
          - [email protected]
          - '@typescript-eslint/[email protected]'
          - '@typescript-eslint/[email protected]'

  # Python - Black formatter
  - repo: https://github.com/psf/black
    rev: 24.1.1
    hooks:
      - id: black

  # Python - Ruff linter
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.14
    hooks:
      - id: ruff
        args: [--fix]

  # Secret detection
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.1
    hooks:
      - id: gitleaks

  # Terraform
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.86.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint

Running Pre-commit

# Run on staged files (what hooks do)
pre-commit run

# Run on all files
pre-commit run --all-files

# Run specific hook
pre-commit run eslint

# Update hooks to latest versions
pre-commit autoupdate

# Skip hooks (emergency only)
git commit --no-verify

Commit Message Validation

Enforce consistent commit messages with commitlint.

Setup with Husky

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

# Create config
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js

# Add hook
echo "npx commitlint --edit \$1" > .husky/commit-msg

Conventional Commits Format

type(scope): subject

body

footer

Types:

TypeUsage
featNew feature
fixBug fix
docsDocumentation
styleFormatting (no code change)
refactorCode restructure
testAdding tests
choreMaintenance
perfPerformance
ciCI/CD changes
buildBuild system
revertRevert commit

Examples:

# Valid commits
git commit -m "feat(auth): add OAuth2 login"
git commit -m "fix(api): handle null response"
git commit -m "docs: update README installation steps"

# Invalid commits (will be rejected)
git commit -m "fixed stuff"           # No type
git commit -m "feat: Added feature"   # Capitalized subject
git commit -m "feat(auth) add login"  # Missing colon

Custom Commit Rules

commitlint.config.js:

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Type must be lowercase
    'type-case': [2, 'always', 'lower-case'],

    // Type must be one of these
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'perf', 'ci', 'build', 'revert'],
    ],

    // Subject must not end with period
    'subject-full-stop': [2, 'never', '.'],

    // Subject must be lowercase
    'subject-case': [2, 'always', 'lower-case'],

    // Body must have blank line before it
    'body-leading-blank': [2, 'always'],

    // Max length for subject line
    'header-max-length': [2, 'always', 72],
  },
};

Complete Example Setup

JavaScript/TypeScript Project

package.json:

{
  "name": "my-project",
  "scripts": {
    "prepare": "husky",
    "lint": "eslint . --ext .ts,.tsx",
    "lint:fix": "eslint . --ext .ts,.tsx --fix",
    "format": "prettier --write .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "@commitlint/cli": "^18.0.0",
    "@commitlint/config-conventional": "^18.0.0",
    "eslint": "^8.0.0",
    "prettier": "^3.0.0",
    "typescript": "^5.0.0",
    "vitest": "^1.0.0"
  },
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix --max-warnings=0",
      "prettier --write"
    ],
    "*.{json,md,yaml}": [
      "prettier --write"
    ]
  }
}

.husky/pre-commit:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# Run lint-staged
npx lint-staged

# Type check
npm run typecheck

.husky/commit-msg:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx commitlint --edit $1

.husky/pre-push:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# Run all tests before push
npm test

# Check for console.logs in staged code
if git diff --cached --name-only | xargs grep -l "console.log" 2>/dev/null; then
  echo "Warning: Found console.log statements"
  # exit 1  # Uncomment to block
fi

Python Project

.pre-commit-config.yaml:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files

  - repo: https://github.com/psf/black
    rev: 24.1.1
    hooks:
      - id: black
        language_version: python3.11

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.14
    hooks:
      - id: ruff
        args: [--fix, --exit-non-zero-on-fix]

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.8.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

  - repo: local
    hooks:
      - id: pytest
        name: pytest
        entry: pytest
        language: system
        types: [python]
        pass_filenames: false
        always_run: true

Monorepo Considerations

Husky in Monorepos

# Root-level Husky installation
npm install husky --save-dev -w  # Workspace root

# Hooks apply to entire repo
.husky/
├── pre-commit
└── commit-msg

.husky/pre-commit for monorepo:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# Run lint-staged with relative paths
npx lint-staged --relative

# Or use Nx/Turborepo for affected-only
npx nx affected --target=lint --uncommitted

lint-staged in Monorepos

package.json (root):

{
  "lint-staged": {
    "packages/*/src/**/*.{ts,tsx}": [
      "eslint --fix"
    ],
    "apps/*/src/**/*.{ts,tsx}": [
      "eslint --fix"
    ]
  }
}

Troubleshooting

Hook Not Running

# Check if hooks are installed
ls -la .git/hooks/

# Verify Husky setup
cat .git/hooks/pre-commit

# Reinstall Husky
rm -rf .husky
npx husky init

Hook Failing Silently

# Run hook manually with debug
sh -x .husky/pre-commit

# Check hook permissions
chmod +x .husky/pre-commit

Skipping Hooks (When Necessary)

# Skip all hooks
git commit --no-verify -m "emergency fix"
git push --no-verify

# Skip specific hook (not possible - it's all or nothing)
# Solution: Add conditional logic in hook script

Best Practices

Keep Hooks Fast

┌─────────────────────────────────────────────────────────────┐
│              HOOK TIMING GUIDELINES                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   pre-commit: < 10 seconds                                  │
│   ├── Lint staged files only (lint-staged)                 │
│   ├── Format staged files only                             │
│   └── Quick type check                                      │
│                                                              │
│   commit-msg: < 1 second                                    │
│   └── Validate message format                               │
│                                                              │
│   pre-push: < 60 seconds                                    │
│   ├── Run affected tests                                    │
│   ├── Full type check                                       │
│   └── Security scan                                         │
│                                                              │
│   Leave for CI:                                              │
│   ├── Full test suite                                       │
│   ├── Integration tests                                     │
│   ├── Build verification                                    │
│   └── E2E tests                                             │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Provide Good Feedback

#!/usr/bin/env sh
# Good: Clear messages
echo "🔍 Running linter..."
npm run lint
if [ $? -ne 0 ]; then
  echo "❌ Linting failed. Run 'npm run lint:fix' to auto-fix."
  exit 1
fi
echo "✅ Linting passed"

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.