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"

Frequently Asked Questions

Find answers to common questions

Git hooks are scripts that run automatically at specific points in the Git workflow. pre-commit runs before creating a commit (lint, format, test). commit-msg runs after writing commit message (validate format). pre-push runs before pushing (run tests, check branch). post-merge runs after merge (install dependencies). They're stored in .git/hooks/ directory.

Husky is a tool that makes Git hooks easy to manage in JavaScript/TypeScript projects. It stores hooks in your repository (not .git/hooks) so they're version controlled and shared with team. It automatically installs hooks when teammates run npm install. Without Husky, each developer must manually set up hooks, leading to inconsistent enforcement.

lint-staged runs linters only on files staged for commit (git add), not the entire codebase. This makes pre-commit hooks fast—checking 5 changed files instead of 5000. Configure it with file patterns: '*.js': ['eslint --fix', 'prettier --write']. Combine with Husky: Husky triggers pre-commit, lint-staged runs linters on staged files.

Use --no-verify flag: git commit --no-verify or git push --no-verify. Use sparingly—this bypasses all safety checks. Common legitimate uses: emergency hotfixes, WIP commits to personal branches, commits that only modify non-code files. Document why you skipped in the commit message. Don't make it a habit.

pre-commit is a Python-based framework supporting any language (Python, JS, Go, Rust, etc.) with a large library of ready-made hooks. Husky is JavaScript-focused and simpler for npm projects. Use pre-commit for polyglot teams or when you need its hook library. Use Husky for pure JavaScript/TypeScript projects where npm is primary.

Use commitlint with Husky: npm install @commitlint/cli @commitlint/config-conventional. Create commitlint.config.js with {extends: ['@commitlint/config-conventional']}. Add commit-msg hook: npx husky add .husky/commit-msg 'npx commitlint --edit $1'. This enforces Conventional Commits format: type(scope): subject.

Common causes: running linters on entire codebase (use lint-staged), running full test suite (run only affected tests), slow dependency installation in hooks. Solutions: lint-staged for partial file linting, jest --onlyChanged or vitest --changed for affected tests, cache dependencies. Aim for <10 seconds—developers will skip slow hooks.

Don't commit to .git/hooks (not tracked). Instead: use Husky which stores hooks in .husky/ directory (committed), or pre-commit with .pre-commit-config.yaml (committed). Both auto-install on npm install or pre-commit install. Document setup in README. Hooks become part of your codebase, versioned and reviewed like code.

Yes, but be strategic. Full test suite is too slow—developers will bypass. Better approaches: run only affected tests (jest --onlyChanged), run fast unit tests only (skip integration), run tests in pre-push instead of pre-commit, rely on CI for full test coverage. Pre-commit should be fast for good developer experience.

For Husky: configure once at repo root, hooks apply to all packages. For lint-staged: use --relative flag and configure per-package patterns. For pre-commit: single config at root. Consider running checks only for changed packages using tools like Nx affected or Turborepo filters. This keeps hooks fast even in large monorepos.

Engineering Excellence for Your Business

Our engineers build systems that scale. Clean architecture, comprehensive testing, and security-first development.