Хук pre-commit: что он проверяет, что видит и как его пропустить

Когда он запускается

Хук pre-commit — последнее, что происходит до того, как git запишет объект коммита. Он запускается при git commit, когда индекс уже окончательный, а сообщение коммита ещё не составлено, поэтому это естественное место для проверок, которым должен пройти именно тот контент, который сейчас будет записан. Аргументов хук не получает.

Первый хук

Самый маленький полезный хук не пропускает коммит, который добавляет пробелы в конце строк. Это 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 отклонить весь патч, и незакоммиченная работа осталась вне рабочего дерева, пока её не восстановили вручную из файла патча.

Чек-лист

Автор статьи: Arthur Isaev