git bisect: бинарный поиск регрессии

Рейтинг: 71.7% · 16 голосов
Самый подробный курс по Git - от первого коммита до объектной базы и packfiles. Терминал и первый репозиторий, ветки и слияние, конфликты, удалённая работа и форджи (GitHub, GitLab), rebase и переписывание истории, спасение кода через reflog и fsck, stash и worktree, bisect и blame, подмодули и монорепо, хуки и подпись коммитов, внутреннее устройство и масштаб. Для новичков и профи. Актуально на 2026 (Git 2.54, дорожная карта 3.0).
Ответить
Аватара пользователя
Pavel_Git
Сообщения: 52
Зарегистрирован: 11 май 2026, 05:31

git bisect: бинарный поиск регрессии

Сообщение Pavel_Git »

Оглавление курса (52)
  1. Что такое контроль версий и почему победил Git
  2. Установка Git на Windows, macOS и Linux и первая настройка
  3. Терминал и как спрашивать у Git: выживание в командной строке
  4. Ментальная модель Git: три состояния, коммит-снимок и граф
  5. Первый репозиторий: init, add, commit, status
  6. Просмотр изменений: status, diff и индексация по частям
  7. История проекта: git log, диапазоны и поиск
  8. .gitignore: что не должно попасть в репозиторий
  9. .gitattributes: окончания строк, бинарники и атрибуты
  10. Ветки как указатели: branch, switch и detached HEAD
  11. Слияние веток: fast-forward против трёхстороннего merge
  12. Конфликты слияния: анатомия и уверенное разрешение
  13. Практикум: твой первый день с Git от нуля до push
  14. Удалённые репозитории: remote, fetch, базовый pull и refspec
  15. git push: безопасная отправка и защита от перезаписи
  16. Клонирование: shallow, partial clone, bundle и backfill
  17. GitHub, GitLab и форки: pull request, rulesets и защита веток
  18. Рабочие процессы команды: GitFlow, GitHub Flow, trunk-based
  19. Merge queue и merge trains: безопасное вливание в trunk
  20. Культура code review и Conventional Commits
  21. git reset: три дерева и отмена индексации и коммитов
  22. git revert: безопасная отмена в общей истории
  23. Правка последнего коммита: git commit --amend
  24. git rebase: пересадка истории и золотое правило
  25. Интерактивный rebase: переписать серию коммитов
  26. git cherry-pick: перенос отдельных коммитов
  27. Stacked diffs: цепочка зависимых PR
  28. Массовое переписывание истории: git filter-repo и git replace
  29. git reflog: журнал, который помнит всё
  30. Восстановление утерянных коммитов, веток и файлов
  31. Откат опасных операций: reset --hard, сломанный rebase, плохой merge
  32. git stash: тайник для незавершённой работы
  33. git worktree: несколько рабочих деревьев одного репозитория
  34. git bisect: бинарный поиск регрессии (вы здесь)
  35. git blame, mailmap и археология кода
  36. Теги, релизы, archive и bundle
  37. Подмодули: внешние репозитории внутри проекта
  38. Subtree, монорепозитории и sparse-checkout
  39. Git LFS и большие файлы
  40. Вспомогательные команды: clean, mv, rm, restore
  41. Псевдонимы и кастомизация .gitconfig
  42. Хуки Git: автоматизация на клиенте и сервере
  43. Подпись коммитов и тегов: SSH, GPG и Sigstore
  44. Внутреннее устройство I: объектная база Git
  45. Внутреннее устройство II: ссылки, HEAD, notes и reftable
  46. Внутреннее устройство III: индекс, packfiles, gc и обслуживание
  47. От SHA-1 к SHA-256: переход на новый хеш
  48. Огромные репозитории: partial clone, FSMonitor, Scalar
  49. Git в CI/CD: shallow, кеш, merge_group и безопасность
  50. Куда движется Git: дорожная карта 3.0 и экосистема
  51. Профессиональный setup: собираем рабочее окружение Git
  52. 30+ типичных ошибок Git и как из них выбираться
Неделю назад работало, а теперь нет

Знакомая боль. Кто-то в чате пишет: "экспорт в 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
Git берёт диапазон коммитов между good и bad (это ровно те, что "достижимы из bad, но не из good" - привычная нам нотация bad ^good), считает, сколько их, и делает checkout того, что стоит посередине. Ты проверяешь эту ревизию и говоришь bad или good. Если середина оказалась good - значит, баг появился позже, вся левая половина отбрасывается. Если bad - баг был уже здесь, отбрасывается правая. Диапазон схлопывается вдвое за шаг.

Важная тонкость про "почти линейный": в реальности история - это не прямая палка, а граф со слияниями. 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
Разберём вывод по косточкам. "123 revisions left to test" - столько коммитов сейчас в зоне подозрения. "roughly 7 steps" - вот тебе тот самый log2: 2^7 = 128, чуть больше 123. И главное - Git уже сам сделал checkout на коммит 9f2a4c1, ровно середину диапазона. Ты сейчас физически находишься на этой ревизии. Рабочее дерево переключено, можно собирать и тестировать.

Проверяешь (запускаешь приложение, гоняешь тот самый экспорт CSV) и выносишь вердикт:

Код: Выделить всё

$ git bisect good                # на этой середине ещё работает
Bisecting: 61 revisions left to test after this (roughly 6 steps)
[checkout 3b71e0d] feat: add streaming mode for large exports
123 превратилось в 61 - половина истории испарилась одним словом. Git снова перепрыгнул на новую середину. Так шаг за шагом, отвечая good/bad, ты сжимаешь кольцо. В конце:

