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.
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 initYou 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-stagedmodule.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-stagedCheck:
# 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.
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.txtmodule.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 testsRuff 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 = truePylint 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=10Fast 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=80FastAPI 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!"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-verifyI 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.