Skip to content
StackPractices
beginner By Mathias Paulenko

Set Up Pre-Commit Hooks

How to set up pre-commit hooks with husky, lint-staged, and pre-commit to enforce code quality before commits

Topics: devops

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

Pre-commit hooks automatically run checks on your code before every commit. They catch linting errors, formatting issues, failing tests, and security vulnerabilities at the earliest possible moment—before they reach CI or production. Below is a practical approach to setting up hooks with the pre-commit framework (Python), husky + lint-staged (JavaScript), and native Git hooks for Java projects.

When to Use

Use this resource when:

  • Your team repeatedly commits code that fails CI lint or format checks. See GitHub Actions for CI pipeline setup.
  • You want to enforce code style without relying solely on PR reviews. See Unit Testing for automated quality gates.
  • You need to run secrets scanning or vulnerability checks on every commit. See Container Security Scanning for security scanning.
  • You want fast feedback: fix issues locally instead of waiting for CI to fail. See Bash Scripting Automation for local automation scripts.

Solution

Python

# Install pre-commit framework
# pip install pre-commit

# .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: 23.12.1
    hooks:
      - id: black
        language_version: python3.11

  - repo: https://github.com/PyCQA/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
        args: ['--max-line-length=100']

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.7.1
    hooks:
      - id: mypy

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

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

JavaScript

// package.json scripts + husky + lint-staged
// npm install --save-dev husky lint-staged prettier eslint

// package.json
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml}": ["prettier --write"]
  },
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint .",
    "format": "prettier --write ."
  }
}

// .husky/pre-commit (generated by npx husky add .husky/pre-commit)
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

// Or with the newer husky v9+ syntax:
// echo "npx lint-staged" > .husky/pre-commit

// .lintstagedrc.js
module.exports = {
  '*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'],
  '*.{json,md,yaml}': ['prettier --write'],
};

Java

// Java projects typically use Maven or Gradle hooks, not husky.
// Option 1: Maven git hook plugin (com.rudikershaw.gitbuildhook)
// pom.xml:
/*
<plugin>
    <groupId>com.rudikershaw.gitbuildhook</groupId>
    <artifactId>git-build-hook-maven-plugin</artifactId>
    <version>3.5.0</version>
    <configuration>
        <installHooks>${project.basedir}/git-hooks</installHooks>
    </configuration>
</plugin>
*/

// Option 2: Gradle + Spotless + custom Git hook
// build.gradle:
plugins {
    id 'com.diffplug.spotless' version '6.23.0'
}
spotless {
    java {
        googleJavaFormat()
    }
}

// git-hooks/pre-commit (chmod +x)
#!/bin/sh
./gradlew spotlessCheck
if [ $? -ne 0 ]; then
    echo "Spotless check failed. Run './gradlew spotlessApply' to fix."
    exit 1
fi

Explanation

Git hooks are executable scripts in .git/hooks/ that run at specific lifecycle events. The pre-commit hook runs after git commit is invoked but before the commit is created. If the hook exits with a non-zero status, the commit is aborted.

How the tools work:

  • pre-commit (Python framework): Manages hook installation and execution across languages. Defined in .pre-commit-config.yaml.
  • husky + lint-staged: Husky installs the Git hook; lint-staged filters file paths so only staged files are checked, making commits fast.
  • Native Git hooks: Any executable script works. Use Maven/Gradle plugins to distribute hooks across the team.

Trade-offs:

  • Hooks add commit latency (seconds to tens of seconds)
  • Team members can bypass hooks with git commit --no-verify
  • Hooks must be installed per clone; CI is still the ultimate gate

Variants

TechnologyToolingNotes
Pythonpre-commit frameworkMature ecosystem; 200+ community hooks available
JavaScript / TypeScripthusky + lint-stagedIndustry standard for Node.js; fast because only staged files are checked
JavaMaven git-build-hook-plugin or Gradle spotlessRun formatters as part of build; hooks call ./gradlew spotlessCheck
Gopre-commit + golangci-lintUse the pre-commit framework with Go-specific hooks
Rustpre-commit + rustfmt / clippySame framework; community hooks available
Secrets scanninggitleaks, trufflehogPre-commit hooks prevent API keys and passwords from entering history

What Works

  1. Keep hooks fast: lint only staged files, not the entire codebase
  2. Auto-fix when possible: formatters should rewrite files, not just report errors
  3. Include a prepare or postinstall script so hooks are auto-installed on npm install or pip install
  4. Run the same checks in CI; hooks are a convenience, not a replacement for CI gates
  5. Document bypass procedures (--no-verify) for emergencies, but require PR review when used

Common Mistakes

  1. Checking the entire repo on every commit — lint-staged and pre-commit’s files filter ensure only changed files are checked
  2. Not auto-installing hooks — new clones skip hooks unless a prepare script installs them
  3. Conflicting formatters — ensure Prettier and ESLint rules agree; use eslint-config-prettier to disable conflicting ESLint format rules
  4. Hooks that modify files but don’t re-stage — if a hook reformats code, it must add the file back to the index or the commit will use the old version
  5. Relying only on hooks — developers can use --no-verify; CI must enforce the same rules

Frequently Asked Questions

Can I skip hooks for a specific commit?

Yes: git commit --no-verify (or -n). Use this sparingly and always follow up with a cleanup commit. Some teams require manager approval for --no-verify usage.

Should I run tests in pre-commit hooks?

Unit tests: sometimes, if they complete in under 10 seconds. Integration or E2E tests: never; they belong in CI. Slow hooks train developers to bypass them.

