Хук pre-commit: что он проверяет, что видит и как его пропустить
Когда он запускается
Хук pre-commit — последнее, что происходит до того, как git запишет объект коммита. Он запускается при git commit, когда индекс уже окончательный, а сообщение коммита ещё не составлено, поэтому это естественное место для проверок, которым должен пройти именно тот контент, который сейчас будет записан. Аргументов хук не получает.
- Ненулевой код возврата останавливает коммит. Git печатает то, что хук написал в стандартный поток ошибок, и не создаёт ничего: ни объекта, ни обновления ссылки.
- Он запускается и при git commit --amend — для того содержимого, которое будет у изменённого коммита.
- Сообщения ещё нет. Проверка текста сообщения — дело commit-msg: он запускается позже и получает файл сообщения аргументом.
- Git выставляет GIT_EDITOR=: в окружении, когда редактор открываться не будет, чтобы хук, который сам вызывает git commit, не завис в ожидании ввода.
- Его можно пропустить. git commit --no-verify его не запускает. Это локальная лазейка, а не правило: проверка, которая должна выполняться для каждого коммита, должна работать и на сервере.
Первый хук
Самый маленький полезный хук не пропускает коммит, который добавляет пробелы в конце строк. Это bash-скрипт в .git/hooks/pre-commit:
#!/usr/bin/env bash if git diff --cached --check; then echo "pre-commit: no whitespace errors" else echo "pre-commit: whitespace errors in the staged changes" >&2 exit 1 fi
Файл должен быть исполняемым. Без этого бита git молча его игнорирует, и это самая частая причина того, что хук «не работает»:
chmod +x .git/hooks/pre-commit
Теперь файл с пробелами в конце строк не проходит, и коммита не появляется:
printf "line one\nline two \n" > app.txt
git add app.txt
git commit -m "Adds a line with trailing spaces"
app.txt:2: trailing whitespace. +line two pre-commit: whitespace errors in the staged changes
git diff --cached --check — та же проверка, которую git делает для git diff --check, но только по индексу. Когда она что-то находит, код возврата ненулевой, и хук превращает это в отказ.
Индекс, а не рабочее дерево
Вот часть, которая удивляет чаще всего. Хуку дают индекс, а не рабочее дерево. git diff --cached показывает то, что видит хук, git diff — то, чего он не видит. Файл может быть добавлен в индекс в одном состоянии и отредактирован в другое, и хук будет судить версию из индекса, потому что именно она попадёт в коммит.
Добавьте в индекс чистую строку, а потом допишите пробелы в файл, не добавляя их в индекс:
printf "line one\nline two\n" > app.txt
git add app.txt
printf "line one\nline two \n" > app.txt
Теперь два диффа расходятся: в индексе одна добавленная строка, в рабочем дереве та же строка изменена ещё раз. Коммит проходит хук:
pre-commit: no whitespace errors [main 2ada783] Adds a clean line 1 file changed, 1 insertion(+)
И в коммит попадает чистая строка, а не та, что на диске. Прочитаем её из HEAD через cat -A: он помечает конец каждой строки знаком $.
git show HEAD:app.txt | cat -A
line one$ line two$
В рабочем дереве пробелы остались, и git status по-прежнему показывает файл как изменённый. Ничего не потеряно и ничего не закоммичено: хук одобрил содержимое индекса, и именно его записал коммит.
У этого есть две стороны. Проверка, которая читает файл с диска, может пройти, когда в индексе лежит сломанный блоб, и упасть, когда в индексе всё в порядке. Если проверка должна описывать коммит, читать нужно блоб из индекса, а не файл из рабочего дерева.
Проверка того, что в индексе
Хук, который запускает php -l для каждого добавленного PHP-файла, решает две задачи сразу: находит только новые и изменённые файлы и читает каждый из индекса. Цикл ниже делает и то и другое. Он просит у git список, разделённый NUL, чтобы путь с пробелом не распался на два файла, и записывает каждый блоб из индекса во временный файл для линтера:
#!/usr/bin/env bash set -euo pipefail status=0 while IFS= read -r -d "" file; do case "$file" in *.php) ;; *) continue ;; esac tmp=$(mktemp) git show ":$file" > "$tmp" if ! php -l "$tmp" > /dev/null; then echo "pre-commit: $file does not parse as staged" >&2 status=1 fi rm -f "$tmp" done < <(git diff --cached --name-only --diff-filter=ACMR -z) exit "$status"
--diff-filter=ACMR оставляет добавленные, скопированные, изменённые и переименованные файлы и отбрасывает удалённые, которые нечего проверять. git show ":$file" — версия из индекса: двоеточие в начале означает индекс, а не HEAD.
Разница важна. Если в индексе лежит сломанный блоб, а на диске файл уже исправлен, версия с диска проходит, и коммит унёс бы поломку:
printf '<?php\necho "broken"\n' > page.php
git add page.php
printf '<?php\necho "fixed";\n' > page.php
git commit -m "Adds a broken page"
PHP Parse error: syntax error, unexpected end of file, expecting "," or ";" in /tmp/tmp.kTRmjM6Nxu.php on line 3 pre-commit: page.php does not parse as staged
Хук остановил коммит, который php -l page.php пропустил бы. После git add page.php тот же хук впускает исправленную версию.
Хук, который добавляет файлы в индекс
Коммит собирается из индекса после того, как хук вернул управление, поэтому хук с git add меняет создаваемый коммит. Так форматтер коммитит собственный результат: хук переписывает файлы, добавляет их в индекс, и коммит содержит отформатированную версию.
Так же в коммит попадает файл, который никто не собирался коммитить. Этот хук пишет два файла, один добавляет в индекс, второй не трогает:
#!/usr/bin/env bash printf "added by the hook\n" > generated.txt git add generated.txt printf "written by the hook, never staged\n" > loose.txt exit 0
Следующий коммит содержит generated.txt, хотя вручную его никто не добавлял, а loose.txt остаётся неотслеживаемым:
[main efdf575] Adds a 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 generated.txt ?? loose.txt
Хук, который добавляет файлы в индекс, решает часть коммита. Пусть это будут только файлы, которые хук сам и создал, и пусть он печатает, что добавил, чтобы человек, делающий коммит, не удивлялся файлу в git show --stat.
Как пропустить хук
git commit --no-verify, или короткая форма -n, пропускает pre-commit, pre-merge-commit и commit-msg. Он не пропускает prepare-commit-msg и не пропускает хуки post-*, которые в любом случае работают после коммита.
Флаг существует для случая, когда неправ хук, а не для случая, когда неправ коммит. Две привычки держат его в узде: хук, который отказал, печатает причину отказа, и та же проверка работает в CI на каждом push. Локальный хук — удобство для автора, серверная проверка — правило. Фреймворк pre-commit добавляет собственный пропуск для отдельного хука: переменную окружения SKIP со списком идентификаторов через запятую.
SKIP=php-lint-src git commit -m "Skips one check"
Как поделиться хуком
.git/hooks лежит внутри .git, а он не часть репозитория. Клон приносит примеры и ничего больше, а хук, который есть только на одной машине, — привычка, а не правило.
Проще всего поделиться отслеживаемой папкой и core.hooksPath. Положите скрипт в .githooks/pre-commit в репозитории, сделайте его исполняемым и укажите git на эту папку:
git config core.hooksPath .githooks
Настройка локальная, поэтому в каждом клоне всё равно нужно выполнить эту одну команду; строки в README или в скрипте настройки достаточно. Другой вариант — фреймворк, который сам ставит файлы хуков.
Фреймворк pre-commit
Фреймворк pre-commit держит проверки в .pre-commit-config.yaml в корне репозитория, ставит файлы хуков и запускает каждый инструмент в своём окружении. Одна команда записывает .git/hooks/pre-commit:
pre-commit install
Установленный файл — небольшой загрузчик, а не сами проверки. Он вызывает pre-commit с типом хука, а фреймворк решает, какие записи YAML-файла относятся к файлам в индексе. pre-commit run --all-files запускает те же проверки без коммита — так делает CI.
Вокруг каждой стадии фреймворк делает то, чего обычный хук не делает: он сохраняет все незакоммиченные изменения в ~/.cache/pre-commit/patch<timestamp>-<pid>, выполняет git checkout -- ., чтобы проверки видели чистое рабочее дерево, и после них накладывает патч обратно. Для хука это значит, что во время работы на диске лежат версии из индекса, а не ваши правки. Для человека, делающего коммит, — что незакоммиченная работа на время исчезает из рабочего дерева и возвращается, только если патч наложится.
Именно это наложение и стало предметом разбора в статье про рабочий процесс: один файл, который переписал хук post-commit, заставил git apply отклонить весь патч, и незакоммиченная работа осталась вне рабочего дерева, пока её не восстановили вручную из файла патча.
Чек-лист
- У файла хука точное имя и исполняемый бит.
- Хук читает содержимое индекса: git diff --cached и git show :path, а не git diff и файл на диске.
- Причину отказа хук печатает в стандартный поток ошибок.
- Та же проверка работает в CI, потому что --no-verify существует.
- Скрипт лежит в репозитории и core.hooksPath указывает на него, либо его ставит фреймворк.
- Свой скрипт хука никогда не выполняет git checkout -- . или git stash в рабочем дереве, где есть незавершённая работа: фреймворк делает это вокруг каждой стадии, и статья про рабочий процесс показывает, чем это кончается.
Автор статьи: Arthur Isaev