Ваш CI красный на main, но вчера был зелёный. Где-то между тем и сейчас прокралась бага. Вы могли бы прокрутить 200 коммитов, читая диффы и угадывая. Вы могли бы спросить в Slack и надеяться, что кто-то помнит, что трогал релевантный код. Или вы могли бы позволить Git сделать работу за вас.
git bisect — это инструмент бинарного поиска по истории коммитов. Вы помечаете один коммит как плохой и один как хороший. Git переключается на середину. Вы тестируете, помечаете хорошим или плохим, и Git повторяет. За 8 шагов он может изолировать один коммит из 256. За 12 шагов — найти иголку в 4096. Это ближайшая вещь к дебаггеру для самой истории, которую есть в Git.
Почему ручной бисекционный поиск — пустая трата времени
Разработчики уже делают бисекцию вручную, не осознавая этого. Вы переключаетесь на более старый коммит, запускаете тесты и думаете «всё ещё сломано, надо идти дальше назад». Затем переключаетесь на ещё более старый, и тесты проходят. Теперь вы знаете, что баг где-то между этими двумя точками. Так выбираете середину и продолжаете сужать.
Это бинарный поиск. git bisect автоматизирует бухгалтерию, чтобы вы не терялись в том, какие коммиты уже протестировали. Что важнее, он предотвращает ленивую эвристику «наверное, это был тот большой refactor со вторника», которая отправляет вас по ложному следу на два часа.
Реальная ценность не в скорости, хотя он и быстрее. Реальная ценность — в корректности. Когда вы раздражены и охотитесь за багом в шесть вечера, вы будете ошибаться. Вы забудете пересобрать после checkout. Вы протестируете не тот коммит дважды. Вы неправильно прочитаете результат теста и пометите плохой коммит как хороший, уничтожив пространство поиска. git bisect применяет дисциплину, которой у вас нет, когда вы раздражены.
Как на самом деле работает бинарный поиск
Git ищет не хронологически. Он ищет топологически, обходя граф коммитов, чтобы найти середину между известно-хорошим и известно-плохим коммитами. Это важно, когда ваша история имеет мёрджи, потому что хронологическая середина может быть вообще недостижима из обеих конечных точек.
Вот что происходит под капотом. Вы запускаете бисекцию и задаёте границы:
git bisect start
git bisect bad HEAD # current commit is broken
git bisect good v2.1.0 # this release was fine
Git вычисляет число коммитов между этими двумя точками. Он переключается на тот, что точно посередине, и ждёт, пока вы его протестируете. Вы запускаете воспроизведение, смотрите, есть ли баг, и говорите Git:
git bisect bad # this commit has the bug
git bisect good # this commit is clean
Git отбрасывает половину пространства поиска и повторяет. Когда остаётся только один коммит, он останавливается и показывает первый плохой коммит. Вывод включает хеш коммита, автора, дату и сообщение. Никакой двусмысленности. Этот коммит внёс баг. Точка.
Конкретный разбор с реальными командами
Предположим, ваши интеграционные тесты начали падать этим утром. Вы знаете, что они проходили в последнем помеченном релизе, v1.4.0. Вот полная сессия:
# Start the session
git bisect start
# Mark the current HEAD as bad
git bisect bad HEAD
# Mark the last known good release
git bisect good v1.4.0
# Git checks out a midpoint commit automatically
# You run your reproduction script or test suite:
npm test -- --grep "checkout flow"
# Tests fail. Mark it bad.
git bisect bad
# Git checks out another midpoint. Run tests again.
npm test -- --grep "checkout flow"
# Tests pass. Mark it good.
git bisect good
# Repeat until Git tells you:
# "<commit-hash> is the first bad commit"
В конце Git оставляет вас на плохом коммите. Вы можете изучить его с помощью git show, создать фикс, а затем убраться:
git bisect reset
Это возвращает вас на ветку, где вы были до начала. Если забудете сделать reset, вы останетесь на detached HEAD и будете удивляться, почему ваш следующий коммит не на вашей ветке. Я так делал. Это унизительно.
Автоматизация всего процесса скриптом
Ручная версия всё ещё требует от вас запускать тесты и набирать good или bad повторно. Если ваше воспроизведение — это одна команда, которая выходит с 0 при успехе и ненулевым кодом при неудаче, вы можете отдать всё Git:
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# Hand over control to an automated script
git bisect run npm test -- --grep "checkout flow"
Git будет переключать коммиты, запускать вашу команду и классифицировать результаты автоматически. Когда закончит, вы получите тот же вывод «первый плохой коммит» без прикасания к клавиатуре. Вот где git bisect переходит от полезного к незаменимому.
Ваш скрипт не обязан быть набором тестов. Это может быть что угодно исполняемое, возвращающее осмысленные коды выхода. Вот минимальный shell-скрипт, который проверяет конкретное сообщение в логе:
#!/bin/bash
# reproduce-bug.sh
# Exit 0 if the bug is NOT present (good)
# Exit 1 if the bug IS present (bad)
if curl -s http://localhost:3000/api/health | grep -q "database_timeout"; then
exit 1 # bug is present
fi
exit 0 # bug is not present
Запустите:
chmod +x reproduce-bug.sh
git bisect run ./reproduce-bug.sh
Контракт кодов выхода строгий. Выход 0 означает «хорошо», выход 1–124 означает «плохо», а выход 125 означает «пропустить этот коммит, он нетестируемый». Выход 125 полезен, когда коммит не компилируется, или сервер не может стартовать из-за несвязанного изменения конфигурации. Git пропустит этот коммит и будет искать вокруг него.
Где бисекция ломается: подводные камни
git bisect предполагает, что ваш баг монотонен. Как только коммит его вносит, каждый потомок тоже плохой. Если баг мигает, появляясь и исчезая между коммитами, бинарный поиск разваливается. Вы получите бессмысленные результаты, или Git пожалуется, что не может изолировать один коммит.
Недетерминированные баги — худшие нарушители. Состояние гонки, которое падает в 10% случаев, иногда пометит плохой коммит как хороший, разрушив поиск. Если ваше воспроизведение нестабильно, сначала почините нестабильность. Или запускайте тест несколько раз в скрипте и помечайте коммит хорошим только если он проходит каждый раз. Это медленнее, но надёжнее.
Артефакты сборки — ещё одна ловушка. Если вы переключаетесь с коммита, который меняет версию инструмента сборки, на тот, который не меняет, ваши устаревшие node_modules или скомпилированные бинарники могут не соответствовать переключённому коду. Всегда чистите и пересобирайте внутри вашего скрипта воспроизведения, если в проекте есть шаг сборки.
#!/bin/bash
# safer-reproduce.sh
rm -rf node_modules dist
npm ci
npm run build
npm test -- --grep "checkout flow"
Это добавляет несколько секунд на итерацию, но устраняет целую категорию ложноположительных срабатываний, где баг на самом деле в устаревших артефактах.
Что бисекция не делает
git bisect находит коммит. Он не говорит, почему коммит плохой. Refactor на 5000 строк, который трогает сорок файлов, может быть первым плохим коммитом, и вам всё равно придётся читать дифф, чтобы понять, какая строка виновата. Бисекция сужает вашу область от «где-то за последний месяц» до «где-то в этом диффе». Остальное — всё ещё ваша работа.
Он также не помогает с багами, которые существуют в codebase, но никогда не ловились тестами. Если ваш набор тестов уже был зелёным, когда баг был внесён, бисекция вас не спасёт. Вам нужно сначала воспроизведение. Bisect — это инструмент поиска, а не обнаружения.
Когда хватать за bisect вместо blame или log
git blame отлично подходит, когда вы уже знаете, в каком файле баг. git log --grep отлично подходит, когда вы помните что-то о сообщении коммита. Bisect — для случаев, когда вы не знаете ни того, ни другого. Вы просто знаете, что работало в точке А и падает в точке Б, а пространство между ними слишком велико, чтобы рассуждать о нём.
Если ваша команда запускает CI на каждом коммите, вы иногда можете бисектить быстрее, читая историю CI. Но CI ловит только то, что тестирует. Регрессия производительности, визуальный баг или тонкая логическая ошибка, которую тесты пропускают, не покажутся в логах CI. Bisect работает для любого воспроизведения, которое вы можете скриптовать, вне зависимости от того, что валидирует ваш CI.
Как сделать бисекцию частью вашего рабочего процесса
Вам не нужна специальная настройка. Команды встроены в Git. Но есть две привычки, которые делают бисекцию плавнее, когда она реально нужна.
Во-первых, помечайте ваши релизы. Bisect нужен известно-хороший коммит, а теги — это самый простой способ его задать. Если ваш последний релиз был три недели назад и вы знаете, что он был чистым, git bisect good v1.4.0 — это однострочная точка отсчёта.
Во-вторых, держите ваше воспроизведение минимальным. Воспроизведение, которое занимает тридцать секунд, терпимо для восьми итераций. Воспроизведение, которое занимает десять минут, — это пытка. Прежде чем бисектить, потратьте пять минут на сужение воспроизведения до наименьшей возможной команды. Ваше будущее я скажет вам спасибо.
Когда закончите, сразу запустите git bisect reset. Detached HEAD с незакоммиченными изменениями — отличный способ потерять работу. Я узнал это на собственном горьком опыте, чтобы вам не пришлось.