How do I share hooks across my team?

Use pre-commit (cross-language) or husky (Node.js). Both store hook configuration in the repo. For Java, use a Maven/Gradle plugin that installs hooks from a tracked git-hooks/ directory during build. Never commit files directly to .git/hooks/—that directory is not tracked.

Go with golangci-lint

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/golangci/golangci-lint
    rev: v1.55.2
    hooks:
      - id: golangci-lint
        args: [--config, .golangci.yml]
# .golangci.yml
linters:
  enable:
    - errcheck
    - gosimple
    - govet
    - ineffassign
    - staticcheck
    - unused
    - gofmt
    - goimports
linters-settings:
  errcheck:
    check-type-assertions: true

Secrets Scanning with gitleaks

# .pre-commit-config.yaml — add gitleaks
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.1
    hooks:
      - id: gitleaks
# Run gitleaks manually
$ gitleaks detect --source . --verbose

# Scan a specific commit range
$ gitleaks detect --source . --log-opts="HEAD~1..HEAD"

# Custom rules in .gitleaks.toml
[[rules]]
id = "custom-api-key"
description = "Custom API key pattern"
regex = '''sk-live-[a-zA-Z0-9]{20,}'''
tags = ["api-key", "custom"]

Commit Message Linting with commitlint

// Install: npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "type-enum": [
      2,
      "always",
      ["feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "perf"],
    ],
    "subject-max-length": [2, "always", 72],
    "body-max-line-length": [2, "always", 100],
  },
};
# .husky/commit-msg
#!/bin/sh
npx --no-install commitlint --edit "$1"
# Valid commit messages:
feat: add user registration endpoint
fix: handle null pointer in auth middleware
docs: update API documentation for v2
chore: upgrade dependencies to latest patch

CI Integration (GitHub Actions)

# .github/workflows/lint.yml
name: Lint and Format
on: [push, pull_request]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - uses: pre-commit/action@v3.0.0
        with:
          extra_args: --all-files

  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for scanning
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Pre-commit Hook for Type Checking

// package.json — add type-check to lint-staged
{
  "lint-staged": {
    "*.{ts,tsx}": ["tsc --noEmit", "eslint --fix", "prettier --write"],
    "*.{js,jsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml}": ["prettier --write"]
  }
}
# .pre-commit-config.yaml — Python type checking
repos:
  - repo: local
    hooks:
      - id: mypy
        name: mypy type check
        entry: mypy
        language: system
        types: [python]
        pass_filenames: false
        args: [--strict, src/]

Additional Best Practices

  1. Use --no-verify sparingly. Track bypass usage in commit messages:
# Document why hooks were bypassed
$ git commit --no-verify -m "fix: hotfix for prod outage (bypass: time-critical)"
  1. Cache pre-commit environments. Speed up hook execution by caching:
# .github/workflows/lint.yml
- uses: actions/cache@v4
  with:
    path: ~/.cache/pre-commit
    key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
  1. Run hooks in Docker for consistency. Ensure all team members use the same tool versions:
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: docker-lint
        name: ESLint in Docker
        entry: docker run --rm -v $(pwd):/app node:20-slim sh -c "cd /app && npx eslint --fix"
        language: system
        types: [javascript]

Additional Common Mistakes

  1. Not pinning hook versions. Floating rev: main causes breakage when upstream changes:
# Bad: floating revision
repos:
  - repo: https://github.com/psf/black
    rev: main  # Will break without warning

# Good: pinned version
repos:
  - repo: https://github.com/psf/black
    rev: 23.12.1
  1. Hooks that require network access. Hooks should work offline. If a hook needs network (e.g., downloading rules), cache them:
# Pre-download rules during setup
$ pre-commit run --all-files  # Downloads and caches on first run

Additional FAQ

How do I skip specific hooks temporarily?

With pre-commit, skip individual hooks using SKIP:

$ SKIP=flake8,mypy git commit -m "wip"

With husky, modify the hook script to check for an env var:

# .husky/pre-commit
if [ "$SKIP_HOOKS" = "1" ]; then
  exit 0
fi
npx lint-staged

Can I run hooks only on specific branches?

Yes. Add a branch check at the top of your hook script:

#!/bin/sh
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = "main" ] || [ "$branch" = "release/"* ]; then
  npx lint-staged
fi

How do I debug a failing pre-commit hook?

Run hooks manually with verbose output:

# Run specific hook with verbose
$ pre-commit run black --verbose --all-files

# Run with trace output
$ pre-commit run --all-files --verbose 2>&1 | tee pre-commit.log

# Check hook environment
$ pre-commit clean
$ pre-commit install

Performance Tips

  1. Only check staged files. Use lint-staged or pre-commit’s files filter:
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black
        files: ^src/.*\.py$  # Only check src/ directory
  1. Parallelize hooks. pre-commit runs hooks in parallel by default. Keep them independent:
# Each repo runs in its own environment — parallel by default
repos:
  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black
  - repo: https://github.com/PyCQA/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
  1. Use local hooks for speed. Avoid downloading remote repos for simple checks:
repos:
  - repo: local
    hooks:
      - id: local-format
        name: format check
        entry: ./scripts/format.sh
        language: system
        files: \.(js|ts)$
  1. Cache tool installations. Use language_version to pin and cache:
hooks:
  - id: black
    language_version: python3.11  # Cached after first run
  1. Skip hooks for generated files. Exclude auto-generated code from checks:
hooks:
  - id: flake8
    exclude: |
      (?x)^(
          src/generated/.*|
          migrations/.*
      )$