Skip to main content

Husky in a working project

Constantin Potapov
14 min

CI is red because of an unused import the linter catches in five seconds. Hooks before commit for JS and Python: lint-staged, Ruff, commitlint.

You push. CI dies on the linter. The error could have been caught locally in five seconds. Colleagues wait, the pipeline is busy, another commit with a fix.

Or review is about console.log and indentation. No time left for architecture.

A broken pipeline: 10-15 minutes. Commit history is dirty. Sometimes the broken thing reaches production.

Git hooks run before commit, before push, after checkout. Husky puts them in the repo so that after npm install they start for everyone.

The problem should die on the author's machine, not in the shared pipeline.

0 min
Time fixing broken CI
100%
Code passes checks before commit
-80%
Formatting debates in reviews
5-10 sec
Check time before commit
ESLintPrettierTypeScriptJestVitestcommitlintlint-staged

Husky holds the hooks. lint-staged runs checks only on staged files. ESLint and Prettier fix the code. commitlint looks at the message.

Install

Husky 9+.

# Install Husky (use version 9+)
npm install --save-dev husky
 
# Initialize Husky (creates .husky/ directory)
npx husky init

You get .husky/ and a "prepare": "husky" script in package.json. prepare runs on npm install. Clone the repo, install dependencies: hooks are there.

lint-staged is what keeps it fast. We do not run a thousand files. Five changed ones.

npm install --save-dev lint-staged
module.exports = {
  // For JavaScript/TypeScript files
  "*.{js,jsx,ts,tsx}": [
    "eslint --fix", // Auto-fix ESLint issues
    "prettier --write", // Format with Prettier
  ],
 
  // For styles
  "*.{css,scss,less}": ["prettier --write"],
 
  // For JSON, Markdown and other files
  "*.{json,md,mdx,yml,yaml}": ["prettier --write"],
};

Complex logic and comments live in lint-staged.config.js, not in package.json.

.husky/pre-commit:

# .husky/pre-commit
npx lint-staged

Check:

# Create a file with ESLint error
echo "const unused = 'variable'" > test.js
 
# Add to staging
git add test.js
 
# Try to commit
git commit -m "test commit"

If clean:

✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...

If ESLint found an error, the commit does not go through:

✖ eslint --fix:
  error  'unused' is assigned a value but never used  no-unused-vars

✖ lint-staged failed

TypeScript, tests, messages

// lint-staged.config.js
module.exports = {
  // TypeScript/JavaScript files
  "*.{ts,tsx,js,jsx}": [
    "eslint --fix --max-warnings=0", // Block commit if warnings exist
    "prettier --write",
  ],
 
  // TypeScript type checking (once for all files)
  "*.{ts,tsx}": () => "tsc --noEmit",
 
  // Styles
  "*.{css,scss,module.css}": ["prettier --write"],
 
  // Markdown and documentation
  "*.{md,mdx}": ["prettier --write"],
 
  // Configs
  "*.{json,yml,yaml}": ["prettier --write"],
};

tsc checks the whole project. That is why () => "tsc --noEmit", not a file list. Otherwise the types lie.

Tests before every commit are too slow. I put them in pre-push.

# Create pre-push hook
npx husky add .husky/pre-push "npm test"

Or a .husky/pre-push file with npm run test:ci.

# Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
 
# Create config
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
 
# Add hook
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'
feat: add new feature
fix: fix bug in auth module
docs: update documentation
chore: update dependencies
git commit -m "added feature"
# ⧗   input: added feature
# ✖   subject may not be empty [subject-empty]
# ✖   type may not be empty [type-empty]

This site is set up that way: Next.js 15, TypeScript, eslint --max-warnings=0, tsc --noEmit, on push a build and tests.

// lint-staged.config.js
module.exports = {
  // TypeScript and JavaScript
  "*.{ts,tsx,js,jsx}": ["eslint --fix --max-warnings=0", "prettier --write"],
 
  // Type-checking for TypeScript (entire project)
  "*.{ts,tsx}": () => "tsc --noEmit",
 
  // Styles and CSS Modules
  "*.{css,scss}": ["prettier --write"],
 
  // MDX content (blog, documentation)
  "*.{md,mdx}": ["prettier --write"],
 
  // JSON configs
  "*.json": ["prettier --write"],
};
# .husky/pre-commit
npx lint-staged
# .husky/pre-push
npm run build      # Check that build doesn't break
npm run test:ci    # Run tests
// commitlint.config.js
module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "type-enum": [
      2,
      "always",
      [
        "feat", // New feature
        "fix", // Bug fix
        "docs", // Documentation
        "style", // Formatting (doesn't affect code)
        "refactor", // Refactoring
        "test", // Tests
        "chore", // Update dependencies, configs
        "perf", // Performance improvement
        "ci", // CI/CD
        "build", // Build system
        "revert", // Revert commit
      ],
    ],
    "subject-case": [0], // Disable case checking for flexibility
  },
};

