Поиск проблемного коммита с помощью git bisect
Используйте git bisect, чтобы найти коммит, вызвавший регрессию. Выберите надежные контрольные версии, автоматизируйте тесты, учтите нестабильные результаты и проверьте виновный коммит.
git bisect находит коммит, вызвавший регрессию, с помощью бинарного поиска. Вы отмечаете один заведомо рабочий (good) и один заведомо сломанный (bad) коммит. Каждая проверка сокращает оставшийся диапазон вдвое, поэтому для 300 коммитов требуется около восьми-девяти проверок.
В прошлом месяце функциональность работала. С тех пор в main попало 300 коммитов, никто не помнит, чтобы трогал код корзины, а просмотр каждого диффа отнимет полдня.
В статье 5 команд Git помимо commit и push описаны start, good, bad, run, skip и reset. Здесь мы пойдём дальше и разберём:
- как выбрать good-коммит, которому можно доверять;
- как написать скрипт для
git bisect run, коды возврата которого не введут Git в заблуждение; - что делать, если нестабильный (flaky) тест или ошибочная отметка уводят поиск в неверном направлении.
Ключевые выводы
- Каждое удвоение диапазона bisect добавляет лишь одну проверку. Поэтому реальная цена слишком давнего good-коммита — старые коммиты, которые уже не собираются и которые приходится пропускать.
- В git bisect run код возврата 0 помечает коммит как good, 125 пропускает его, любой другой код от 1 до 127 помечает его как bad, а 128 и выше прерывает bisect.
- При плавающей ошибке помечайте коммит как bad, если упал хотя бы один из нескольких запусков, и как good — только если все запуски прошли успешно.
- Ошибочная отметка не означает, что нужно начинать заново. Сохраните git bisect log, удалите неверную строку и всё, что идёт после неё, затем выполните git bisect reset и git bisect replay.
- Подтверждайте результат, проверяя найденный коммит и его родителя. На родителе тест должен проходить, а на найденном коммите — падать.
Ручной цикл git bisect
Для запуска ручного цикла нужны три команды: git bisect start, затем git bisect bad на сломанном коммите и git bisect good <ref> на рабочем. После этого вы проверяете каждый коммит, на который переключается Git, и помечаете его, пока Git не назовёт виновника. Тот же процесс описан в главе Pro Git об отладке с помощью Git. Если вы только начинаете работать с Git, основы повседневной работы описаны в статье 10 команд Git, которые должен знать каждый разработчик.
git bisect start
git bisect bad # HEAD has the bug
git bisect good v4.12.0 # last release known to work
Bisecting: 149 revisions left to test after this (roughly 7 steps)
[9e41c07b2d85a3f16c0e7b94d2a58f3e1c6b0d27] Refactor cart line-item formatter
Git переключился на коммит в середине диапазона. Запустите тест и сообщите результат:
git bisect bad
Bisecting: 74 revisions left to test after this (roughly 6 steps)
[2b7f0d9a4c16e83b5f2a07d9c4e1b68a3f05c9d1] Update currency util types
Продолжайте проверять и помечать коммиты. Когда останется один кандидат, Git выведет результат (SHA и имена ниже условные):
3c9d2e7a51f04b8e96d1a2c7f05e4b3d8a6c1f92 is the first bad commit
commit 3c9d2e7a51f04b8e96d1a2c7f05e4b3d8a6c1f92
Author: Example Dev <dev@example.com>
Date: <date>
Round line totals before applying discount
src/cart/total.ts | 4 ++--
Как выбрать good-коммит?
Лучший good-коммит для git bisect — самый свежий тег релиза, который точно вышел без этой ошибки. Перед тем как пометить его, обязательно проверьте его. Если good-ссылка на самом деле сломана, bisect всё равно уверенно выдаст ответ. Обычно это коммит сразу после вашей good-ссылки, и этот ответ будет неверным.
Проверьте тег той же проверкой, которую будет использовать bisect (Vitest здесь лишь пример тест-раннера):
git switch --detach v4.12.0
npm ci && npx vitest run src/cart/total.test.ts # must pass
git switch -
Не сужайте диапазон в ущерб надёжности. Каждое удвоение добавляет лишь одну проверку: около 8–9 проверок для 300 коммитов, 9–10 для 600 и 11–12 для 2400. У ухода далеко в прошлое другая цена. Старым коммитам может потребоваться более старая версия Node, уже снятая с публикации зависимость или другой формат lock-файла. Каждый коммит, который не собирается, приходится пропускать, а длинная полоса пропущенных коммитов может скрыть виновника.
Записи пользовательских сессий (session replay) показывают, когда регрессия впервые затронула реальных пользователей. Это позволяет взять в качестве good-коммита релиз, развёрнутый непосредственно перед этим моментом.
Как git bisect run автоматизирует поиск?
git bisect run <script> запускает ваш скрипт на каждом коммите-кандидате и считает его код возврата вердиктом. Соответствие кодов определено в документации git-bisect:
| Код возврата | Значение |
|---|---|
| 0 | Good |
| 1–127, кроме 125 | Bad |
| 125 | Пропуск (невозможно протестировать) |
| 128 и выше | Прерывание bisect |
Храните скрипт вне репозитория. Тогда переключение на старые коммиты не изменит его, а команды очистки вроде git clean -fdx его не удалят:
cat > ../bisect-test.sh <<'EOF'
#!/usr/bin/env bash
npm ci --silent || exit 125 # can't install: skip
npm run build --silent || exit 125 # can't build: skip
npx vitest run src/cart/total.test.ts || exit 1
EOF
chmod +x ../bisect-test.sh
git bisect run ../bisect-test.sh
При неудачной установке или сборке скрипт завершается с кодом 125. Поэтому посторонняя поломка приводит к пропуску коммита, а не засчитывается как bad. Завершающий || exit 1 превращает любое падение тестов в код 1. Так тест-раннер, который случайно вернёт 125 или 128+, не сможет по ошибке пропустить коммит или прервать запуск.
Коды 126 (файл не является исполняемым) и 127 (команда не найдена) обычно засчитываются как bad. Значит, из-за неверного пути к скрипту или отсутствующего бита исполнения все коммиты могут оказаться помеченными как bad. В примечаниях к релизу Git 2.36 описана защита от этого: Git пытается распознать скрипт, который не запускается, и останавливается заранее, не называя виновника. Если git bisect run остановился раньше времени, сначала проверьте путь к скрипту и права доступа, а уже потом разбирайтесь в коде.
Типичные затруднения: skip, reset и грязное рабочее дерево
Чаще всего работу прерывают три вещи: коммит, который невозможно протестировать, сессия, которую нужно завершить, и мешающие локальные изменения.
git bisect skip
git bisect skip откладывает текущий коммит, и Git переходит к соседнему. Если виновник окажется среди пропущенных коммитов, Git выведет список кандидатов, а не один коммит.
git bisect reset # back to the branch you started on
git bisect reset 3c9d2e7 # end the session on a specific commit
Перед git bisect start закоммитьте локальные изменения или уберите их в stash. Поиск можно ограничить определёнными путями: git bisect start -- src/cart/. git bisect visualize открывает gitk с оставшимися подозрительными коммитами. Если Git не находит графической сессии, вместо gitk используется git log.
Как быть с нестабильными тестами и ошибочными отметками?
Чтобы найти плавающую ошибку с помощью bisect, запускайте тест на каждом коммите несколько раз. Помечайте коммит как bad, если упал хотя бы один запуск, и как good — только если прошли все. Одна неверная отметка отправляет поиск не в ту половину диапазона, а Git всё равно сообщит о «первом плохом коммите». Действительно сломанный коммит может пройти проверку случайно, но рабочий коммит не должен проваливать её никогда.
#!/usr/bin/env bash
npm ci --silent || exit 125
npm run build --silent || exit 125
for i in $(seq 1 10); do
npx vitest run src/cart/total.test.ts || exit 1 # any failure = bad
done
exit 0 # all passed = good
Рассмотрим гипотетический пример. Если сломанный коммит падает в 20% случаев, то вероятность десяти успешных запусков подряд составляет 0,8¹⁰ ≈ 0,11. Чем больше запусков, тем ниже риск. Пропуск неоднозначных коммитов проблему не решает: ненадёжный результат никуда не девается, а итоговый ответ просто охватывает больше коммитов. Это правило предполагает, что нестабильность появилась вместе с регрессией. Если тест был нестабильным и раньше, сначала стабилизируйте его или напишите более узкую проверку. Иначе он будет помечать рабочие коммиты как bad.
Если вы уже ошиблись с отметкой, это можно исправить, не начиная заново:
git bisect log > ../bisect.log
# edit ../bisect.log
git bisect reset
git bisect replay ../bisect.log
Сохранённый лог содержит также строки комментариев. Здесь показаны только строки с командами. До редактирования:
git bisect start
git bisect bad 8d1f...
git bisect good 5a0c...
git bisect good 9e41... <- wrong: this commit was flaky
git bisect bad 2b7f...
Удалите неверную строку и всё, что идёт после неё. После редактирования:
git bisect start
git bisect bad 8d1f...
git bisect good 5a0c...
git bisect replay восстанавливает сессию до последней корректной отметки, и вы продолжаете поиск с этого места.
Как подтвердить результат?
Считайте коммит, найденный git bisect, лишь подозреваемым, пока не проверите его. Изучите дифф с помощью git show 3c9d2e7, затем протестируйте родителя коммита и сам коммит:
git switch --detach 3c9d2e7^ # parent: test should pass
git switch --detach 3c9d2e7 # culprit: test should fail
Если на родителе тест проходит, а на самом коммите падает, регрессия найдена. Дальше с помощью git blame можно выяснить, почему менялись соседние строки. Ещё не отправленные коммиты можно исправить, отредактировав историю Git. При следующей регрессии начинайте с проверенного тега релиза и скрипта, который запускает тест на каждом коммите несколько раз.
Часто задаваемые вопросы
Может ли git bisect найти коммит, который исправил ошибку, а не тот, который её внёс?
Да. Используйте термины old и new вместо good и bad. Выполните git bisect start, пометьте исправленный коммит командой git bisect new, а более старый сломанный — командой git bisect old. Git сообщит о первом new-коммите, то есть об исправлении. Для более понятных меток выполните git bisect start --term-old broken --term-new fixed, а затем помечайте коммиты командами git bisect fixed и git bisect broken.
Как git bisect обрабатывает merge-коммиты из feature-веток?
По умолчанию git bisect проверяет и коммиты внутри влитых веток, включая промежуточные (work-in-progress) коммиты, которые могут не собираться. Команда git bisect start --first-parent, появившаяся в Git 2.29, удерживает поиск на основной линии истории. В main поиск остановится на merge-коммите, который принёс ошибку, и не будет проверять коммиты самой ветки. Повторный bisect по этой ветке найдёт конкретный коммит.
Что произойдёт, если good-коммит не является предком bad-коммита?
Git переключится на merge base (общего предка) этих двух коммитов и попросит сначала протестировать его. Если merge base рабочий, bisect продолжится как обычно по коммитам между ним и bad-коммитом. Если merge base уже сломан, Git остановится, не начиная поиск. Это значит, что good-коммит находится на отдельной линии истории, где ошибки нет или где она была исправлена.
Чем git bisect отличается от git blame?
git blame построчно показывает, какой коммит последним изменил файл, то есть отвечает лишь на вопрос, кто последним редактировал конкретную строку. git bisect проверяет поведение программы на разных коммитах. Поэтому он находит регрессию, даже если её причина — обновление зависимости, изменение конфигурации или правка в другом файле, а не в том, где проявляется симптом. Найдите коммит с помощью bisect, а затем отследите изменённые им строки с помощью blame.