Пушишь. CI падает на линтере. Ошибку можно было поймать локально за пять секунд. Коллеги ждут, пайплайн занят, ещё один коммит с фиксом.
Или в ревью обсуждают console.log и отступы. На архитектуру времени не остаётся.
Сломанный пайплайн: 10-15 минут. История коммитов грязная. Сломанное иногда доезжает до прода.
Хуки Git запускаются до коммита, до пуша, после чекаута. Husky кладёт их в репозиторий так, чтобы после npm install они завелись у всех.
Проблема должна умирать на машине автора, не в общем пайплайне.
Husky держит хуки. lint-staged гоняет проверки только по staged-файлам. ESLint и Prettier правят код. commitlint смотрит сообщение.
Установка
Нужен Husky 9+.
# Установка Husky (используйте версию 9+)
npm install --save-dev husky
# Инициализация Husky (создаёт .husky/ директорию)
npx husky initПоявится .husky/ и скрипт "prepare": "husky" в package.json. prepare срабатывает на npm install. Клонировал репозиторий, поставил зависимости: хуки на месте.
lint-staged критичен для скорости. Тысячу файлов не гоняем. Пять изменённых да.
npm install --save-dev lint-stagedmodule.exports = {
// Для JavaScript/TypeScript файлов
"*.{js,jsx,ts,tsx}": [
"eslint --fix", // Автофикс проблем ESLint
"prettier --write", // Форматирование через Prettier
],
// Для стилей
"*.{css,scss,less}": ["prettier --write"],
// Для JSON, Markdown и других файлов
"*.{json,md,mdx,yml,yaml}": ["prettier --write"],
};Сложную логику и комментарии держу в lint-staged.config.js, не в
package.json.
.husky/pre-commit:
# .husky/pre-commit
npx lint-stagedПроверка:
# Создайте файл с ошибкой ESLint
echo "const unused = 'variable'" > test.js
# Добавьте в staging
git add test.js
# Попробуйте закоммитить
git commit -m "test commit"Если чисто:
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
Если ESLint нашёл ошибку, коммит не пройдёт:
✖ eslint --fix:
error 'unused' is assigned a value but never used no-unused-vars
✖ lint-staged failed
TypeScript, тесты, сообщения
// lint-staged.config.js
module.exports = {
// TypeScript/JavaScript файлы
"*.{ts,tsx,js,jsx}": [
"eslint --fix --max-warnings=0", // Блокируем коммит, если есть warnings
"prettier --write",
],
// Проверка типов TypeScript (только один раз на все файлы)
"*.{ts,tsx}": () => "tsc --noEmit",
// Стили
"*.{css,scss,module.css}": ["prettier --write"],
// Markdown и документация
"*.{md,mdx}": ["prettier --write"],
// Конфиги
"*.{json,yml,yaml}": ["prettier --write"],
};tsc проверяет весь проект. Поэтому () => "tsc --noEmit", а не список
файлов. Иначе типы врут.
Тесты перед каждым коммитом слишком медленные. Их кладу в pre-push.
# Создаём pre-push хук
npx husky add .husky/pre-push "npm test"Или файл .husky/pre-push с npm run test:ci.
# Установка commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# Создание конфига
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
# Добавление хука
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'feat: добавлена новая фича
fix: исправлен баг в модуле авторизации
docs: обновлена документация
chore: обновлены зависимости
git commit -m "добавил фичу"
# ⧗ input: добавил фичу
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]На этом сайте так и стоит: Next.js 15, TypeScript, eslint --max-warnings=0, tsc --noEmit, на пуш билд и тесты.
// lint-staged.config.js
module.exports = {
// TypeScript и JavaScript
"*.{ts,tsx,js,jsx}": ["eslint --fix --max-warnings=0", "prettier --write"],
// Type-checking для TypeScript (на весь проект)
"*.{ts,tsx}": () => "tsc --noEmit",
// Стили и CSS Modules
"*.{css,scss}": ["prettier --write"],
// MDX контент (блог, документация)
"*.{md,mdx}": ["prettier --write"],
// JSON конфиги
"*.json": ["prettier --write"],
};# .husky/pre-commit
npx lint-staged# .husky/pre-push
npm run build # Проверяем, что билд не сломан
npm run test:ci # Запускаем тесты// commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
[
"feat", // Новая фича
"fix", // Багфикс
"docs", // Документация
"style", // Форматирование (не влияет на код)
"refactor", // Рефакторинг
"test", // Тесты
"chore", // Обновление зависимостей, конфигов
"perf", // Улучшение производительности
"ci", // CI/CD
"build", // Система сборки
"revert", // Откат коммита
],
],
"subject-case": [0], // Отключаем проверку регистра для русских коммитов
},
};Python
Husky ставится из npm. Хуки запускают что угодно, в том числе Ruff и pytest. Node нужен только чтобы поставить Husky.
Ruff заменяет Flake8, isort и частично Black. Black непримирим к формату. mypy смотрит типы. pytest гоняет тесты.
# Инициализируем npm проект (если его ещё нет)
npm init -y
# Устанавливаем Husky
npm install --save-dev husky lint-staged
npx husky init# Через pip
pip install ruff black mypy pytest
# Или через poetry
poetry add --group dev ruff black mypy pytest
# Или через 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 файлы: проверка Ruff и форматирование Black
"*.py": [
"ruff check --fix", // Проверка и автофикс через Ruff
"black", // Форматирование через Black
"mypy --ignore-missing-imports", // Type checking
],
// Jupyter notebooks (если используете)
"*.ipynb": ["ruff check --fix"],
// YAML конфиги
"*.{yml,yaml}": [
"yamllint", // Линтер для YAML
],
// Markdown документация
"*.md": [],
};# .husky/pre-commit
npx lint-staged# .husky/pre-push
pytest tests/ # Запуск всех тестовТолько Ruff, самый быстрый вариант:
// lint-staged.config.js
module.exports = {
"*.py": [
"ruff check --fix --select I", // Проверка и сортировка импортов
"ruff check --fix", // Проверка и автофикс всех правил
"ruff format", // Форматирование (альтернатива Black)
],
};[tool.ruff]
# Максимальная длина строки
line-length = 88
# Python версия
target-version = "py311"
# Файлы для игнорирования
exclude = [
".git",
".venv",
"__pycache__",
"build",
"dist",
]
[tool.ruff.lint]
# Правила для проверки (эквивалент Flake8, pycodestyle, isort и др.)
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
]
# Игнорируемые правила
ignore = [
"E501", # line-too-long (Black справляется)
]
# Автофикс для правил
fixable = ["ALL"]
unfixable = []
[tool.ruff.format]
# Использовать двойные кавычки
quote-style = "double"
# Отступы
indent-style = "space"
# Совместимость с Black
skip-magic-trailing-comma = false
line-ending = "auto"Ruff быстрее Flake8 и Pylint в десятки раз. На большом репозитории это миллисекунды, не полминуты.
Ruff плюс Black плюс mypy:
// lint-staged.config.js
module.exports = {
"*.py": [
"ruff check --fix --select I", // Сортировка импортов через Ruff
"black --check", // Проверка форматирования
"black", // Применение форматирования
"ruff check --fix", // Линтинг через 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_imports = true
# Строгий режим (опционально)
# strict = truePylint строже и медленнее. На тысяче файлов около 45 секунд против 0.5 у Ruff. Если оставлять, только --errors-only или в pre-push.
// lint-staged.config.js
module.exports = {
"*.py": [
"black", // Форматирование
"isort", // Сортировка импортов
"pylint --errors-only", // Только ошибки (быстрее)
// "pylint", // Полная проверка (медленно)
"mypy", // Type checking
],
};[MASTER]
# Игнорируемые файлы
ignore=CVS,.git,__pycache__,.venv
# Количество процессов (0 = автоопределение)
jobs=0
[MESSAGES CONTROL]
# Отключённые правила
disable=
C0111, # missing-docstring
C0103, # invalid-name
R0903, # too-few-public-methods
W0212, # protected-access
[FORMAT]
# Максимальная длина строки
max-line-length=88
# Отступы
indent-string=' '
[DESIGN]
# Максимум аргументов функции
max-args=7
# Максимум атрибутов класса
max-attributes=10Быстрое в pre-commit, медленное в pre-push:
// lint-staged.config.js
module.exports = {
"*.py": [
"ruff check --fix --select I", // Импорты
"ruff format", // Форматирование
"ruff check --fix", // Быстрые проверки
],
};# .husky/pre-push
#!/bin/sh
# Type checking на весь проект
echo "Running mypy type checking..."
mypy src/
# Полные тесты
echo "Running pytest..."
pytest tests/ -v
# Проверка покрытия тестами (опционально)
# pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=80FastAPI и Poetry, рабочий каркас:
# 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
[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
[tool.black]
line-length = 88
target-version = ['py311']
exclude = '''/(\.venv|migrations)/'''
# Конфигурация mypy
[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
[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: при изменении моделей сухой прогон миграций.
// lint-staged.config.js
module.exports = {
"*.py": [
"ruff check --fix --select I",
"ruff format",
"ruff check --fix",
"mypy --ignore-missing-imports",
],
// Проверка миграций при изменении моделей
"**/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!"Новый проект: Ruff и mypy. Старый: снимаю Pylint постепенно. Корпоративный стандарт: Ruff, Pylint только на ошибки, mypy.
Когда хуки молчат или душат
После клона хуки не бегут: нет "prepare": "husky" или не сделали npm install.
Коммит 30 секунд: гоняется весь репозиторий. Нужен lint-staged, тесты в pre-push, у ESLint флаг --cache.
// lint-staged.config.js
module.exports = {
"*.{ts,tsx,js,jsx}": [
"eslint --cache --fix", // Добавляем --cache
"prettier --write",
],
};Срочный хотфикс:
# Пропустить pre-commit и commit-msg хуки
git commit --no-verify -m "emergency fix"
# Пропустить pre-push хук
git push --no-verify--no-verify оставляю для горящего прода. В остальных случаях чиню то, что
нашёл линтер.
ESLint орёт на то, чего не должен видеть. .eslintignore или фильтр в 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(" ")}`;
},
};Большой легаси не проходит проверки. Сначала только Prettier. Через неделю ESLint --fix. Потом типы и тесты. Или --max-warnings=10 и каждый спринт ниже: 10, 5, 0. Старый код в .eslintignore, новый уже строгий.
{
"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"
}
}validate гоняю перед PR целиком. Хуки берегут историю от ерунды. Пайплайн после этого занимается тем, что локально не поймать.
Документация: Husky, lint-staged, Conventional Commits, commitlint, Ruff, Black, mypy, Pylint, pytest.



