Что такое git hooks и где они физически лежат
Хук - это обычный исполняемый файл, который Git запускает сам в определённый момент своей работы. Никакой магии: перед коммитом Git смотрит, есть ли исполняемый файл с именем pre-commit в каталоге хуков, и если есть - запускает его. Код выхода этого файла решает судьбу операции. Ноль - продолжаем, любое ненулевое значение - Git отменяет операцию.
Лежат они по умолчанию в .git/hooks. Загляни туда в любом репозитории:
Код: Выделить всё
$ ls -1 .git/hooks
applypatch-msg.sample
commit-msg.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-push.sample
prepare-commit-msg.sample
update.sample
Код: Выделить всё
$ cd .git/hooks
$ mv pre-commit.sample pre-commit
$ chmod +x pre-commit

Клиентские хуки: pre-commit, commit-msg, pre-push и друзья
Клиентские хуки срабатывают на твоей машине вокруг локальных операций. Разберём рабочую четвёрку.
pre-commit - запускается ДО того, как Git начнёт формировать коммит, ещё до ввода сообщения. Сюда вешают линт, автоформат, проверку на отладочный мусор. Хук видит то, что в индексе (staging area). Классическая ошибка - проверять рабочую копию, а не индекс: если ты сделал git add файла, потом поправил его, в коммит уйдёт версия из индекса, а линт ты прогнал по другой. Правильно - проверять именно staged-версию. Минимальный pre-commit, отбивающий отладочные принты:
Код: Выделить всё
#!/bin/sh
# блокируем коммит, если в индексируемых .js есть console.log
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
[ -z "$files" ] && exit 0
if git diff --cached -- $files | grep -q 'console\.log'; then
echo "Найден console.log в staged-изменениях. Убери и попробуй снова." >&2
exit 1
fi
exit 0
prepare-commit-msg - запускается после pre-commit, но до открытия редактора. Получает аргументами путь к файлу с черновиком сообщения. Тут удобно подставить шаблон: номер задачи из имени ветки, префикс типа коммита, заготовку. Текст, который хук запишет в файл, ты увидишь уже в редакторе.
commit-msg - запускается после того, как ты ввёл сообщение, и получает путь к файлу с этим сообщением. Это место для валидации формата. В 2026 стандарт де-факто - Conventional Commits: сообщение вида type(scope): описание, где type из закрытого списка (feat, fix, docs, refactor, test, chore и так далее). Зачем такая дисциплина - потому что из этих префиксов инструменты semantic-release и release-please автоматически собирают CHANGELOG и поднимают версию по SemVer. Сломанный формат рушит автоматику, поэтому ловим его прямо тут:
Код: Выделить всё
#!/bin/sh
# commit-msg: первый аргумент - путь к файлу сообщения
pattern='^(feat|fix|docs|style|refactor|perf|test|chore)(\(.+\))?: .{1,}'
if ! grep -qE "$pattern" "$1"; then
echo "Сообщение не по Conventional Commits." >&2
echo "Пример: feat(auth): добавить вход по SSH-ключу" >&2
exit 1
fi
Серверные хуки: pre-receive, update, post-receive
Серверные хуки живут на голом (bare) репозитории, куда народ пушит. Их принципиальное отличие от клиентских - их нельзя обойти. Клиентский хук на твоей машине; хочешь - снёс его. Серверный исполняется на сервере, который ты не контролируешь, и потому это настоящий enforcement, а не вежливая просьба.
pre-receive - срабатывает один раз на весь push, ДО применения изменений. На stdin получает по строке на каждый обновляемый ref: старый sha, новый sha, имя ref. Если хук вернёт ненулевой код - отклоняется ВЕСЬ push целиком, ни одна ветка не обновится. Сюда вешают политику: запрет force-push в защищённые ветки, требование подписи, проверку, что автор коммита из разрешённого домена.
update - почти то же, но вызывается ОТДЕЛЬНО для каждого обновляемого ref и получает три аргумента: имя ref, старый sha, новый sha. Разница тонкая, но важная: с update можно принять обновление одной ветки и отклонить другой в рамках одного push. pre-receive - "всё или ничего", update - поштучно.
post-receive - срабатывает ПОСЛЕ того, как изменения уже приняты. Повлиять на исход не может, его код выхода игнорируется. Зато это идеальная точка для побочных эффектов: дёрнуть CI, задеплоить, отправить уведомление в чат, обновить тикет. На stdin - те же строки old new ref, что и у pre-receive.
Код: Выделить всё
#!/bin/sh
# post-receive: триггерим деплой только при пуше в main
while read oldrev newrev ref; do
if [ "$ref" = "refs/heads/main" ]; then
echo "main обновлён, запускаю деплой..."
/opt/deploy/run.sh "$newrev" &
fi
done
Почему хуки не версионируются и как это обойти: core.hooksPath и фреймворки
Вернёмся к главной проблеме: .git/hooks не уезжает с push, значит у каждого в команде свой набор хуков, то есть по факту никакого. Есть три способа это починить, по возрастанию цивилизованности.
Способ 1: core.hooksPath. Эта настройка переопределяет каталог хуков. Кладёшь хуки в обычную папку внутри репозитория, например .githooks/, версионируешь её как любой код, а каждый разработчик один раз выполняет:
Код: Выделить всё
$ git config core.hooksPath .githooks
Способ 2: фреймворк pre-commit - стандарт 2026. Несмотря на имя, это не про хук pre-commit, а про целый менеджер хуков на Python (ставится через pip или uv). Идея: ты не пишешь bash-скрипты руками, а декларативно описываешь нужные проверки в версионируемом файле .pre-commit-config.yaml. Сами проверки тянутся из готовых репозиториев-источников по pinned-версии, что делает прогон воспроизводимым у всех.
Код: Выделить всё
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
Код: Выделить всё
$ pre-commit run --all-files
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
detect private key.......................................................Passed
ruff.....................................................................Failed
- hook id: ruff
- files were modified by this hook
Способ 3: husky/lefthook - для JS-команд. husky прописывает core.hooksPath в свою папку и активируется через npm-скрипт prepare, так что после npm install хуки уже на месте. lefthook - быстрая альтернатива на Go с YAML-конфигом и параллельным запуском. Выбор между pre-commit, husky и lefthook чаще про стек: Python/смешанный - pre-commit, чистый Node - husky или lefthook.
Способ 4 (новинка Git 2.54, апрель 2026): хуки прямо в конфиге. Раньше core.hooksPath был "всё или ничего" - одна общая папка на репозиторий. Git 2.54 добавил конфиг-секции hook, где можно объявить несколько команд на одно событие, и они выполнятся в порядке появления в конфиге:
Код: Выделить всё
[hook "linter"]
event = pre-commit
command = ~/bin/linter
[hook "no-leaks"]
event = pre-commit
command = ~/bin/leak-detector
Bypass через --no-verify и когда так делать нельзя
У клиентских хуков есть аварийный обход. Флаг --no-verify (короткий -n) у commit и push отключает pre-commit, commit-msg и pre-push:
Код: Выделить всё
$ git commit -m "wip" --no-verify
$ git push --no-verify
Типичные граблиПравило большого пальца: если проверку можно обойти флагом и от этого пострадает кто-то кроме тебя - она должна жить и на сервере тоже.
- Хук не запускается, потому что нет бита исполнимости. chmod +x обязателен; на проверках в CI бит часто теряется - храни хуки исполняемыми в индексе.
- Неправильный шебанг. На системах без python3 в PATH хук с #!/usr/bin/env python3 молча падает. Для максимальной переносимости - #!/bin/sh.
- pre-commit проверяет рабочее дерево, а не индекс. Всегда git diff --cached, иначе ловишь то, что не коммитишь, и пропускаешь то, что коммитишь.
- Тяжёлый pre-push с долгими тестами - люди массово переходят на --no-verify, и хук перестаёт что-либо значить. Тяжёлое - в CI.
- core.hooksPath включён, но забыли выполнить git config на новой машине - хуки тихо не работают. Прячь установку в setup-скрипт.
- Перенос rev в .pre-commit-config.yaml забыли запинить - и у всех разные версии линтера, прогоны не воспроизводятся. Фиксируй rev и обновляй через pre-commit autoupdate.
Повтори по шагам, это пять минут:
Код: Выделить всё
$ mkdir hook-lab && cd hook-lab
$ git init -b main
$ printf '#!/bin/sh\npat="^(feat|fix|docs|chore): .+"\n' > .git/hooks/commit-msg
$ printf 'grep -qE "$pat" "$1" || { echo "bad msg" >&2; exit 1; }\n' >> .git/hooks/commit-msg
$ chmod +x .git/hooks/commit-msg
$ echo hi > a.txt && git add a.txt
$ git commit -m "added file"
bad msg
$ git commit -m "feat: add a.txt"
[main (root-commit) ...] feat: add a.txt
Контрольные вопросы
- Почему хук, лежащий в .git/hooks, не попадёт к коллеге через git push, и какие три способа это обойти ты знаешь?
- В чём разница между серверными хуками pre-receive и update при пуше сразу нескольких веток?
- Какой хук и по какому аргументу валидирует сообщение коммита, и при чём тут Conventional Commits и semantic-release?
- Что делает --no-verify, какие хуки он НЕ может отключить и почему обязательные проверки дублируют на сервере?
Хук - это исполняемый файл, который Git дёргает на событие, а код выхода решает, продолжать или нет. Клиентские (pre-commit, commit-msg, pre-push, prepare-commit-msg) дают быструю обратную связь, но обходятся через --no-verify. Серверные (pre-receive, update, post-receive) - настоящий enforcement и точка для CI/деплоя. Каталог .git/hooks не версионируется, поэтому в 2026 хуки распространяют через core.hooksPath, фреймворк pre-commit с .pre-commit-config.yaml (стандарт де-факто), husky/lefthook для JS, а с Git 2.54 - ещё и через конфиг-секции hook. Главный принцип: клиент для удобства, сервер для гарантий.