Перейти к содержимому

Husky в рабочем проекте

Константин Потапов
14 мин

CI красный из-за unused import, который линтер ловит за пять секунд. Хуки до коммита для JS и Python: lint-staged, Ruff, commitlint.

Husky в рабочем проекте

Пушишь. CI падает на линтере. Ошибку можно было поймать локально за пять секунд. Коллеги ждут, пайплайн занят, ещё один коммит с фиксом.

Или в ревью обсуждают console.log и отступы. На архитектуру времени не остаётся.

Сломанный пайплайн: 10-15 минут. История коммитов грязная. Сломанное иногда доезжает до прода.

Хуки Git запускаются до коммита, до пуша, после чекаута. Husky кладёт их в репозиторий так, чтобы после npm install они завелись у всех.

Проблема должна умирать на машине автора, не в общем пайплайне.

0 мин
Время на фикс сломанного CI
100%
Код проходит проверки до коммита
-80%
Споров о форматировании на ревью
5-10 сек
Время проверки перед коммитом
ESLintPrettierTypeScriptJestVitestcommitlintlint-staged

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-staged
module.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.

RuffBlackmypypytestisort

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.txt
module.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 = true

Pylint строже и медленнее. На тысяче файлов около 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=80

FastAPI и 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!"
Традиционный стек
Современный стек
Инструмент
Pylint
Ruff
Скорость проверки (1000 файлов)
~45 секунд
~0.5 секунды
99%
Строгость проверок
Очень высокая
Высокая
Автофикс
Нет
Да

Новый проект: 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.