Python

Husky installs from npm. Hooks run anything, including Ruff and pytest. Node is only needed to install Husky.

RuffBlackmypypytestisort

Ruff replaces Flake8, isort and part of Black. Black does not negotiate format. mypy watches types. pytest runs tests.

# Initialize npm project (if not exists)
npm init -y
 
# Install Husky
npm install --save-dev husky lint-staged
npx husky init
# Via pip
pip install ruff black mypy pytest
 
# Or via poetry
poetry add --group dev ruff black mypy pytest
 
# Or via requirements-dev.txt
echo "ruff>=0.1.0" >> requirements-dev.txt
echo "black>=23.0.0" >> requirements-dev.txt
echo "mypy>=1.7.0" >> requirements-dev.txt
echo "pytest>=7.4.0" >> requirements-dev.txt
pip install -r requirements-dev.txt
module.exports = {
  // Python files: Ruff check and Black formatting
  "*.py": [
    "ruff check --fix", // Check and auto-fix via Ruff
    "black", // Format with Black
    "mypy --ignore-missing-imports", // Type checking
  ],
 
  // Jupyter notebooks (if using)
  "*.ipynb": ["ruff check --fix"],
 
  // YAML configs
  "*.{yml,yaml}": [
    "yamllint", // YAML linter
  ],
 
  // Markdown documentation
  "*.md": [],
};
# .husky/pre-commit
npx lint-staged
# .husky/pre-push
pytest tests/                    # Run all tests

Ruff only, the fastest option:

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I", // Check and sort imports
    "ruff check --fix", // Check and auto-fix all rules
    "ruff format", // Formatting (Black alternative)
  ],
};
[tool.ruff]
# Maximum line length
line-length = 88
 
# Python version
target-version = "py311"
 
# Files to ignore
exclude = [
    ".git",
    ".venv",
    "__pycache__",
    "build",
    "dist",
]
 
[tool.ruff.lint]
# Rules to check (equivalent to Flake8, pycodestyle, isort, etc.)
select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # pyflakes
    "I",   # isort
    "N",   # pep8-naming
    "UP",  # pyupgrade
    "B",   # flake8-bugbear
    "C4",  # flake8-comprehensions
    "DTZ", # flake8-datetimez
    "T10", # flake8-debugger
    "SIM", # flake8-simplify
]
 
# Ignored rules
ignore = [
    "E501",  # line-too-long (Black handles this)
]
 
# Auto-fix rules
fixable = ["ALL"]
unfixable = []
 
[tool.ruff.format]
# Use double quotes
quote-style = "double"
 
# Indentation
indent-style = "space"
 
# Black compatibility
skip-magic-trailing-comma = false
line-ending = "auto"

Ruff is tens of times faster than Flake8 and Pylint. On a large repo that is milliseconds, not half a minute.

Ruff plus Black plus mypy:

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I", // Sort imports via Ruff
    "black --check", // Check formatting
    "black", // Apply formatting
    "ruff check --fix", // Lint via Ruff
    "mypy", // Type checking
  ],
};
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
exclude = '''
/(
    \.git
  | \.venv
  | build
  | dist
)/
'''
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
 
# Ignore missing types in libraries
ignore_missing_imports = true
 
# Strict mode (optional)
# strict = true

Pylint is stricter and slower. On a thousand files about 45 seconds against 0.5 for Ruff. If you keep it, only --errors-only or in pre-push.

// lint-staged.config.js
module.exports = {
  "*.py": [
    "black", // Formatting
    "isort", // Import sorting
    "pylint --errors-only", // Errors only (faster)
    // "pylint",                     // Full check (slow)
    "mypy", // Type checking
  ],
};
[MASTER]
# Ignored files
ignore=CVS,.git,__pycache__,.venv
 
# Number of processes (0 = auto-detect)
jobs=0
 
[MESSAGES CONTROL]
# Disabled rules
disable=
    C0111,  # missing-docstring
    C0103,  # invalid-name
    R0903,  # too-few-public-methods
    W0212,  # protected-access
 
[FORMAT]
# Maximum line length
max-line-length=88
 
# Indentation
indent-string='    '
 
[DESIGN]
# Maximum function arguments
max-args=7
 
# Maximum class attributes
max-attributes=10

Fast in pre-commit, slow in pre-push:

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I", // Imports
    "ruff format", // Formatting
    "ruff check --fix", // Fast checks
  ],
};
# .husky/pre-push
#!/bin/sh
 
