14.1 Hook - это скрипт
В каждом репозитории есть .git/hooks/ с шаблонами:
.git/hooks/
pre-commit.sample
commit-msg.sample
pre-push.sample
post-merge.sample
...
.sample - это «выключенные». Чтобы hook сработал, нужно:
- Снять расширение
.sample(или создать новый файл без него). - Сделать исполняемым (
chmod +x .git/hooks/pre-commit). - Написать туда любой исполняемый код: bash, python, что угодно - лишь бы первая строка shebang указывала, чем это запустить.
Минимальный pre-commit hook:
#!/usr/bin/env bash
# .git/hooks/pre-commit
if grep -r "TODO" --include="*.py" src/; then
echo "Found TODO in code. Commit blocked."
exit 1
fi
Git вызывает этот скрипт перед каждым коммитом. Если он
возвращает 0 - коммит проходит. Если ненулевой - коммит
отменяется. Это весь протокол.
.git/hooks/ - локальные
Hooks не клонируются. Когда коллега делает git clone,
директория .git/hooks/ у него пустая (точнее, с теми же
.sample-шаблонами). Это значит:
- Hook нельзя «коммитнуть». Если ты добавил скрипт в
.git/hooks/, его никто, кроме тебя, не получит. - Hook у каждого разработчика свой. Один может включить проверку перед коммитом, другой - нет.
Это by design: hooks - это локальная политика разработчика, не политика репозитория. Чтобы политика была общей, есть отдельные инструменты (см. секцию про pre-commit framework ниже).
14.2 Client-side hooks: до и после локальных операций
Самые полезные client-side hooks:
pre-commit
Запускается перед git commit, после git commit -m "...", но
до создания коммита. Если возвращает ненулевой код - коммит
не создаётся.
Типичное использование:
- Запустить linter / formatter и отказать в плохом коде.
- Проверить, что нет TODO/FIXME в чувствительных файлах.
- Запретить коммит секретов (с помощью detect-secrets, см. secret-scanning).
#!/usr/bin/env bash
# Запустить ruff на изменённых .py файлах
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
if [ -n "$files" ]; then
ruff check $files || exit 1
fi
commit-msg
Запускается после ввода сообщения коммита. На входе - путь к файлу с сообщением. Может:
- Проверить формат (например, conventional-commits).
- Дополнить сообщение (добавить ID тикета из имени ветки).
- Отказать, если сообщение пустое или нарушает правила.
#!/usr/bin/env bash
msg_file=$1
first_line=$(head -1 "$msg_file")
if ! echo "$first_line" | grep -qE '^(feat|fix|chore|docs|refactor|test)(\(.+\))?: .+'; then
echo "Commit message must start with feat/fix/chore/... See [[conventional-commits]]."
exit 1
fi
pre-push
Запускается перед git push. На входе - имя remote и URL.
Может:
- Не дать запушить, если локальные тесты падают.
- Заблокировать push в защищённые ветки (main).
#!/usr/bin/env bash
protected_branch="main"
current=$(git symbolic-ref --short HEAD)
if [ "$current" = "$protected_branch" ]; then
echo "Direct push to $protected_branch is not allowed."
exit 1
fi
post-merge, post-checkout, post-rewrite
«After»-hooks: запускаются после операции. Не могут отменить - операция уже произошла. Полезны для «привести окружение в порядок»:
post-merge- обновить зависимости, если изменился package.json/requirements.txt.post-checkout- пересобрать assets, если переключили ветку.
#!/usr/bin/env bash
# post-merge: переустановить deps при изменении requirements.txt
if git diff HEAD@{1} HEAD --name-only | grep -q '^requirements\.txt$'; thenpip install -r requirements.txt
fi
Эти hooks не могут отменить операцию, но могут спасти от ситуации «забыл переустановить deps после смены ветки, питон не импортирует, час ищу почему».
14.3 Server-side hooks: централизованная политика
На сервере (bare-репозитории) есть свои hooks. Они запускаются при приёме push'а:
- pre-receive - перед принятием любого ref'а. Если возвращает ненулевой код, весь push (все ветки в одном push'е) отменяется.
- update - для каждого пушимого ref'а отдельно. Можно принять одни и отвергнуть другие.
- post-receive - после приёма всех refs. Используется для нотификаций, триггеров CI, деплоя.
Server-side hooks нельзя обойти: они выполняются на сервере, до
того как изменения становятся видимыми. В отличие от
client-side, которые можно проигнорировать через
git commit --no-verify.
Когда они нужны
Server-side hooks устанавливают только админы репозитория. Типичные случаи:
- Запрет force-push на main. Хотя обычно проще через branch protection на форджах (GitHub/GitLab/Bitbucket).
- Проверка подписей коммитов - все коммиты должны быть подписаны GPG (gpg-signing).
- Проверка через линтер всего pushed-кода, если local hooks могли быть пропущены.
- Триггер CI/CD через post-receive (хотя обычно это webhook'ом, не hook'ом).
В мире форджей (GitHub/GitLab/Bitbucket)
Server-side hooks в чистом виде есть только в self-hosted bare-репах. На GitHub их нет - взамен GitHub Apps, branch protection, required status checks, CODEOWNERS. GitLab имеет push rules в Premium-тарифе. Bitbucket Server - да, настоящие hooks.
Если ты на GitHub - не пытайся «делать что-то на pre-receive», этого механизма у тебя нет. Используй branch protection и Actions/Apps.
14.4 Husky и pre-commit framework: команда вместо хаоса
Так как .git/hooks/ не клонируется, на проектах с несколькими
разработчиками нужен способ держать hooks одинаковыми у всех.
Два самых популярных решения: Husky (JavaScript-проекты) и
pre-commit (универсальный, на Python).
Husky (для JS/TS-проектов)
Husky устанавливается через npm и регистрирует git-hooks,
которые запускают скрипты из package.json:
// package.json
{ "scripts": {"prepare": "husky install"
},
"devDependencies": {"husky": "^9.0.0"
}
}
# .husky/pre-commit
npx lint-staged
Когда новый разработчик делает npm install, скрипт prepare
устанавливает husky, husky регистрирует hooks из .husky/.
Теперь pre-commit одинаковый у всех.
В паре с lint-staged запускает linter только на staged файлах, а не на всём проекте. Это критично для скорости.
pre-commit framework
pre-commit - отдельный инструмент, написан на Python, но
работает с любым языком. Конфиг лежит в .pre-commit-config.yaml:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
Установка:
pip install pre-commit
pre-commit install
pre-commit install создаёт .git/hooks/pre-commit, который
запускает hooks из .pre-commit-config.yaml. После этого
команда коммитит - hooks запускаются.
Плюсы pre-commit:
- Огромный каталог готовых hooks (ruff, prettier, black, shellcheck, eslint, dozens of others).
rev:пинит версию hook'а. Воспроизводимость.pre-commit autoupdateобновляет все hooks разом.- Работает на любом языке, не только Python.
Какой выбрать
| Что | Husky | pre-commit |
|---|---|---|
| Язык проекта | JS/TS | любой |
| Установка | через npm | через pip/brew |
| Конфиг | package.json + .husky/ | .pre-commit-config.yaml |
| Каталог готовых hooks | через lint-staged + плагины | свой большой каталог |
| Версионирование hooks | через package.json | через rev: в конфиге |
Если проект JS/TS - обычно husky привычнее. Если многоязычный или Python/Go/Rust/etc. - pre-commit.
В любом случае выбери что-то одно и зафиксируй в README:
«после clone'а сделай npm install (или pre-commit install)».
Без этого hooks не активируются у нового разработчика.
14.5 Что хорошо делать в hooks, а что - нет
Простое правило: hook должен быть быстрым и детерминированным.
Хорошо в pre-commit:
- Формат кода (
ruff format,prettier). - Лёгкий статический анализ (linter).
- Проверка YAML/JSON на синтаксис.
- Проверка, что нет конфликт-маркеров (
<<<<<<<). - Запрет коммитить большие бинари.
- Поиск секретов через secret-scanning.
Плохо в pre-commit:
- Тесты. Тесты идут в CI, не в hook. Если тесты медленнее
нескольких секунд - разработчик отключит hook через
--no-verifyи не вернётся. - Сетевые запросы. Hook должен работать офлайн.
- Долгий статический анализ. mypy на всё дерево - пять минут, никому не нравится. Если он должен быть - в CI.
- Сложные миграции. Изменение содержимого других файлов в ответ на изменение этого - обычно усложняет, а не помогает.
Правило 5 секунд
Pre-commit должен укладываться в 5 секунд на типичном изменении.
Если дольше - разработчики начнут обходить через --no-verify,
и hook потеряет смысл.
Способы оставаться быстрым:
- Запускать только на staged файлах (
git diff --cached --name-only), не на всём дереве.lint-staged(для JS) или опцияfiles:в pre-commit framework делают это автоматически. - Кэшировать результаты. ruff, prettier, eslint умеют пропускать неизменившиеся файлы.
- Не запускать тяжёлое в hook. Сложный typecheck - в CI.
Hooks ≠ заменитель CI
Hook - это «быстро поймать очевидные ошибки до коммита». CI - «полная проверка перед мержем». Они дополняют, не дублируют:
- Hook отказывает в коммите с непрошедшим formatter'ом → ты исправляешь сразу, не доводишь до push'а.
- CI прогоняет всё тесты + security scan + build → ты не мерджишь сломанное.
Если ты ставишь в hook то же, что и в CI - это медленно и нудно. Hooks - только лёгкая, быстрая проверка.
--no-verify
Команда коммита поддерживает git commit --no-verify (или -n).
Это пропускает все client-side hooks. Полезно в одной ситуации:
работа в emergency / hotfix-режиме, когда CI потом всё проверит
и нужно зафиксировать состояние сейчас.
Опасно - если становится привычкой. Если ты постоянно
--no-verify - значит hook раздражает; разбираться, починить
hook или ослабить его, а не привыкать обходить.
14.5.1 Подводный камень: hook на rebase
Tricky случай: на git rebase Git'у нужно пересоздавать коммиты,
и pre-commit на каждом из них (по умолчанию rebase запускает
hooks) превращает большой rebase в утомительное упражнение.
Отдельно от pre-commit есть свой pre-rebase hook, который
срабатывает один раз в начале rebase, - его обычно не путают с
pre-commit.
Если pre-commit делает что-то «правильное» вроде «удалить trailing whitespace» - он будет делать это снова и снова на каждом rebased коммите. На большом rebase это утомительно, и иногда приводит к конфликтам с самим собой.
Решения:
- Отключить hooks на время rebase:
Это пройдёт rebase, не запуская pre-commit/commit-msg.
git -c core.hooksPath=/dev/null rebase main
- Сделать hook идемпотентным. Если
pre-commit runдважды на одном файле даёт одинаковый результат - повторные запуски безопасны.
Про git stash стоит знать отдельно: native --no-verify у
stash нет (в Git 2.x команда такого флага не принимает). Если
pre-commit мешает stash'у, отключай hooks через тот же
core.hooksPath=/dev/null или временно убирай
.git/hooks/pre-commit.
Это редкая боль, но регулярно появляется в больших rebase'ах. Если ты её заметил - знай, что лечение есть, и не списывай на «hooks плохие».
Уроки в sandbox
lab-14.1. Настроить pre-commit с ruff и prettier
Цель - пройти полный цикл «установить pre-commit, настроить
два hook'а (ruff для Python и prettier для JSON/YAML), увидеть,
как он отказывает в плохом коммите и как форматирует
автоматически». В конце - попробовать обойти через --no-verify
и решить, в каких сценариях это оправдано.
Создай репо с парой плохо отформатированных файлов:
mkdir -p /tmp/hooks-lab && cd /tmp/hooks-lab && git init && cat > app.py <<EOF def hello( name ): print('hi',name ) EOF cat > config.yaml <<EOF port: 8080 name:foo EOF git add . && git commit -m 'initial messy code'.Установи pre-commit framework:
pip install pre-commit(илиbrew install pre-commit, если на macOS). Проверь:pre-commit --version. Должно показать версию.Создай конфиг
.pre-commit-config.yaml:yamlrepos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
types_or: [yaml, json, markdown]
Закоммить конфиг:
git add .pre-commit-config.yaml && git commit -m 'add pre-commit config'.Активируй pre-commit:
pre-commit install. Это создаст.git/hooks/pre-commit, который при коммите будет запускать наши hooks. Проверь:ls .git/hooks/pre-commit.Попробуй сделать новый коммит. Сначала измени файл:
echo "# extra line" >> app.py && git add app.py && git commit -m 'edit app'. Pre-commit запустит ruff на app.py.Что увидишь: pre-commit скачает ruff (это разовый шаг), затем запустит. Ruff найдёт проблемы (лишние пробелы, неправильная отступа, неконсистентный кавычки) и исправит автоматически через
--fix. Pre-commit пометит файл как «изменён hook'ом», и попросит сноваgit add.Снова:
git add app.py && git commit -m 'edit app'. Теперь pre-commit пройдёт (файл чистый), коммит создастся.Аналогично проверь prettier:
echo "key:val" >> config.yaml && git add config.yaml && git commit -m 'edit config'. Prettier исправит форматирование, попросит re-add.Сценарий обхода: хочется в emergency-режиме зафиксировать без проверок.
echo 'broken=' >> config.yaml && git add config.yaml && git commit -m 'wip' --no-verify. Этот коммит пройдёт без pre-commit. Делай это редко и осознанно: всё, что обошло hook, рано или поздно поймает CI.Запусти pre-commit вручную на всех файлах:
pre-commit run --all-files. Это полезно, чтобы пройтись по существующему коду после первой настройки и привести его в порядок.Итог: ты настроил автоматический форматтер + линтер, который ловит проблемы до коммита. Никаких ручных
ruff format+prettier --writeперед commit'ом.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Hook - это обычный исполняемый файл в .git/hooks/. Если возвращает 0 - операция продолжается, иначе - отменяется (для pre-hooks).
- Client-side hooks (pre-commit, commit-msg, pre-push) живут локально, не клонируются. Server-side hooks (pre-receive, post-receive) - только на bare-репозитории, на GitHub их в чистом виде нет.
- Чтобы hook'и были одинаковыми у команды - нужен фреймворк: Husky (для JS-проектов) или pre-commit framework (универсальный).
- Pre-commit framework хранит конфиг в .pre-commit-config.yaml, пиннит версии каждого hook'а через rev:, имеет каталог готовых hooks для большинства языков.
- Что хорошо в hook'е: format, лёгкий lint, проверка YAML/JSON/синтаксиса, поиск секретов. Плохо: тесты, сетевые запросы, тяжёлый анализ. Правило 5 секунд - иначе hook будут обходить.
- Hooks ≠ CI. Hook ловит очевидное до коммита; CI делает полную проверку перед мержем. --no-verify оправдан в emergency, не в привычке.
- На GitHub server-side hooks недоступны - используй branch protection, required status checks, CODEOWNERS, GitHub Apps.
Контрольные вопросы
Я настроил pre-commit hook у себя, коммитнул, запушил. Коллега pullит - у него pre-commit не активируется. Почему?
Показать ответ
Потому что
.git/hooks/не клонируется. Hooks - это локальная политика разработчика, и Git намеренно их не шарит через repo. Чтобы политика была общей, нужен внешний механизм:- Husky (для JS):
npm installавтоматически активирует hooks через скриптprepare. - pre-commit framework: после clone'а коллега делает
pre-commit installодин раз.
Обычно в README прописывают: «после clone - выполни команду X для активации hooks». Если этого нет, hooks работают только у тех, кто сам их настроил, и качество кода будет разнородным.
- Husky (для JS):
В pre-commit hook'е я хочу запустить полный pytest. Хорошо или плохо?
Показать ответ
Плохо. Тесты в pre-commit - антипаттерн, по двум причинам:
- Скорость. Полный pytest на нормальном проекте - это
минуты, иногда десятки минут. На каждом коммите. Разработчики
перестанут терпеть и начнут
--no-verifyвсё подряд. Hook потеряет смысл. - Дублирование с CI. Тесты гоняются в CI на каждый PR. Если pre-commit гоняет то же - это лишняя нагрузка локально на разработчика.
В pre-commit оставь:
- format (
ruff format,prettier,gofmt), - lint только на изменённых файлах (быстро),
- проверки syntax YAML/JSON,
- поиск секретов.
Тесты - в CI. Там у них своё окружение, своё время, и они не блокируют коммит-цикл разработчика.
- Скорость. Полный pytest на нормальном проекте - это
минуты, иногда десятки минут. На каждом коммите. Разработчики
перестанут терпеть и начнут
Можно ли запретить force-push в main через client-side hook?
Показать ответ
Можно, но это ненадёжная страховка. Pre-push hook вызывается перед push'ем и может отменить его. Но:
- Hook у каждого разработчика свой. Если коллега не настроил - защиты у него нет.
git push --no-verifyобходит pre-push hook (как и любой client hook).- Hook не действует на push с другого клона или другой машины.
Реальная защита - на стороне сервера: branch protection в GitHub/GitLab/Bitbucket. Включи «no force pushes» на main, и сервер физически откажется принимать force-push, независимо от того, что у разработчиков настроено. Pre-push hook полезен как дополнительная проверка, но не вместо protection.
Pre-commit срабатывает при `git rebase`, каждый коммит формирует. На длинном rebase бесит. Что делать?
Показать ответ
Это поведение by design: каждый rebased коммит - это новый commit, и hook отрабатывает на нём. Два пути:
-
Временно отключить hook на время rebase:
git -c core.hooksPath=/dev/null rebase main
Это для длинного rebase. После rebase verify-fix-amend цикл всё равно понадобится, чтобы hook прошёл на финальном состоянии.
-
Сделать hook идемпотентным. Если
ruff formatзапущен повторно на уже отформатированном файле - он ничего не меняет, hook проходит мгновенно. Это норма для нормальных форматтеров. Если ты ловишь «infinite loop hook'а на rebase» - у тебя hook не идемпотентен, и это его починить.
Вариант 1 быстрее, вариант 2 - правильнее долгосрочно.
-
Я в команде, мы хотим перейти на pre-commit framework. С чего начать?
Показать ответ
Минимальный план:
- Создай
.pre-commit-config.yamlс самыми очевидными hooks: trailing-whitespace, end-of-file-fixer, check-yaml, check-merge-conflict, форматтер для основного языка (ruff/prettier/gofmt). - Прогони
pre-commit run --all-filesна всём дереве. Это поправит весь существующий код. Сделай это отдельным большим PR - он не будет «логически содержательным», но обновит форматирование сразу везде. - Закоммить
.pre-commit-config.yamlв репо. Так все его видят. - Добавь в README инструкцию: «после clone выполни
pre-commit install». Без этого hooks не активируются. - Добавь в CI шаг
pre-commit run --all-files. Это защита от тех, кто забыл сделать install или сделал--no-verify.
Шаги 2 и 5 - самые важные. Без шага 2 ты будешь долго разбирать «зачем мне эти 200 файлов с whitespace-правками» в каждом PR. Без шага 5 не будет гарантии, что hook реально работает у всех.
- Создай