Хук post-commit: реакции, которые не могут отменить коммит

Хук post-commit — это программа, которую git запускает после того, как объект коммита создан и HEAD сдвинулся на него. К моменту его запуска ничего нельзя ни одобрить, ни отклонить: коммит уже часть истории. Поэтому хук — реакция, а не проверка. Он пишет журнал, уведомляет сервис, пересобирает файл, выведенный из истории, или прогревает кеш. Проверки, которые могут остановить коммит, живут в хуке pre-commit, а о двух хуках вместе рассказывает статья pre-commit и post-commit вместе.

Примеры ниже выполнены на git 2.54.1. Часть, где появляется фреймворк pre-commit, выполнена ещё и на pre-commit 4.6.2.

Что получает хук

Хук не получает аргументов, и на стандартный ввод ему ничего не приходит. Всё, что ему нужно, есть в репозитории, где он работает: git запускает его с корнем репозитория в качестве рабочей папки, с GIT_INDEX_FILE, указывающим на индекс, и с HEAD, который уже стоит на новом коммите. Хук, который печатает свой контекст:

vi .git/hooks/post-commit

#!/usr/bin/env bash echo "post-commit: arguments: $#" >&2 echo "post-commit: GIT_DIR: ${GIT_DIR:-unset}" >&2 echo "post-commit: GIT_INDEX_FILE: ${GIT_INDEX_FILE:-unset}" >&2 echo "post-commit: HEAD: $(git rev-parse --short HEAD)" >&2 echo "post-commit: message: $(git log -1 --pretty=%s)" >&2 echo "post-commit: worktree: $(pwd)" >&2

Файл должен быть исполняемым, и тогда его запускает коммит:

chmod +x .git/hooks/post-commit

git commit -m "Extend app.txt"

post-commit: arguments: 0 post-commit: GIT_DIR: unset post-commit: GIT_INDEX_FILE: .git/index post-commit: HEAD: 03a3e9a post-commit: message: Extend app.txt post-commit: worktree: /home/user/git-demo/receives [main 03a3e9a] Extend app.txt 1 file changed, 1 insertion(+)

Три детали отвечают на большинство вопросов об этом хуке. Аргументов ноль, поэтому имени файла и сообщения в аргументах нет; коммит находится через HEAD, а сообщение — через git log -1. Рабочая папка — корень репозитория, поэтому относительный путь в хуке означает путь в репозитории. И HEAD — уже новый коммит, а не его родитель; именно поэтому хук, который читает историю, вообще полезен.

Тот же хук запускается при git commit --amend и на коммите, которым заканчивают слияние с конфликтами: оба случая идут через git commit. Чистый git merge, который сам записывает коммит слияния, его не запускает. Он не запускается, когда коммит не удался, а git commit --no-verify его не пропускает: флаг пропускает хуки, которые охраняют коммит, а этот хук не охрана.

Почему он не может отменить коммит

Коммит записывается в таком порядке: git собирает дерево, пишет объект коммита, переставляет ветку на него и только потом запускает post-commit. Объект и ссылка окончательны до того, как хук получает управление. Код возврата хука игнорируется, поэтому даже упавший хук оставляет после себя успешный коммит:

#!/usr/bin/env bash echo "post-commit: the commit $(git rev-parse --short HEAD) already exists" >&2 exit 1

git commit -m "Add app.txt"

post-commit: the commit f8567b0 already exists [main (root-commit) f8567b0] Add app.txt 1 file changed, 1 insertion(+) create mode 100644 app.txt

echo $?

0

Команда завершается с кодом 0, и git log показывает коммит. Хук напечатал строку и упал; git не записал ни неудачу, ни код возврата.

Хук, который правит рабочее дерево, до коммита тоже не достанет. Этот дописывает строку в отслеживаемый файл после каждого коммита:

#!/usr/bin/env bash printf "rewritten by the hook\n" >> app.txt

В коммите то, что было в индексе, а в рабочем дереве — содержимое индекса плюс строка, которую добавил хук. Две копии различаются, и git status показывает обычное незакоммиченное изменение:

git show HEAD:app.txt

first line second line

cat app.txt

first line second line rewritten by the hook

git status --short

M app.txt

git show --stat --oneline HEAD

d3294ff Extend app.txt app.txt | 1 + 1 file changed, 1 insertion(+)

