Problem: Code Breaks After Commit
How many times has this happened to you: you push code to the repository, CI fails with a linter or test error that you could have caught locally in 5 seconds. Your colleagues are waiting, the pipeline is busy, and you're making yet another commit with a fix.
Or a classic: someone on the team commits code with console.log(), unused imports, or formatting "as it happened." Code review turns into a discussion about indentation instead of architecture.
Cost of the problem:
- 10-15 minutes for each broken CI pipeline
- Polluted commit history
- Reviewers wasting time discussing formatting
- Risk of broken code reaching production
Solution: automate checks before commit using git hooks and Husky.
Git hooks are scripts that automatically run at certain stages of Git workflow (before commit, before push, after checkout, etc.). Husky is a tool that simplifies setting up and managing these hooks in modern JavaScript projects.
What is Husky and Why You Need It
Husky is a popular npm package for managing git hooks in JavaScript projects. It allows you to:
- Run checks automatically before commit or push
- Format code before commit (via Prettier or ESLint --fix)
- Run tests before pushing to remote repository
- Validate commit messages (via commitlint)
- Guarantee code quality at the developer's local machine level
Key advantage: problems are found before they get into the repository, not after.
What Husky Works With
Husky integrates excellently with popular tools:
Typical stack:
- Husky — manages git hooks
- lint-staged — runs checks only on changed files (not the entire project)
- ESLint — checks code quality
- Prettier — formats code
- commitlint — validates commit message format
Installation and Basic Setup
Step 1: Installing Husky
# Install Husky (use version 9+)
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory)
npx husky initAfter running npx husky init, you'll have:
.husky/directory with examplepre-commithook"prepare": "husky"script inpackage.json
What the prepare script does:
This script automatically runs during npm install and sets up Husky for each developer who clones the repository. This ensures git hooks work for everyone on the team without additional actions.
Step 2: Installing lint-staged
lint-staged is a tool that runs checks only on files you've added to the staging area. This is critically important for performance: instead of checking 1000 files, you check only 5 changed ones.
npm install --save-dev lint-stagedCreate a configuration file lint-staged.config.js in the project root:
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"],
};Important: Use lint-staged.config.js instead of inline configuration in
package.json if you have complex logic or need comments.
Step 3: Configuring pre-commit Hook
Now let's configure the hook that will run before each commit.
Open the .husky/pre-commit file (created during initialization) and replace its content:
# .husky/pre-commit
npx lint-stagedThat's it! Now before each commit, lint-staged will run and check only changed files.
Step 4: Testing
Let's verify the hooks are working:
# 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 everything is configured correctly, you'll see:
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
If ESLint found errors, the commit will be blocked:
✖ eslint --fix:
error 'unused' is assigned a value but never used no-unused-vars
✖ lint-staged failed
Advanced Setup: Production-Ready Configuration
Now let's configure Husky for a real project with TypeScript, tests, and commit validation.
lint-staged Configuration with TypeScript
For TypeScript projects, it's important not only to check with linter but also run type-checking:
// 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"],
};Note: For TypeScript type-checking, use the () => "command" syntax because tsc checks the entire project, not individual files. This is important for the type system to work correctly.
Adding pre-push Hook for Tests
Running all tests before each commit is too slow. It's better to run them before push:
# Create pre-push hook
npx husky add .husky/pre-push "npm test"Or if you use a specific test runner:
# .husky/pre-push
npm run test:ci # Run tests without watch modeCommit Message Validation with commitlint
Let's install commitlint to check commit format (e.g., Conventional Commits):
# 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'Now commits must follow the format:
feat: add new feature
fix: fix bug in auth module
docs: update documentation
chore: update dependencies
Attempting to commit with incorrect format will be blocked:
git commit -m "added feature"
# ⧗ input: added feature
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]Real Case: Next.js Project Setup
Here's a complete configuration from a production Next.js 15 project with TypeScript:
// 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
],
};Result: All code that gets into the repository automatically passes quality, formatting, and type checks. Probability of broken CI is nearly zero.
Husky for Python Projects
Husky isn't just for JavaScript! Git hooks work with any programming language. Let's configure Husky for a Python project with modern code quality tools.
Important: While Husky is an npm package, it works great with Python projects. You only need Node.js to install Husky itself, but the hooks will run Python tools.
Tools for Python
Modern stack for Python projects:
- Ruff — ultra-fast linter and formatter (replaces Flake8, isort, and partially Black)
- Black — uncompromising code formatter
- mypy — type checking
- pytest — testing
- isort — import sorting (if not using Ruff)
Basic Python Setup
Step 1: Installing Husky
# Initialize npm project (if not exists)
npm init -y
# Install Husky
npm install --save-dev husky lint-staged
npx husky initStep 2: Installing Python Tools
# 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.txtStep 3: Configuring lint-staged for Python
Create lint-staged.config.js:
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": [],
};Step 4: Configuring pre-commit Hook
# .husky/pre-commit
npx lint-stagedStep 5: Configuring pre-push Hook for Tests
# .husky/pre-push
pytest tests/ # Run all testsOption 1: Ruff Only (Fastest)
Ruff is a modern "all-in-one" tool for Python. It replaces Flake8, isort, pyupgrade, and partially Black.
// 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)
],
};Ruff Configuration (pyproject.toml):
[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 advantage: Speed! Ruff is 10-100x faster than Flake8/Pylint and works in milliseconds even on large projects.
Option 2: Ruff + Black + mypy (Classic Stack)
If you want to use Black for formatting and mypy for type-checking:
// 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
],
};Black Configuration (pyproject.toml):
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
exclude = '''
/(
\.git
| \.venv
| build
| dist
)/
'''mypy Configuration (pyproject.toml):
[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 = trueOption 3: Pylint Instead of Ruff (For Strict Checks)
Pylint is a stricter and more detailed linter, but slower than Ruff.
// 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
],
};Pylint Configuration (.pylintrc):
[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=10Warning: Pylint is 50-100x slower than Ruff. On large projects, this can
slow commits to 10-30 seconds. It's recommended to use --errors-only or move
to pre-push.
Optimization: Fast Checks in pre-commit, Slow in pre-push
For large Python projects, it's recommended to split checks:
Fast checks (pre-commit):
// lint-staged.config.js
module.exports = {
"*.py": [
"ruff check --fix --select I", // Imports
"ruff format", // Formatting
"ruff check --fix", // Fast checks
],
};Slow checks (pre-push):
# .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=80Real Case: FastAPI Project with Poetry
Complete configuration for production FastAPI project:
# 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-commit
npx lint-staged# .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 Project: Configuration Specifics
For Django projects, add migration checks:
// 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!"Comparing Python Tools
Recommendations:
- For new projects: Ruff + mypy (fast, modern, strict enough)
- For legacy projects: Gradual migration from Pylint to Ruff
- For strict corporate standards: Ruff + Pylint (errors only) + mypy
Checklist for Python Projects
- Husky and lint-staged installed
- Python tools installed (Ruff/Black/mypy)
pyproject.tomlcreated with configurationlint-staged.config.jsconfigured for Python files.husky/pre-commitconfigured for fast checks.husky/pre-pushconfigured for tests.gitignoreadded for Python (.venv,__pycache__, etc.)- Tested: commit with error is blocked
- Tested: commit with valid code passes
Problems and Solutions
Problem 1: Hooks Don't Work After Cloning
Symptom: Colleague cloned the repository, but git hooks don't run.
Cause: prepare script didn't run during npm install.
Solution:
Make sure package.json contains:
{
"scripts": {
"prepare": "husky"
}
}Ask colleagues to run after cloning:
npm install # This will run prepare automaticallyProblem 2: Hooks Are Too Slow
Symptom: Commit takes 30+ seconds.
Cause: Checks run on all project files, not just changed ones.
Solution:
- Use
lint-staged— it checks only staged files - Don't run tests in
pre-commit, move them topre-push - Use caching in ESLint:
// lint-staged.config.js
module.exports = {
"*.{ts,tsx,js,jsx}": [
"eslint --cache --fix", // Add --cache
"prettier --write",
],
};Problem 3: Need to Bypass Hooks in Emergency
Symptom: Need to commit urgently, but hooks are blocking.
Solution (use with caution!):
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "emergency fix"
# Skip pre-push hook
git push --no-verifyDon't abuse --no-verify! Use only in emergency cases (production
hotfix). In other cases, fix the errors found by the linter.
Problem 4: ESLint Complains About Files That Shouldn't Be Checked
Cause: lint-staged passes files to ESLint that should be ignored.
Solution:
Create/update .eslintignore:
# .eslintignore
node_modules/
.next/
out/
dist/
build/
*.config.js
Or configure lint-staged explicitly:
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(" ")}`;
},
};Implementing in Existing Project (Migration)
If you already have a large project with existing code that doesn't pass checks:
Strategy 1: Gradual Implementation
- Install Husky and lint-staged
- Configure Prettier only (auto-fix doesn't break code):
// lint-staged.config.js
module.exports = {
"*.{ts,tsx,js,jsx}": ["prettier --write"],
};- After a week, add ESLint with --fix:
module.exports = {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
};- After a month, add type-checking and tests
Strategy 2: Check Only New Files
Use --max-warnings for gradual improvement:
module.exports = {
"*.{ts,tsx,js,jsx}": [
"eslint --fix --max-warnings=10", // Allow 10 warnings, gradually decrease
"prettier --write",
],
};Each sprint, reduce the limit: 10 → 5 → 0.
Strategy 3: Ignoring Legacy Code
Add legacy files to .eslintignore:
# Legacy code being gradually refactored
src/legacy/
src/old-api/
New code is checked strictly, old code is gradually refactored.
Scripts for package.json
Useful commands for working with git hooks:
{
"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"
}
}The validate command is useful for checking the entire project (e.g., before creating a PR):
npm run validateSuccess Metrics
After a month of using Husky, you should see:
Additional metrics:
- Code review time reduced by 30-40% (no need to discuss formatting)
- Code quality improves (automated checks find issues early)
- Less team stress (CI rarely fails)
Implementation Checklist
Use this checklist for implementing Husky in your project:
- Husky installed (
npm install --save-dev husky) - Initialization completed (
npx husky init) - lint-staged installed (
npm install --save-dev lint-staged) lint-staged.config.jscreated with checks.husky/pre-commitconfigured to run lint-staged- (Optional)
.husky/pre-pushconfigured for tests/build - (Optional) commitlint installed for commit validation
preparescript added topackage.json- Tested: commit with ESLint error is blocked
- Tested: commit with valid code passes
- Team informed about new rules
- Documentation updated (README or CONTRIBUTING.md)
Conclusion
Husky and git hooks aren't just "convenient automation." This is a culture shift in team development.
What you get:
- Code always passes minimum quality checks before getting into the repository
- CI/CD pipelines stop failing due to trivial errors
- Code review focuses on architecture and logic, not formatting
- New developers immediately get automated checks after
npm install
Implementation time:
- Basic setup: 15-30 minutes
- Production-ready configuration: 1-2 hours
- ROI: Already in a week (time saved fixing CI)
Next step:
Open your terminal right now and run:
npm install --save-dev husky lint-staged
npx husky initCreate lint-staged.config.js from the examples above — and your project is already protected from trivial errors.
Quality automation isn't about control, it's about letting developers focus on what matters while routine checks run themselves.
Resources for further learning:
Git hooks and automation: Official Husky documentation, lint-staged on GitHub, Conventional Commits, commitlint
Python tools: Ruff — official documentation, Black — the uncompromising formatter, mypy — static type checking, Pylint — documentation, pytest — testing
