pre-commit и post-commit вместе: проверка и реакция
Обычно репозиторию нужны от хуков две разные вещи. До того как коммит записан, нужна проверка: она не пропускает плохое содержимое. После того как коммит появился, нужна реакция: журнал изменений, отметка сборки, уведомление. И то и другое помещается в одну конфигурацию pre-commit, и вот здесь начинаются проблемы. Фреймворк сохраняет незакоммиченные изменения в файл патча перед каждой стадией, включая стадию post-commit, и после неё возвращает их на место. Реакция, которая переписывает файл или добавляет его в индекс, сталкивается с этим патчем.
Случай ниже — не теория: незакоммиченная работа исчезла из рабочего дерева, осталась только в файле под ~/.cache/pre-commit, а git commit всё равно завершился с кодом 0. Впервые это увидели 2026-11-03 на pre-commit 4.6.2 и воспроизвели снова 2026-11-13 на git 2.54.1 и pre-commit 4.6.2.
Проверка и реакция
Оба хука видят один и тот же репозиторий в два разных момента, и эта разница решает всё, что они могут делать.
| pre-commit | post-commit | |
|---|---|---|
| Когда | когда индекс уже окончательный, до объекта коммита | после объекта коммита и обновления ветки |
| Может остановить коммит | да, ненулевой код возврата его отклоняет | нет, код возврата игнорируется |
| Пропускается флагом --no-verify | да | нет |
| Хорош для | линтеров, тестов, правил сообщения | пересборки файлов, уведомлений |
Проверку можно пропустить, а реакцию нельзя, и это кажется странным, пока не вспомнишь, чем может обернуться каждая. Пропущенная проверка стоит плохого коммита, который ещё может отклонить CI или серверный хук; пропущенная реакция стоит файла, который так и не был записан. Хук pre-commit и хук post-commit разобраны каждый на своей странице.
Одна конфигурация, две стадии
Оба хука — записи в .pre-commit-config.yaml с разными stages. Проверка здесь — та же проверка пробелов, что в статье про pre-commit; реакция пересобирает CHANGELOG.md. Локальные хуки держат пример офлайн; настоящий проект обычно указывает для каждой записи репозиторий с закреплённым rev.
vi .pre-commit-config.yaml
repos: - repo: local hooks: - id: whitespace name: check trailing whitespace entry: bash -c 'git diff --cached --check' language: system pass_filenames: false stages: [pre-commit] - id: changelog name: regenerate CHANGELOG.md entry: bash -c 'git log --pretty=format:"- %h %s" > CHANGELOG.md' language: system pass_filenames: false stages: [post-commit] always_run: true
always_run: true и pass_filenames: false относятся к реакции: на стадии post-commit нет изменённых файлов, которые можно передать, а хук должен работать на каждом коммите. Две команды установки записывают два файла хуков:
pre-commit install
pre-commit installed at .git/hooks/pre-commit
pre-commit install --hook-type post-commit
pre-commit installed at .git/hooks/post-commit
Реакция работает и тогда, когда незакоммиченных изменений нет, и просто сообщает, что изменила файл:
git commit -m "Add app.txt and a changelog"
check trailing whitespace................................................Passed regenerate CHANGELOG.md..................................................Failed - hook id: changelog - files were modified by this hook [main (root-commit) 2c58e88] Add app.txt and a changelog 3 files changed, 18 insertions(+)
Это здоровый случай: хук переписал файл, фреймворк об этом сообщил, а коммит был сделан. Ничего не потерялось, потому что терять было нечего. Следующий раздел добавляет работу, которую можно потерять.
Случай: работа осталась в файле патча
Фреймворк хочет, чтобы хуки видели чистое рабочее дерево: линтер, который читает файлы с диска, должен видеть содержимое индекса. Поэтому перед каждой стадией он делает одно и то же: сохраняет все незакоммиченные изменения в ~/.cache/pre-commit/patch<timestamp>-<pid>, выполняет git checkout -- ., чтобы вернуть рабочее дерево к индексу, запускает хуки и накладывает патч обратно через git apply. Если наложить не удалось, он снова выполняет git checkout -- . и накладывает патч ещё раз.
Он делает это и на стадии post-commit, хотя коммит уже закончен и измениться может только рабочее дерево. Состояние, которое запускает случай, самое обычное: отслеживаемый файл с правкой, которой нет в индексе, и другой файл, добавленный в индекс для коммита.
git status --short
M CHANGELOG.md M app.txt
В CHANGELOG.md есть написанная руками строка черновика, которой нет в индексе; app.txt в индексе. Реакция затем переписывает CHANGELOG.md из git log — ровно тот файл, который держит патч. Уцелеет работа или нет, зависит от одной строки в хуке: добавляет ли он свою переписку в индекс через git add?
Случай A: хук только пишет
Реакция из конфигурации выше заканчивается на > CHANGELOG.md и больше ничего не делает. Коммит при незакоммиченном черновике:
git commit -m "Extend app.txt"
[WARNING] Unstaged files detected. [INFO] Stashing unstaged files to /home/user/.cache/pre-commit/patch1794601549-1012. check trailing whitespace................................................Passed [INFO] Restored changes from /home/user/.cache/pre-commit/patch1794601549-1012. [WARNING] Unstaged files detected. [INFO] Stashing unstaged files to /home/user/.cache/pre-commit/patch1794601550-1031. regenerate CHANGELOG.md..................................................Failed - hook id: changelog - files were modified by this hook [WARNING] Stashed changes conflicted with hook auto-fixes... Rolling back fixes... [INFO] Restored changes from /home/user/.cache/pre-commit/patch1794601550-1031. [main 9022387] Extend app.txt 1 file changed, 1 insertion(+)
Два патча, по одному на стадию. Реакция упала с сообщением «files were modified by this hook» — так фреймворк говорит, что хук оставил файлы не такими, какими они были в начале. Строка «Rolling back fixes» — это повтор: наложение не удалось из-за переписки хука, поэтому фреймворк вернул рабочее дерево к индексу и наложил патч снова. Второе наложение прошло.
Итог: незакоммиченная работа на месте, переписка хука пропала.
git status --short
M CHANGELOG.md
cat CHANGELOG.md
- (old changelog) - draft: release notes for 1.1
git show --stat --oneline HEAD
9022387 Extend app.txt app.txt | 1 + 1 file changed, 1 insertion(+)
Строка черновика в рабочем дереве, сгенерированного журнала нет, а коммит содержит только app.txt. Команда завершилась с кодом 0. Цена случая A — собственный результат реакции: он выброшен, и следующий коммит пересоберёт его заново — или не пересоберёт, если хук больше не запускается.
Случай B: хук пишет и добавляет в индекс
Теперь та же реакция заканчивается на git add CHANGELOG.md:
- id: changelog name: regenerate CHANGELOG.md entry: bash -c 'git log --pretty=format:"- %h %s" > CHANGELOG.md && git add CHANGELOG.md' language: system pass_filenames: false stages: [post-commit] always_run: true
То же состояние, тот же коммит. Проверка проходит, реакция проходит, а патч наложить не удаётся:
git commit -m "Extend app.txt"
[WARNING] Unstaged files detected. [INFO] Stashing unstaged files to /home/user/.cache/pre-commit/patch1794601552-1108. check trailing whitespace................................................Passed [INFO] Restored changes from /home/user/.cache/pre-commit/patch1794601552-1108. [WARNING] Unstaged files detected. [INFO] Stashing unstaged files to /home/user/.cache/pre-commit/patch1794601552-1127. regenerate CHANGELOG.md..................................................Passed [WARNING] Stashed changes conflicted with hook auto-fixes... Rolling back fixes... An unexpected error has occurred: CalledProcessError: command: ('/home/user/.local/bin/git', '-c', 'core.autocrlf=false', 'apply', '--whitespace=nowarn', '/home/user/.cache/pre-commit/patch1794601552-1127') return code: 1 stdout: (none) stderr: error: patch failed: CHANGELOG.md:1 error: CHANGELOG.md: patch does not apply Check the log at /home/user/.cache/pre-commit/pre-commit.log [main 4f78d1d] Extend app.txt 1 file changed, 1 insertion(+)
Повтор выполняет git checkout -- ., и именно это отличает случай B. Хук добавил свою переписку в индекс, поэтому в индексе теперь сгенерированный журнал, и git checkout -- . возвращает рабочее дерево из этого индекса. Второе наложение встречает версию файла от хука вместо старой и падает снова. Фреймворк сдаётся с CalledProcessError, а git commit всё равно завершается с кодом 0. В командной строке внутри ошибки стоит абсолютный путь к git, которым пользовался запуск.
git status --short
M CHANGELOG.md
cat CHANGELOG.md
- 4f78d1d Extend app.txt - db9310b Add app.txt and a changelog
git show HEAD:CHANGELOG.md
- (old changelog)
В рабочем дереве переписка хука, добавленная в индекс для следующего коммита. Строки черновика там нет. Она уцелела только внутри файла патча, который назван в ошибке:
cat /home/user/.cache/pre-commit/patch1794601552-1127
diff --git a/CHANGELOG.md b/CHANGELOG.md index 7693d37..e2338c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ - (old changelog) +- draft: release notes for 1.1
Одна написанная руками строка — в файле, имя которого никто не запомнит. Вот и весь ущерб: работа есть, но не в репозитории, а git говорит, что команда прошла успешно. После ошибки файл патча не удаляется; он лежит, пока его кто-нибудь не уберёт.
Как вернуть работу
Патч построен относительно закоммиченной версии CHANGELOG.md, поэтому поверх переписки хука он не ложится. Сначала верните индекс и рабочее дерево к HEAD, затем наложите патч:
git apply --check /home/user/.cache/pre-commit/patch1794601552-1127
error: patch failed: CHANGELOG.md:1 error: CHANGELOG.md: patch does not apply
git reset -q
git checkout -- .
git apply /home/user/.cache/pre-commit/patch1794601552-1127
При успехе git apply ничего не печатает, и теперь черновик снова там, где был:
git status --short
M CHANGELOG.md
cat CHANGELOG.md
- (old changelog) - draft: release notes for 1.1
Переписка хука пропала вместе со сбросом. Это приемлемо: она сгенерирована, поэтому её создаст заново запуск реакции вручную или следующий коммит.
pre-commit run --hook-stage post-commit --all-files
regenerate CHANGELOG.md..................................................Passed
Написанная руками строка — единственное, что нельзя восстановить из истории, и именно её возвращает патч.
Две привычки делают восстановление безопасным. Посмотрите в патч раньше, чем что-то трогать: git apply --check скажет, ложится ли он, а cat — что в нём. И не выполняйте git checkout -- . или git reset --hard, пока не прочитали патч: файл под ~/.cache/pre-commit в этот момент — единственная копия работы. Если файлов патча несколько, самый новый относится к последнему неудачному запуску, и сообщение об ошибке называет его точно.
Как настроить, чтобы этого не случилось
Для случая нужны три вещи сразу: фреймворк, который складывает изменения в патч на стадии post-commit, хук, который переписывает отслеживаемый файл, и незакоммиченная работа в этом же файле. Уберите любую из них, и работа останется на месте.
- Никогда не выполняйте git add в хуке post-commit. Результат — случай B: именно версию из индекса возвращает git checkout -- ., и второму наложению нечего накладывать. Реакции не нужно ничего добавлять в индекс; если сгенерированный файл должен попасть в коммит, добавьте его вручную или дождитесь следующего коммита.
- Пишите сгенерированный файл вне отслеживаемого дерева. Путь в build/ с build/ в .gitignore никогда не попадает в патч, поэтому никакая переписка с ним не столкнётся.
- Держите реакцию вне фреймворка, если она должна менять отслеживаемый файл. Обычный хук в .githooks с core.hooksPath ничего не складывает в stash.
- Коммитьте с чистым рабочим деревом. Если реакцией владеет фреймворк, а файл отслеживается, git commit -a или ручной git stash перед коммитом не оставляют патча, который можно потерять.
Первые два пункта — изменения в хуке. Эта конфигурация оставляет ту же реакцию, но пишет её в игнорируемый build/changelog.txt:
- id: changelog name: regenerate build/changelog.txt entry: bash -c 'mkdir -p build && git log --pretty=format:"- %h %s" > build/changelog.txt' language: system pass_filenames: false stages: [post-commit] always_run: true
git commit -m "Extend app.txt"
[WARNING] Unstaged files detected. [INFO] Stashing unstaged files to /home/user/.cache/pre-commit/patch1794601555-1218. check trailing whitespace................................................Passed [INFO] Restored changes from /home/user/.cache/pre-commit/patch1794601555-1218. [WARNING] Unstaged files detected. [INFO] Stashing unstaged files to /home/user/.cache/pre-commit/patch1794601556-1237. regenerate build/changelog.txt...........................................Passed [INFO] Restored changes from /home/user/.cache/pre-commit/patch1794601556-1237. [main 95ba604] Extend app.txt 1 file changed, 1 insertion(+)
Патч всё ещё создаётся, но в нём больше нет файла, который пишет хук, поэтому возврат проходит, а черновик возвращается целым и незакоммиченным:
git status --short
M notes.txt
git status --ignored --short
M notes.txt !! build/
Последний вариант — вообще убрать фреймворк со стадии post-commit. Обычный хук, отслеживаемый в репозитории и включённый через core.hooksPath, работает без патча: проверка в .githooks/pre-commit, реакция в .githooks/post-commit.
vi .githooks/post-commit
#!/usr/bin/env bash git log --pretty=format:'- %h %s' > CHANGELOG.md git add CHANGELOG.md
chmod +x .githooks/pre-commit .githooks/post-commit
git config core.hooksPath .githooks
Тот же коммит с незакоммиченной работой в notes.txt: ни сообщений о патче, ни патча, а черновик остаётся на месте.
git commit -m "Extend app.txt"
[main a763abe] Extend app.txt 1 file changed, 1 insertion(+)
git status --short
M CHANGELOG.md M notes.txt
core.hooksPath заменяет папку хуков, поэтому файлы, которые фреймворк положил в .git/hooks, больше не вызываются; перенести туда реакцию значит перенести и проверку, либо вызывать фреймворк из скрипта проверки в отслеживаемой папке.
git commit --no-verify от этого случая не спасает. Он пропускает проверку, а реакция всё равно запускается: тот же коммит напечатал тот же CalledProcessError и с флагом, и без него, потому что хуки post-commit не входят в то, что пропускает флаг.
Чек-лист
- Проверка в pre-commit, реакция в post-commit, и обе в одной конфигурации.
- Хук post-commit не выполняет git add.
- Файл, который пишет хук post-commit, лежит вне отслеживаемого дерева или правится только машиной.
- Если реакцией владеет фреймворк, коммит делают с чистым рабочим деревом.
- После CalledProcessError с концовкой «patch does not apply» читают самый новый файл ~/.cache/pre-commit/patch* до любого git checkout.
- Код 0 у git commit не значит, что хуки сработали; читайте их вывод.
Автор статьи: Arthur Isaev