Хук 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 describe, перечень последнего релиза: всем им нужен коммит, который уже существует, а не тот, который готовится.
- Уведомление. Сообщение в чат, строка в локальном журнале, запрос на сервер сборки. Хук работает после факта, поэтому его падение — потерянное уведомление, а не потерянный коммит.
- Кеш. Пересборка индекса, очистка собранного артефакта, копирование нового дерева в папку предпросмотра.
- Не проверка. Всё, что должно остановить плохой коммит, живёт в pre-commit или commit-msg. Код возврата этого хука игнорируется, поэтому проверка здесь — совет.
- Не правило. Правило, которое обязан соблюдать каждый автор, живёт на сервере, в pre-receive или в CI, где --no-verify не существует.
- Не тот же коммит. Хук post-commit не может добавить свой результат в коммит, на который реагирует, только в следующий, и файл всегда отстаёт на один коммит.
Хук лежит в .git/hooks, а эта папка не часть репозитория, поэтому клон начинается без него. Отслеживаемая папка плюс core.hooksPath делают хук общим для команды, как описано в обзоре хуков, а фреймворк ставит те же два файла для своих стадий.
Автор статьи: Arthur Isaev