Знакомая боль. Кто-то в чате пишет: "экспорт в CSV сломался". Ты открываешь - и правда, падает. Лезешь в git log - а там за неделю 247 коммитов от шести человек. Diff между "работало" и "не работает" - это +4000/-2700 строк по тридцати файлам. Глазами такое не прочитать, это не ревью, это сеанс гадания.
И тут вступает git bisect - один из самых мощных и при этом самых недооценённых инструментов в арсенале. Идея простая до неприличия: ты знаешь коммит, где точно работало (good), и коммит, где точно сломано (bad). Где-то между ними - один-единственный коммит, который всё испортил. Bisect находит его бинарным поиском: режет диапазон пополам, проверяет середину, отбрасывает ту половину, где бага гарантированно нет. И так пока не останется один виновник.
Математика тут безжалостно приятная. Чтобы найти баг git среди N коммитов перебором, в худшем случае нужно N проверок. Бинарный поиск - это log2(N). Среди 247 коммитов это 8 шагов (2^8 = 256). Среди 1000 коммитов - 10 шагов. Среди миллиона - 20. Поиск регрессии git перестаёт зависеть от глубины истории почти полностью. Восемь раз запустить тест против восьми ревизий - это десять минут работы вместо двух часов вычитывания диффов.

Как это работает под капотом
Bisect не магия, а аккуратное хождение по графу коммитов. Напомню легенду из прошлых уроков: узел - это коммит, стрелка ведёт от коммита к его родителю. У тебя есть линейный (или почти линейный) кусок истории, на одном конце good, на другом bad.
Код: Выделить всё
good bad
o <- o <- o <- o <- o <- o <- o <- o <- o
v0 v1 v2 v3 v4 v5 v6 v7 v8
Важная тонкость про "почти линейный": в реальности история - это не прямая палка, а граф со слияниями. Bisect это умеет. Он работает не на отрезке, а на DAG (направленном ацикличном графе), и на каждом шаге выбирает коммит, который максимально близок к середине по числу достижимых ревизий, а не по дате. Поэтому ему всё равно, что у тебя десять параллельных feature-веток влились в main - он честно поделит всё множество подозреваемых пополам.
Базовый ручной заход: git bisect start, bad, good
Минимальный сеанс. Запускаем поиск, отмечаем известные концы:
Код: Выделить всё
$ git bisect start
$ git bisect bad # текущий HEAD сломан
$ git bisect good v2.3.0 # на этом теге всё работало
Bisecting: 123 revisions left to test after this (roughly 7 steps)
[checkout 9f2a4c1] refactor: extract CsvWriter into separate service
Проверяешь (запускаешь приложение, гоняешь тот самый экспорт CSV) и выносишь вердикт:
Код: Выделить всё
$ git bisect good # на этой середине ещё работает
Bisecting: 61 revisions left to test after this (roughly 6 steps)
[checkout 3b71e0d] feat: add streaming mode for large exports
Код: Выделить всё
$ git bisect bad
3b71e0d is the first bad commit
commit 3b71e0d8a1c4...
Author: Petr Ivanov <petr@example.com>
Date: Mon Jun 8 14:22:10 2026 +0300
feat: add streaming mode for large exports
src/Export/CsvWriter.php | 38 ++++++++++++++++-------
1 file changed, 27 insertions(+), 11 deletions(-)
Когда закончил - обязательно вернись в исходное состояние:
Код: Выделить всё
$ git bisect reset
Previous HEAD position was 3b71e0d feat: add streaming mode...
Switched to branch 'main'
git bisect run: автоматизация и вау-эффект
Ручной режим хорош, но настоящая мощь - это git bisect run. Ты отдаёшь Git скрипт-проверку, и он сам прогоняет весь поиск, без единого твоего нажатия. Уходишь за кофе, возвращаешься - виновник найден.
Код: Выделить всё
$ git bisect start
$ git bisect bad
$ git bisect good v2.3.0
$ git bisect run ./check.sh
- exit 0 - ревизия good (тест прошёл, бага нет)
- exit 1..127, кроме 125, - ревизия bad (тест упал, баг есть)
- exit 125 - skip, ревизию нельзя проверить (например, не собирается)
- exit 126/127 - технически попадут в "bad", но это коды "нет прав на запуск" и "команда не найдена" от POSIX-шелла; именно поэтому потолок для skip - 125, а не 128
- exit >127 (типа 128+, через сигналы) - bisect остановится с ошибкой
Простейший check.sh для нашего CSV-кейса:
Код: Выделить всё
#!/bin/sh
# собрать; если не собралось - skip, а не bad
composer install --no-interaction --quiet || exit 125
# прогнать ровно один узкий тест на регрессию
vendor/bin/phpunit --filter testCsvExportRoundtrip
# exit-код phpunit пробросится наружу: 0 = good, иначе bad
Код: Выделить всё
$ git bisect run ./check.sh
running ./check.sh
Bisecting: 61 revisions left to test after this (roughly 6 steps)
running ./check.sh
Bisecting: 30 revisions left to test after this (roughly 5 steps)
running ./check.sh
...
3b71e0d8a1c4 is the first bad commit
bisect found first bad commit
Ключевая хитрость скрипта - строка
Код: Выделить всё
composer install ... || exit 125Грабли: пропуск, флапающие тесты и not-a-bug
Несобираемые коммиты и git bisect skip. В ручном режиме аналог exit 125 - это команда git bisect skip. Стоишь на ревизии, которая не компилируется или зависит от чего-то снесённого - говоришь skip, Git подберёт другую близкую к середине ревизию:
Код: Выделить всё
$ git bisect skip
Bisecting: 30 revisions left to test (roughly 5 steps)
Флапающие тесты - главный враг bisect. Если тест иногда падает, иногда нет (race, зависимость от времени, недетерминированный порядок), bisect получает шум вместо сигнала и уверенно приведёт тебя не туда. Лечится двумя способами. Первый - сделать проверку устойчивой: гонять тест несколько раз и считать good, только если все прогоны зелёные. Второй - выбирать для bisect максимально узкий и детерминированный тест. Не "прогнать весь сьют" (там полно своего шума), а один сфокусированный кейс ровно на сломанное поведение. Чем уже тест - тем чище бинарный поиск.
Грабли с reset. Забыл git bisect reset, начал работать, потом удивляешься detached HEAD и "пропавшим" изменениям. Всегда закрывай сеанс. И не путай: bisect не делает коммитов и не трогает твои файлы вне переключения ревизий - но если у тебя есть незакоммиченные правки, начни с чистого дерева, иначе checkout будет конфликтовать.
terms: bisect не только про баги (old/new)
Слова "bad" и "good" - это просто метки, и они привязаны к багам неудобно, когда ты ищешь не поломку, а появление чего-либо. Например: "в каком коммите производительность упала?", "когда тест начал проходить (баг починили, но непонятно где)?", "когда размер бандла перевалил за лимит?". Тут "хорошо/плохо" путает голову.
Для этого есть нейтральные термины old/new (старое состояние и новое состояние - ты ищешь переход old->new):
Код: Выделить всё
$ git bisect start --term-old works --term-new broken
$ git bisect broken
$ git bisect works v2.3.0
Код: Выделить всё
git bisect start --term-new slow --term-old fastlog, replay: воспроизводимость сеанса
Bisect ведёт журнал всех твоих вердиктов. Это спасает в двух ситуациях: случайно ошибся с good/bad и хочешь переиграть, или нужно отдать коллеге точные шаги.
Код: Выделить всё
$ git bisect log > bisect.log
$ git bisect log
# bad: [9f8e...] HEAD
# good: [a1b2...] v2.3.0
git bisect start
git bisect bad 9f8e...
git bisect good a1b2...
# good: [3c4d...] feat: streaming mode
git bisect good 3c4d...
Код: Выделить всё
$ git bisect reset
$ git bisect replay bisect.log
Связка с CI и когда bisect эффективнее чтения diff'ов
Тот же check.sh, что ты пишешь для bisect, - это по сути мини-CI-джоба. Поэтому связка очевидна: воспроизводимый тест-кейс на регрессию живёт в репозитории, и его одинаково гоняет и пайплайн, и bisect run. На крупных проектах есть и автоматический bisect "по кнопке": CI ловит провал на main, берёт последний зелёный прогон как good, текущий как bad, и сам запускает bisect run, чтобы в тикете сразу лежал коммит-виновник и его автор. Geometric-обслуживание репозитория (дефолт ручного maintenance с Git 2.54) тут в помощь - checkout'ы между далёкими ревизиями на хорошо упакованном репо быстрые.
Когда bisect реально выигрывает у чтения диффов? Когда между good и bad много коммитов и/или много авторов, а симптом - чёткий бинарный признак (работает / не работает, проходит / падает). Если же диапазон - три коммита одного человека, открой git log -p и прочитай, это быстрее. Bisect не заменяет понимание кода - он сужает место, где это понимание нужно применить, с тысячи строк до десятков.
Мини-лаба: поймай регрессию руками
Собери игрушечный репозиторий и натрави на него bisect run.
- Создай папку, git init, и сделай файл calc.sh, который печатает число 42, плюс тест check.sh, который проверяет это (grep 42). Закоммить - это твой good.
- Сделай ещё 10-15 пустяковых коммитов (меняй комментарии, README - что угодно), периодически коммить рабочий calc.sh.
- Где-то в середине "сломай": в одном коммите поменяй 42 на 41 в calc.sh. Запиши его хеш на бумажку и забудь.
- Сделай ещё несколько коммитов поверх. Текущий HEAD теперь bad.
- Запусти: git bisect start; git bisect bad; git bisect good <первый коммит>; git bisect run ./check.sh
- Сверь "first bad commit" с бумажкой - совпало.
- Бонус: в один из коммитов внеси синтаксическую поломку в calc.sh и в check.sh добавь строку, возвращающую exit 125, если calc.sh не запускается. Повтори bisect и убедись, что эта ревизия пропущена (skip), а не помечена bad.
- Закрой сеанс: git bisect reset.
- Среди 500 коммитов между good и bad - сколько примерно проверок сделает bisect и почему именно столько?
- Скрипт для bisect run на несобираемой ревизии вернул exit 1. Как Git её пометит и к чему это приведёт? Как починить скрипт?
- Зачем нужны termы old/new, если есть good/bad? Приведи задачу, где old/new уместнее.
- Ты прошёл половину сеанса и понял, что на третьем шаге ошибся с вердиктом. Как переиграть, не отвечая заново на первые шаги?
git bisect превращает "где-то среди сотен коммитов сломалось" в log2(N) механических проверок. Руками - start/bad/good и checkout середины; автоматом - bisect run со скриптом, где exit 0 это good, 1..127 (кроме 125) это bad, а 125 это skip для несобираемого. Узкий детерминированный тест, 125 на несборку, нейтральные old/new для не-багов, log/replay для воспроизводимости - и виновник найден за минуты, а не за часы. Когда в следующий раз услышишь "неделю назад работало" - ты знаешь, что делать.