Код: Выделить всё

$ 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(-)
Вот он, виновник. "3b71e0d is the first bad commit" - первый коммит, где появилась поломка. И обрати внимание: Git сразу показывает diff виновника, и он крошечный - 38 строк в одном файле. Вот это и есть тот самый дифф, который реально стоит читать глазами. Bisect не читает код за тебя - он сводит "прочитать 4000 строк" к "прочитать 38 строк в правильном месте".

Когда закончил - обязательно вернись в исходное состояние:

Код: Выделить всё

$ git bisect reset
Previous HEAD position was 3b71e0d feat: add streaming mode...
Switched to branch 'main'
git bisect reset возвращает HEAD туда, где ты был до start, и чистит служебное состояние. Пока сеанс идёт, Git держит метаданные в .git/BISECT_* и специальные ссылки в refs/bisect/. Без reset ты так и останешься болтаться на случайной середине истории в detached HEAD - частая причина паники у новичков ("где моя ветка?!").

git bisect run: автоматизация и вау-эффект

Ручной режим хорош, но настоящая мощь - это git bisect run. Ты отдаёшь Git скрипт-проверку, и он сам прогоняет весь поиск, без единого твоего нажатия. Уходишь за кофе, возвращаешься - виновник найден.

Код: Выделить всё

$ git bisect start
$ git bisect bad
$ git bisect good v2.3.0
$ git bisect run ./check.sh
Дальше Git на каждой ревизии запускает ./check.sh и читает его код возврата (exit code). Вот вся таблица интерпретации, выучи её - это сердце автоматизации:
  • exit 0 - ревизия good (тест прошёл, бага нет)
  • exit 1..127, кроме 125, - ревизия bad (тест упал, баг есть)
  • exit 125 - skip, ревизию нельзя проверить (например, не собирается)
  • exit 126/127 - технически попадут в "bad", но это коды "нет прав на запуск" и "команда не найдена" от POSIX-шелла; именно поэтому потолок для skip - 125, а не 128
  • exit >127 (типа 128+, через сигналы) - bisect остановится с ошибкой
Почему именно 125? Это не случайное число. В POSIX-шеллах 126 значит "файл найден, но не исполняемый", а 127 - "команда не найдена". Эти коды у тебя могут вылезти случайно, и трактовать их как "skip" было бы опасно. 125 - максимальное безопасное значение ниже них, его и выбрали под skip.

Простейший 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 сам пляшет по истории:

Код: Выделить всё

$ 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
Вот это и есть вау-эффект. Восемь автоматических прогонов, и Git кладёт перед тобой хеш виновника. Ты в это время даже не смотрел в терминал. На реальном проекте, где сборка минута и тест полминуты, весь поиск среди тысячи коммитов - это пятнадцать минут фоновой работы машины.

Ключевая хитрость скрипта - строка

Код: Выделить всё

composer install ... || exit 125
. Без неё несобираемая ревизия (а такие почти всегда есть в истории - кто-то закоммитил промежуточный сломанный шаг) уронила бы тест, скрипт вернул бы ненулевой код, и Git ошибочно пометил бы её bad. Это сместило бы весь поиск и привело к ложному виновнику. Правило железное: не смог проверить - верни 125, пусть Git пропустит и возьмёт соседа.

Грабли: пропуск, флапающие тесты и 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)
Если виновник зажат между несколькими пропущенными, в конце Git честно скажет, что не может назвать ровно один коммит, и выдаст список кандидатов ("The first bad commit could be any of: ..."). Это не провал - это сужение до 2-3 подозреваемых, которые ты добьёшь глазами.

Флапающие тесты - главный враг 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 fast
для регрессии скорости. Семантика та же бинарная, но язык совпадает с задачей. По умолчанию old=good, new=bad, так что это чистый сахар для ясности.

log, 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...
Если понял, что на каком-то шаге ошибся, - правишь bisect.log руками (убираешь кривой вердикт), сбрасываешь и проигрываешь заново:

Код: Выделить всё

$ git bisect reset
$ git bisect replay bisect.log
git bisect replay прогонит весь сценарий из файла до того места, где ты остановился, - и ты продолжишь с корректной точки, не переотвечая на десяток вопросов заново.

Связка с 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 для воспроизводимости - и виновник найден за минуты, а не за часы. Когда в следующий раз услышишь "неделю назад работало" - ты знаешь, что делать.
👍2 ❤️ 🔥1 😄 🤔1
Аватара пользователя
krasso
Сообщения: 1
Зарегистрирован: 15 май 2026, 06:48

Re: git bisect: бинарный поиск регрессии

Сообщение krasso »

Блин, я месяцами читал диффы глазами как идиот. Попробовал bisect run на проде сегодня - нашёл коммит за 6 минут. Где ты был раньше.
👍 ❤️ 🔥2 😄 🤔
Аватара пользователя
lonelyblueteam
Сообщения: 1
Зарегистрирован: 14 май 2026, 00:54

Re: git bisect: бинарный поиск регрессии

Сообщение lonelyblueteam »

Вопрос по флапающим тестам: а если я не уверен что тест детерминированный, можно в check.sh прогнать его 3 раза и считать good только если все три зелёные? Или это уже паранойя?
👍1 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
git worktree: несколько рабочих деревьев одного репозитория
Следующая глава →
git blame, mailmap и археология кода

Все главы курса «Git профессионально: от первого коммита до внутреннего устройства»

Поделиться темой: ✈ Telegram VK
Похожие запросы: git bisect: найти коммит с багом

Вернуться в «Git профессионально: от первого коммита до внутреннего устройства»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 0 гостей