git bisect: поиск сломавшего коммита бинарным поиском

Как за несколько шагов найти коммит, в котором что-то сломалось, даже если их между «было хорошо» и «стало плохо» тысячи.

git bisect — встроенный в Git бинарный поиск по истории: вы отмечаете один «хороший» и один «плохой» коммит, а Git сам расставляет вас по середине диапазона и за log₂N проверок находит коммит, который внёс регрессию.

Зачем это нужно на практике

Знакомая ситуация: вчера тесты были зелёными, сегодня один из них падает, а между этими состояниями — 200 коммитов от пяти человек. Читать diff каждого вручную бессмысленно. Откатываться «на глаз» — лотерея. git bisect превращает поиск в строгий алгоритм: вместо 200 проверок вам понадобится примерно log₂200 ≈ 8. Это та же идея, что и поиск числа в отсортированном массиве — каждый шаг вдвое сокращает зону подозрения.

Ключевое условие: «плохое» свойство должно быть монотонным. То есть существует момент, после которого баг есть всегда, а до него его нет. Если баг то появляется, то исчезает (флапает), бинарный поиск даст неверный результат — об этом ниже.

Базовый ручной сценарий

Сессия начинается командой git bisect start. Дальше вы сообщаете Git два известных факта: текущее состояние плохое, а какой-то старый коммит (тег релиза, вчерашний коммит) — хороший.

git bisect start
git bisect bad                 # текущий HEAD сломан
git bisect good v1.4.0         # на этом теге всё работало
# Git: Bisecting: 97 revisions left to test after this (roughly 7 steps)
# и переключает рабочее дерево на коммит-середину

Теперь Git сам сделал checkout на коммит посередине диапазона. Ваша задача — проверить именно это состояние (собрать, запустить тест, ткнуть в приложении руками) и вынести вердикт одной командой:

git bisect good   # в этой ревизии бага ещё нет
# ИЛИ
git bisect bad    # в этой ревизии баг уже есть

После каждого вердикта Git снова делит оставшийся диапазон пополам и переключается на новую середину. Через несколько итераций он печатает виновника:

# 3f9a1c2 is the first bad commit
# Author: ...
# commit message ...
# а ниже — список изменённых файлов

Обязательно завершите сессию, чтобы вернуть HEAD на исходную ветку:

git bisect reset   # вернуться туда, где вы были до старта

Автоматизация через bisect run

Самое мощное — git bisect run. Вместо ручного вердикта вы даёте Git скрипт или команду; Git гоняет её на каждой ревизии и читает код возврата: 0 — хорошо, любой ненулевой (кроме 125) — плохо. Весь поиск проходит без вашего участия.

git bisect start
git bisect bad HEAD
git bisect good v1.4.0
git bisect run pytest tests/test_login.py -q

Если проверка сложнее одной команды, оформите её скриптом и верните нужные коды:

#!/usr/bin/env bash
# check.sh — собрать и проверить ровно один симптом регрессии
make build || exit 125        # 125 = «ревизию не собрать, пропусти её»
if ./app --selftest | grep -q "checksum mismatch"; then
  exit 1                      # симптом есть -> bad
else
  exit 0                      # симптома нет -> good
fi
chmod +x check.sh
git bisect start HEAD v1.4.0   # порядок: bad good одной строкой
git bisect run ./check.sh

Код 125 — особый: он говорит «эту ревизию невозможно протестировать, исключи её из выборки» (например, код тут не компилируется по другой причине). Git пропустит коммит и возьмёт соседний, не засчитав его ни плохим, ни хорошим.

Как это работает под капотом

Git строит граф предков между «good» и «bad» и на каждом шаге выбирает коммит, максимально близкий к середине по числу достижимых ревизий, а не по дате. Поэтому bisect корректно работает даже с ветвлениями и слияниями: он считает топологию, а не календарь. Рабочее дерево физически переключается на выбранный коммит (detached HEAD) — именно поэтому важно проверять собранный артефакт, а не исходник «на глаз». Состояние сессии Git хранит в файлах внутри .git/ (например, .git/BISECT_LOG), и его можно посмотреть или перезапустить:

git bisect log              # полный протокол вердиктов
git bisect log > bisect.txt # сохранить, чтобы повторить позже
git bisect replay bisect.txt # воспроизвести сессию из лога

Если по ходу вы ошиблись с вердиктом, не нужно начинать заново — отмотайте сессию:

git bisect skip            # пропустить текущую (нестабильную) ревизию
git bisect good~3          # синтаксис не сработает; используйте replay/log

Частые ошибки

  • Перепутаны good и bad. Если отметить сломанное как «good», Git уведёт поиск не туда и найдёт «первый хороший» вместо «первого плохого». Запомните: bad — то, что болит сейчас; good — заведомо здоровое прошлое.
  • Флапающий тест. Если симптом непостоянный, проверяйте признак, который воспроизводится стабильно, или прогоняйте тест несколько раз в скрипте и решайте по большинству. Для подозрительных ревизий — git bisect skip.
  • Грязное рабочее дерево. Перед start закоммитьте или спрячьте изменения: bisect переключает дерево и упрётся в незакоммиченные правки.
  • Забыли reset. Без него вы останетесь в detached HEAD на старом коммите и потом удивитесь «пропавшим» последним изменениям.
  • Сборка тоже сломана. Если часть диапазона не компилируется по постороннней причине, используйте код 125 в скрипте, иначе Git примет ошибку сборки за искомый баг.

Итоги

  • git bisect start / bad / good запускают бинарный поиск; Git сам ставит вас на середину диапазона.
  • Поиск занимает ~log₂N проверок — на тысячах коммитов это меньше десятка шагов.
  • git bisect run <команда> автоматизирует всё: код 0 — good, ненулевой — bad, 125 — пропустить ревизию.
  • Свойство «сломано» должно быть монотонным; флапающие симптомы лечит skip или повтор теста.
  • Всегда завершайте сессию git bisect reset, чтобы вернуть HEAD на ветку.
Проверьте себя
1. Сколько примерно проверок понадобится git bisect, чтобы найти сломавший коммит среди 1000 коммитов между good и bad?
AОколо 1000
BОколо 500
CОколо 10
DРовно 2
2. Что означает код возврата 125 от скрипта в `git bisect run`?
AРевизию невозможно протестировать — пропустить её
BБаг найден, можно останавливаться
CРевизия хорошая (как код 0)
DОшибка в самой команде bisect
3. Какая команда обязательна в конце сессии, чтобы вернуть HEAD на исходную ветку?
Agit bisect stop
Bgit bisect reset
Cgit checkout bad
Dgit bisect good