# Type checking for entire project
echo "Running mypy type checking..."
mypy src/
 
# Full tests
echo "Running pytest..."
pytest tests/ -v
 
# Test coverage check (optional)
# pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=80

FastAPI and Poetry, a working skeleton:

# pyproject.toml
[tool.poetry]
name = "my-fastapi-app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
 
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104.0"
uvicorn = "^0.24.0"
pydantic = "^2.5.0"
sqlalchemy = "^2.0.0"
 
[tool.poetry.group.dev.dependencies]
ruff = "^0.1.0"
black = "^23.11.0"
mypy = "^1.7.0"
pytest = "^7.4.0"
pytest-cov = "^4.1.0"
pytest-asyncio = "^0.21.0"
httpx = "^0.25.0"
 
# Ruff configuration
[tool.ruff]
line-length = 88
target-version = "py311"
exclude = [".venv", "migrations"]
 
[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "UP", "B", "C4", "SIM"]
ignore = ["E501"]
fixable = ["ALL"]
 
# Black configuration
[tool.black]
line-length = 88
target-version = ['py311']
exclude = '''/(\.venv|migrations)/'''
 
# mypy configuration
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
plugins = ["pydantic.mypy"]
 
[[tool.mypy.overrides]]
module = "sqlalchemy.*"
ignore_missing_imports = true
 
# pytest configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_functions = "test_*"
asyncio_mode = "auto"
// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I",
    "ruff format",
    "ruff check --fix",
    "mypy",
  ],
  "*.{json,yml,yaml}": [],
};
# .husky/pre-push
#!/bin/sh
 
echo "🔍 Running type checking..."
poetry run mypy src/
 
echo "🧪 Running tests..."
poetry run pytest tests/ -v --cov=src --cov-report=term-missing
 
echo "✅ All checks passed!"
// package.json
{
  "name": "my-fastapi-app",
  "private": true,
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0"
  }
}

Django: a dry run of migrations when models change.

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I",
    "ruff format",
    "ruff check --fix",
    "mypy --ignore-missing-imports",
  ],
 
  // Check migrations when models change
  "**/models.py": () => "python manage.py makemigrations --check --dry-run",
};
# .husky/pre-push
#!/bin/sh
 
echo "🔍 Checking migrations..."
python manage.py makemigrations --check --dry-run
 
echo "🧪 Running tests..."
python manage.py test
 
echo "🔐 Running security checks..."
python manage.py check --deploy
 
echo "✅ All checks passed!"
Traditional stack
Modern stack
Tool
Pylint
Ruff
Check speed (1000 files)
~45 seconds
~0.5 seconds
99%
Check strictness
Very high
High
Auto-fix
No
Yes

A new project: Ruff and mypy. An old one: I peel Pylint off gradually. A corporate standard: Ruff, Pylint only for errors, mypy.

When hooks stay silent or choke

After a clone the hooks do not run: no "prepare": "husky" or nobody ran npm install.

A commit takes 30 seconds: the whole repo is running. You need lint-staged, tests in pre-push, --cache on ESLint.

// lint-staged.config.js
module.exports = {
  "*.{ts,tsx,js,jsx}": [
    "eslint --cache --fix", // Add --cache
    "prettier --write",
  ],
};

An urgent hotfix:

# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "emergency fix"
 
# Skip pre-push hook
git push --no-verify

I leave --no-verify for a burning production. Otherwise I fix what the linter found.

ESLint shouts at things it should not see. .eslintignore or a filter in lint-staged:

# .eslintignore
node_modules/
.next/
out/
dist/
build/
*.config.js
module.exports = {
  "*.{ts,tsx,js,jsx}": (filenames) => {
    const filteredFiles = filenames
      .filter((file) => !file.includes("node_modules"))
      .filter((file) => !file.includes(".next"));
 
    return `eslint --fix ${filteredFiles.join(" ")}`;
  },
};

A large legacy does not pass the checks. Prettier only first. A week later ESLint --fix. Then types and tests. Or --max-warnings=10 and lower each sprint: 10, 5, 0. Old code in .eslintignore, new code already strict.

{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint . --ext .ts,.tsx,.js,.jsx",
    "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
    "format": "prettier --write \"**/*.{ts,tsx,js,jsx,css,md,json}\"",
    "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,css,md,json}\"",
    "type-check": "tsc --noEmit",
    "test:ci": "vitest run",
    "validate": "npm run lint && npm run type-check && npm run test:ci"
  }
}

I run validate before a PR on the whole project. Hooks keep history clean of junk. After that the pipeline does what you cannot catch locally.

Docs: Husky, lint-staged, Conventional Commits, commitlint, Ruff, Black, mypy, Pylint, pytest.