Проверка кода перед коммитом экономит часы на разборах в код-ревью и неделях на отлове банальных ошибок. Хук pre-commit в Git срабатывает ровно в тот момент, когда коммит уже сформирован, но ещё не записан в историю, и умеет отменить его, если проверки упали. Встроенный механизм .git/hooks/pre-commit работает из коробки, но вся соль в том, что файлы из .git/hooks не попадают в репозиторий: клонировать проект и получить готовые проверки не выйдет. Поэтому поверх голого механизма повзрослели два инструмента установки хуков в команду разом: хаски (husky) для Node.js-проектов и фреймворк pre-commit для всего остального. Ниже разберём оба пути с реальными конфигами, выводом команд и теми подводными камнями, о которых обычно узнают методом тыка.
Устройство хука pre-commit и поле боя .git/hooks
Git ищет исполняемые скрипты хуков в каталоге .git/hooks. После git init там лежат образцы с суффиксом .sample, и стоит снять суффикс и дать скрипту бит исполнения, как хук оживает. Хук pre-commit вызывается без аргументов, а его код выхода решает судьбу коммита: ноль пропускает коммит дальше, любое ненулевое значение его отменяет.
Минимальный самодельный хук выглядит так:
#!/bin/sh
# .git/hooks/pre-commit
if grep -rn "console.log" --include="*.js" src/; then
echo "Обнаружен console.log, коммит отменён"
exit 1
fi
Разбор по строкам. Первая строка задаёт интерпретатор: POSIX-совместимый sh, чтобы скрипт работал и на Linux, и на macOS. Команда grep с флагом -r идёт рекурсивно, -n печатает номера строк, а --include="*.js" ограничивает поиск файлами с нужным расширением. Если grep что-то нашёл, он вернул ноль, условие сработало, echo написало предупреждение в терминал, а exit 1 велел Git отменить коммит. Если совпадений нет, скрипт молча завершится нулём, и коммит пройдёт.
Проверим руками:
$ git commit -m "feat: add parser"
Обнаружен console.log, коммит отменён
Коммит не создан, индекс не тронут, изменения на месте. В команде такие скрипты разводятся вручную с трудом: версии расходятся, про chmod +x кто-нибудь забудет. Отсюда два механизма выравнивания.
Первый встроенный в Git: параметр core.hooksPath перенаправляет поиск хуков из .git/hooks в любой каталог, например в версионируемый .githooks внутри репозитория:
git config core.hooksPath .githooks
После этого Git читает хуки из .githooks, каталог коммитится вместе с кодом, и коллеге достаточно один раз выполнить ту же команду. Локальная настройка через репозиторий не распространяется, поэтому здесь вступают внешние установщики, которые делают этот шаг за разработчика автоматически.
Установка husky и создание .husky/pre-commit
Хаски решает задачу элегантно: он ставит core.hooksPath на собственный каталог .husky и делает это при установке зависимостей. С версии husky 9 поток такой:
npm install --save-dev husky
npx husky init
Команда init создаёт каталог .husky, кладёт туда заготовку pre-commit и прописывает в package.json скрипт "prepare": "husky". Скрипт prepare npm выполняет при каждом npm install, так что хук врубается сам собой у каждого, кто клонирует проект и ставит зависимости. За кулисами husky вызывает git config core.hooksPath .husky, в этом весь фокус.
Файл .husky/pre-commit - обычный shell-скрипт. Минимальное содержимое:
npm test
А для проверки стиля только изменённых файлов сюда вписывают запуск lint-staged:
npx lint-staged
Что происходит при коммите:
$ git commit -m "fix: currency rounding"
[SAT] lint-staged v15.5.1
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
[main 1f6c2a9] fix: currency rounding
2 files changed, 14 insertions(+), 2 deletions(-)
Разбор. Строка с версией подтверждает, что lint-staged запустился именно из хука. Шаг "Preparing" делает stash ещё не заиндексированных изменений, чтобы проверки шли только по тому, что реально попадёт в коммит. "Running tasks" прогоняет команды из конфига по списку staged-файлов. "Applying modifications" возвращает автоисправления (например, результат prettier --write) обратно в индекс. "Cleaning up" убирает временный stash. Последние строки - обычное резюме коммита от Git: ветка main, хеш 1f6c2a9, статистика изменений. Если любая из проверок упала бы ненулевым кодом, коммита бы не было вообще.
Сам конфиг lint-staged чаще держат в package.json или в отдельном .lintstagedrc.json:
{
"*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,yml}": "prettier --write",
"*.css": "stylelint --fix"
}
Ключ - glob-шаблон над staged-файлами, значение - команда или массив команд, которые выполняются последовательно. Нюанс, о котором забывают: lint-staged передаёт командам список файлов аргументами, поэтому тяжёлые задачи вроде tsc --noEmit на всём проекте там сломаются, если написать их бездумно. Для полной типизации в lint-staged.config.js используют функцию без подстановки файлов: "() => 'tsc --noEmit'".
Фреймворк pre-commit и его .pre-commit-config.yaml
Для проектов не на Node.js, а чаще вообще для полиглотских репозиториев, принят другой инструмент - pre-commit framework на Python. Ставится через pip или пакетный менеджер:
pip install pre-commit
pre-commit install
Вторая команда записывает прослойку в .git/hooks/pre-commit. Дальше логика живёт в версионируемом файле .pre-commit-config.yaml в корне репозитория:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files
args: ["--maxkb=1024"]
- id: check-merge-conflict
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
language_version: python3.12
- repo: local
hooks:
- id: pytest-quick
name: быстрые тесты
entry: pytest -x -q tests/unit
language: system
pass_filenames: false
always_run: true
Разбор ключевых мест. Блок repos перечисляет источники хуков, каждый с репозиторием и пином версии rev - грамотная защита от внезапно изменившегося поведения. Хуки из pre-commit-hooks - рабочая классика: trailing-whitespace срезает пробелы в конце строк, end-of-file-fixer гарантирует перевод строки в конце файла, check-added-large-files с порогом --maxkb=1024 не даёт случайно закоммитить мегабайтный бинарник, check-merge-conflict ловит забытые маркеры конфликта после неудачного слияния. Секция repo: local - способ подключить свою команду: language: system значит "выполнять как есть, без изолированного окружения", pass_filenames: false отключает подстановку имён файлов, а always_run: true запускает проверку на каждом коммите, даже если релевантных файлов нет.
Первый прогон и установка окружений:
$ pre-commit run --all-files
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check for added large files..............................................Passed
Check for merge conflicts................................................Passed
black....................................................................Failed
- hook id: black
- files were modified by this hook
быстрые тесты............................................................Passed
Разбор. Каждая строка - результат одного хука. Passed означает, что файл остался нетронут и проверка чиста. Failed тут не авария, а сообщение: black переформатировал часть файлов сам, строка "files were modified by this hook" говорит именно об этом. Прогоняем повторно - и Failed превращается в Passed, потому что исправить больше нечего. Флаг --all-files прогоняет хуки по всему дереву, что удобно для первичного приведения старого репозитория к новым правилам; в обычной работе хуки бьют только по staged-файлам.
Пошаговый workflow настройки хуков в командном проекте
Последовательность ровно та, по которой заходят с нуля и не наступают на типовые грабли:
- Решаем, какой стек доминирует: Node.js берёт husky + lint-staged, Python и смешанные проекты берут framework pre-commit;
- Ставим инструмент через пакетный менеджер проекта и инициализируем: npx husky init либо pre-commit install;
- Кладём конфиг в корень репозитория и коммитим его вместе со скриптом prepare или инструкцией в README, чтобы коллеги получили хуки после обычной установки зависимостей;
- Прогоняем проверки на всём дереве единоразово через pre-commit run --all-files или ручной запуск линтеров и закоммичиваем автоисправления отдельным коммитом;
- Делаем пробный коммит с намеренно сломанным файлом и убеждаемся, что хук его отклоняет с понятным сообщением;
- Прикрываем тыл в CI: те же проверки продублированы в пайплайне, потому что локальный хук разработчик может обойти, а серверный пайплайн - нет.
Пункт четыре заслуживает отдельного абзаца. Если включить автоформаттер и сразу коммитить фичу, в diff насыплются сотни строк чисто косметических правок, и ревью превратится в угадайку. Отдельный коммит "chore: apply pre-commit autofixes" решает это на раз, а файл .git-blame-ignore-revs с хешем этого коммита позволяет git blame не спотыкаться о косметику:
git config blame.ignoreRevsFile .git-blame-ignore-revs
Обход проверок через --no-verify и когда это оправдано
У Git есть аварийный клапан: флаг --no-verify (короткая форма -n) пропускает хуки pre-commit и commit-msg:
git commit --no-verify -m "wip: черновик для переноса на другой ноутбук"
Хуки молча не выполняются, коммит записывается как есть. Это заложенное поведение Git, и ни husky, ни pre-commit framework его не блокируют - и сам флаг обходит исполнение по дизайну Git, поэтому блокировать его инструментами нельзя.
Когда обход нормален: рабочий WIP-коммит, который утром уедет в rebase -i для склеивания; экстренный фикс, где упавший по смешной причине хук дороже правильности; работа за чужой машиной без установленного окружения. Тревожный звонок - когда --no-verify фигурирует в большинстве коммитов автора: хук либо душно медленный, либо ложно ругается, и чинить надо хук, а не привычку. Финальный барьер всё равно стоит CI.
git log --grep="wip" --oneline -i | wc -l
Другое полезное знание: хуки не срабатывают на коммитах, созданных через git merge без конфликтов, если мёрдж-коммит не требует редактирования сообщения, и на части вызовов из некоторых GUI-клиентов с собственной логикой. Надеяться на локальный хук как на единственную линию контроля не стоит.
Типичные ошибки и профилактика проблем с хуками
Ошибка номер один: хук молча не запускается после клонирования. Причина почти всегда в том, что husky не выполнился, потому что npm install запускали с флагом --ignore-scripts или среда CI отключила lifecycle-скрипты. Лечение - ручной запуск npm run prepare или npx husky. Вторая по частоте беда: core.hooksPath указан глобально в ~/.gitconfig и перебивает каталог .husky, после чего одна команда ставит хуки туда, другая - сюда. Проверяется мгновенно:
git config --get core.hooksPath
Если команда вернула чужой путь, источник значения ищется через git config --show-origin --get core.hooksPath.
Следующая классика - производительность. Хук, который гоняет полный прогон тестов на каждом коммите, живёт недолго: через неделю команда массово жмёт --no-verify. Здоровый бюджет для pre-commit - секунды, не минуты. Тяжёлое (интеграционные тесты, e2e) выносят в pre-push или в CI, локально оставляют статические проверки по изменённым файлам. На Windows из-под Git Bash дополнительно ловят падения из-за разных окончаний строк и отсутствия python в PATH для фреймворка pre-commit; там помогает установка через системный пакетный менеджер и явный language_version в конфиге.
Ещё одна засада - автоисправляющие хуки при частично добавленном в индекс файле. Когда в коммит отправлена часть hunks через git add -p, а prettier переписал весь файл, свежие версии lint-staged корректно откатывают такую ситуацию с понятной ошибкой, но на старых версиях индекс мог поплыть вместе с рабочей копией. Профилактика - актуальная версия инструмента и осторожность с частичным индексированием.
И финальный штрих для устойчивости: хуки оформляют как код. Конфиги в репозитории, пины версий, обновление через pre-commit autoupdate с ревью diff, а не по наитию. Тогда проверка качества перед коммитом перестаёт быть бюрократией и становится тем, чем должна быть, - спокойной автоматической рутиной, которая ловит мелочи, пока они ещё дёшевы.