Заменить коммит может только новая команда: git commit --amend, git rebase или git reset. Хук может запустить одну из них, но тогда он уже не реагирует на коммит, а переписывает историю изнутри точки, которая должна была быть концом операции. Пусть реакция занимается тем, что не меняет прошлое.

Хук, который пересобирает файл

Типичная работа для этого хука — файл, выведенный из истории: CHANGELOG.md, отметка сборки, список коммитов последнего релиза. Здесь файл собирается из git log после каждого коммита:

vi .git/hooks/post-commit

#!/usr/bin/env bash git log --pretty=format:'- %h %s' > CHANGELOG.md echo "post-commit: CHANGELOG.md now lists $(git rev-list --count HEAD) commits" >&2

chmod +x .git/hooks/post-commit

git commit -m "Extend app.txt"

post-commit: CHANGELOG.md now lists 2 commits [main 1075c48] Extend app.txt 1 file changed, 1 insertion(+)

cat CHANGELOG.md

- 1075c48 Extend app.txt - 4a0606d Start the project

git status --short

?? CHANGELOG.md

Хук заменил файл свежим списком, и в списке уже есть коммит, который запустил хук, потому что коммит существовал до запуска. В этом преимущество перед хуком pre-commit для такой задачи: хук pre-commit не может назвать коммит, который он готовит.

Файл не отслеживается. Git не добавил его в коммит, потому что коммит был закончен до того, как хук написал файл, и следующий шаг — отправить файл в один из следующих коммитов.

Как файл попадает в следующий коммит

Способа два, и различаются они тем, кто добавляет файл в индекс. Первый — добавить его вручную перед следующим коммитом:

git add CHANGELOG.md

git commit -m "Add the changelog"

post-commit: CHANGELOG.md now lists 3 commits [main 421a82f] Add the changelog 1 file changed, 3 insertions(+) create mode 100644 CHANGELOG.md

git show HEAD:CHANGELOG.md

- 1075c48 Extend app.txt - 4a0606d Start the project

cat CHANGELOG.md

- 421a82f Add the changelog - 1075c48 Extend app.txt - 4a0606d Start the project

В коммите та копия, которая была в индексе до коммита; потом хук пересобрал файл заново, и в версии на диске на одну запись больше. Это не ошибка хука, а порядок событий: коммит не может содержать строку о самом себе. Журнал изменений в коммите всегда отстаёт от истории, которую описывает, на один коммит.

Второй способ — добавить файл в индекс внутри хука. Коммит, который делается, уже закрыт, а индекс — нет: git add в хуке готовит файл не для текущего коммита, а для следующего:

#!/usr/bin/env bash git log --pretty=format:'- %h %s' > CHANGELOG.md git add CHANGELOG.md

После коммита, который изменил только app.txt, в индексе уже лежит пересобранный журнал:

git commit -m "Extend app.txt again"

[main d2dc2e1] Extend app.txt again 1 file changed, 1 insertion(+)

git status --short

M CHANGELOG.md

git diff --cached --stat

CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+)

Изменение в индексе видно по двум буквам в git status: M в первом столбце — это индекс, пустой второй столбец — рабочее дерево. Следующий коммит заберёт файл, хотя вручную его никто не добавлял:

git commit -m "Record the changelog"

[main b8f8a54] Record the changelog 1 file changed, 3 insertions(+)

git show --stat --oneline HEAD

b8f8a54 Record the changelog CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+)

Хук, который добавляет файлы в индекс, оставляет индекс грязным после каждого коммита, и следующий коммит, о чём бы он ни был, уносит сгенерированный файл. Это удобно для файла, который никто не правит руками, и неожиданно для файла, который правят. Если сгенерированного файла в репозитории быть не должно вовсе, пишите его в игнорируемый путь вроде build/ и добавьте эту папку в .gitignore: в индекс ничего не попадёт, и в коммит ничего не уйдёт случайно.

Фреймворк pre-commit запускает собственную стадию post-commit и складывает незакоммиченные изменения в патч вокруг неё, и тогда хук, который добавляет файл в индекс, превращается в потерю данных; разбор в статье про рабочий процесс проходит этот случай по шагам.

Когда это подходящий инструмент

Хук лежит в .git/hooks, а эта папка не часть репозитория, поэтому клон начинается без него. Отслеживаемая папка плюс core.hooksPath делают хук общим для команды, как описано в обзоре хуков, а фреймворк ставит те же два файла для своих стадий.

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