Git и командная работа Расследование по истории: log, pickaxe, blame и bisect
0%

Расследование по истории: log, pickaxe, blame и bisect

Расследование по истории: log, pickaxe, blame и bisect

Десять предыдущих статей трека были про то, как записать историю: объекты, ветки, слияния, переписывание, ревью. Но история пишется не ради архива. Она нужна в тот момент, когда в проде что-то сломалось, а вопрос звучит так: «этот код работал в июне, сейчас не работает, кто и когда его тронул и зачем».

И вот тут выясняется неприятное: команду git commit знают все, а git log -S, git log --full-history, git blame -C -C, git bisect run — почти никто. Расследование ведут глазами: открывают файл, скроллят git log, читают три сотни коммитов и сдаются. Между тем Git содержит полноценный набор следственных инструментов, каждый из которых отвечает на свой класс вопросов за секунды, а не за час.

Эта статья — про чтение истории. Она опирается на модель данных из «Внутри Git» (коммит-DAG, деревья, достижимость) и не пересказывает восстановление потерянного — это «Восстановление», там про reflog и fsck. Здесь наоборот: всё на месте, ничего не потеряно, надо найти улику среди десятков тысяч коммитов.

Три вопроса и три инструмента

Любое расследование по истории сводится к одному из трёх вопросов, и путать их — главный источник потраченного впустую времени.

Вопрос Инструмент Что даёт на выходе
«Когда в коде появилась/исчезла вот эта строка, символ, константа?» git log -S / -G (pickaxe), git log -L список коммитов, менявших содержимое
«Кто последним трогал вот эту конкретную строку и в каком коммите?» git blame, git log -L коммит и автор на строку
«Какой коммит сломал поведение, которое раньше работало?» git bisect ровно один коммит, найденный за log₂(N) проверок

Разница принципиальна. blame отвечает на вопрос «кто написал эту строку», а не «кто сломал». Строка может быть невиновной: сломала её другая строка в другом файле, а blame покажет автора, который просто переставил отступ. Поэтому, когда вопрос — «что сломалось», правильный первый ход почти всегда bisect, а не blame.

Язык вопросов: выбор ревизий

Прежде чем искать, надо уметь называть множество коммитов, в котором ищем. Синтаксис описан в gitrevisions(7), и восьми конструкций хватает на 99% случаев.

Запись Что означает Когда нужна
A..B достижимо из B, но не из A «что нового в ветке относительно main»
A...B симметрическая разность (в log) «чем две ветки отличаются в обе стороны»
^A B C то же, что B C --not A исключить целую линию из поиска
HEAD~3 третий предок по первому родителю шаг назад по стволу
HEAD^2 второй родитель merge-коммита зайти в влитую ветку
HEAD^! сам коммит без его родителей «покажи только этот коммит как диапазон»
:/промокод самый свежий достижимый коммит, чьё сообщение содержит текст быстро найти коммит по памяти
v2.3^{tree}, v2.3:src/app.py дерево тега, содержимое файла в теге достать старую версию, не переключаясь

Две конструкции стоят отдельного слова, потому что их почти не знают.

--ancestry-path отвечает на вопрос «через какие коммиты изменение реально дошло до релиза». Обычный A..B покажет всё, что достижимо из B и не достижимо из A, включая параллельные ветки, никак с A не связанные. --ancestry-path оставляет только те коммиты, которые являются одновременно потомками A и предками B:

# вся «дельта» между коммитом и релизом — сотни коммитов, большинство не по теме
$ git log --oneline a1b2c3d..v2.4.0 | wc -l
417

# только путь, по которому изменение доехало до релиза — обычно единицы
$ git log --oneline --ancestry-path a1b2c3d..v2.4.0
9f3a2c1 Merge pull request #812 from acme/release-2.4
4d1e8b7 Merge branch 'feature/pricing' into develop
7c0a5f2 fix: округление скидки

git describe --contains и --contains у веток и тегов отвечают на вопрос «а этот фикс уже уехал в прод?»:

$ git describe --contains a1b2c3d          # в каком теге впервые появился коммит
v2.4.0~18^2~3
$ git branch -a --contains a1b2c3d         # в каких ветках он есть
  main
  remotes/origin/main
  remotes/origin/release/2.4
$ git merge-base --is-ancestor a1b2c3d origin/production && echo "уже в проде"
уже в проде

Последняя команда ничего не печатает и отвечает кодом возврата — идеально для скриптов и хуков (см. «Хуки и автоматизация»).

Почему коммит «пропал» из git log по файлу

Это самая частая и самая недооценённая ловушка расследования. Разработчик пишет git log -- src/cart.py, не видит в выводе коммит коллеги, который точно правил этот файл, и делает вывод «Git потерял историю». Git ничего не терял: по умолчанию git log с путём не показывает историю, он показывает упрощённую историю.

Правило упрощения такое: коммит выбрасывается из вывода, если его дерево по указанному пути совпадает хотя бы с одним родителем (в терминах документации — TREESAME), а у merge-коммитов обход идёт только по той ветке, которая объясняет итоговое содержимое. Смысл разумный: показать минимальную цепочку изменений, которая объясняет текущее состояние файла. Побочный эффект неприятный: если конфликт при слиянии разрешили в пользу одной стороны, коммиты другой стороны исчезают из вывода полностью.

Воспроизводится за минуту:

$ git log --oneline --graph --all
*   93c10d2 (HEAD -> main) merge: взяли версию main
|\
| * 1351ccc (side) side: правка
* | 16ab48c main: правка
|/
* 60e56bd base

# по умолчанию: правки ветки side в выводе НЕТ
$ git log --oneline -- f.txt
16ab48c main: правка
60e56bd base

# полная история: коммит на месте
$ git log --oneline --full-history -- f.txt
93c10d2 merge: взяли версию main
16ab48c main: правка
1351ccc side: правка
60e56bd base

# полная история с осмысленной топологией
$ git log --oneline --full-history --simplify-merges -- f.txt
93c10d2 merge: взяли версию main
1351ccc side: правка
16ab48c main: правка
60e56bd base

Практический вывод: как только вы расследуете инцидент, а не листаете историю для интереса, добавляйте --full-history. Разница между «фикс потеряли при мерже» и «фикса никогда не было» видна только в этом режиме.

Полная карта режимов обхода:

Ключ Что делает Когда применять
(по умолчанию) упрощённая история: минимальная цепочка, объясняющая содержимое обычное чтение
--full-history не выбрасывать боковые ветки расследование, аудит
--full-history --simplify-merges полная история, но без коммитов, ничего не менявших по пути лучший режим для разбора
--first-parent только ствол: merge-коммиты как атомарные «влитые работы» «что уехало в main и когда»
--sparse показывать все промежуточные коммиты обхода отладка самого обхода
--follow <path> продолжать историю через переименования файл переезжал между каталогами
--diff-filter=D только коммиты, где путь удалён «кто удалил этот файл»

--follow — эвристика поверх детектора переименований, и работает она только с одним путём. Если файл переименовали вместе с массовым рефакторингом, где сходство упало ниже порога, --follow цепочку потеряет; тогда остаётся git log --full-history -- 'старый/путь' 'новый/путь'.

Ещё один режим обхода — по первому родителю:

git log --first-parent на таком графе покажет четыре записи: «корзина», «bump deps», merge-коммит и «hotfix». Внутренняя кухня ветки не попадает в вывод — это ровно то, что нужно для changelog и для ответа «когда фича приехала в main», и ровно то, чего не нужно, когда ищете конкретную строку.

Поиск по содержимому: pickaxe

Вопрос «когда в коде появилась эта константа» решается не грепом по рабочему каталогу (там её уже нет) и не чтением истории глазами, а «киркой» — режимом -S/-G у git log.

-S<строка> показывает коммиты, в которых изменилось количество вхождений строки. -G<регулярка> показывает коммиты, в тексте диффа которых есть добавленная или удалённая строка, подходящая под регулярку. Разницу проще всего увидеть на перестановке строки внутри файла: количество вхождений не изменилось, а в диффе строка есть и в +, и в -.

$ git log --oneline -S TOKEN_SECRET
dd06ed8 удалили TOKEN_SECRET
eebb205 добавили TOKEN_SECRET

$ git log --oneline -G TOKEN_SECRET
dd06ed8 удалили TOKEN_SECRET
3e41e67 переставили строку      # <- только -G
eebb205 добавили TOKEN_SECRET

Отсюда рабочее правило: -S — для «когда появилось и когда исчезло», -G — для «где вообще упоминалось». Первый даёт короткий точный список (обычно 2–5 коммитов), второй — длинный и полный.

Полезные спутники:

# -S с регулярным выражением, а не литералом
git log -S 'timeout\s*=\s*[0-9]+' --pickaxe-regex

# показать ВЕСЬ коммит целиком, а не только файлы с найденной строкой
git log -S 'DEFAULT_TIMEOUT' --pickaxe-all -p

# кто добавил ровно этот блоб (по хешу объекта) — ловит копипасту файла
git log --find-object=6f0a2c9e1b3d5f7a9c0e2b4d6f8a0c2e4b6d8f01

# эволюция одной функции: -L :имя:файл, диапазон строк ищется по diff-драйверу
git log -L :calculate_discount:src/pricing.py

# эволюция конкретного диапазона строк
git log -L 120,168:src/pricing.py --oneline

-L :имя_функции:файл опирается на «funcname»-регулярки из diff-драйверов, которые Git знает для двух десятков языков; для своего языка драйвер задаётся в .gitattributes (*.py diff=python) — тот же механизм, что подписывает заголовки ханков в диффе.

Про стоимость. Pickaxe — не индекс, а перебор: Git идёт по коммитам и для каждого считает разницу деревьев по интересующим путям. Сложность O(K · C), где K — число коммитов в диапазоне, C — цена одного сравнения. Отсюда два вывода. Первый: всегда ограничивайте область — git log -S ... -- src/billing/ вместо поиска по всему репозиторию, --since=2025-01-01 вместо всей истории. Второй: -S без --pickaxe-regex дешевле -G, потому что ему достаточно посчитать вхождения в двух блобах, тогда как -G обязан построить полный текст диффа.

Ускоряет обход commit-graph с фильтрами Блума по изменённым путям — механизм, разобранный в «Больших репозиториях»:

git commit-graph write --reachable --changed-paths
git config core.commitGraph true
git config fetch.writeCommitGraph true

На репозитории с сотнями тысяч коммитов git log -- path после этого ускоряется в разы: фильтр Блума позволяет пропускать коммиты, которые заведомо не трогали путь, не читая их деревья.

git grep: поиск в срезе, а не в истории

git grep — это grep по дереву (по индексу, рабочему каталогу или произвольной ревизии), а не по истории. Он не заменяет pickaxe, но заметно быстрее обычного grep -r, потому что не заходит в игнорируемые файлы и умеет искать сразу в нескольких ревизиях.

git grep -n "DEFAULT_TIMEOUT"                       # в рабочем каталоге
git grep -n "DEFAULT_TIMEOUT" v2.3.0                # в теге, без checkout
git grep -n "DEFAULT_TIMEOUT" v2.3.0 v2.4.0 main    # сразу в трёх ревизиях
git grep -n -e "timeout" --and -e "retry"           # оба условия в одном файле
git grep -n --heading --break "class Order"         # человекочитаемый вывод
git grep -c "TODO" $(git rev-list --all) 2>/dev/null | head   # дорого, но иногда надо

Последняя строка — легальный способ ответить «в какой вообще момент в репозитории это существовало», но стоит она обхода всех деревьев всех коммитов. Если вопрос звучит как «когда появилось» — это pickaxe, и он на порядки быстрее.

git blame: механика и её границы

blame отвечает на вопрос «какой коммит последним изменил каждую строку файла». Алгоритм простой и объясняет все особенности поведения: Git берёт версию файла в указанной ревизии, помечает все строки как «неопознанные», сравнивает с версией в родителе и всё, что совпало, передаёт родителю дальше; остаток приписывает текущему коммиту. Затем шаг повторяется вверх по DAG, пока каждая строка не получит владельца.

Как git blame идёт назад по истории и назначает строкам коммит-владельца

$ git blame -L 118,124 --date=short src/pricing.py
a1b2c3d1 (Ada Lovelace 2025-11-02 118) def apply_discount(total, promo):
4d5e6f72 (Bob Kahn     2026-01-14 119)     if promo is None:
4d5e6f72 (Bob Kahn     2026-01-14 120)         return total
ef012345 (Ada Lovelace 2026-06-30 121)     rate = PROMO_RATES.get(promo.code, 0)
ef012345 (Ada Lovelace 2026-06-30 122)     return round(total * (1 - rate), 2)

Голый blame врёт чаще, чем кажется, и лечится четырьмя ключами:

Ключ Что чинит
-w игнорировать изменения только в пробелах — иначе автором становится тот, кто поправил отступ
-M найти строки, перемещённые внутри файла, и приписать их исходному коммиту
-C найти строки, скопированные из других файлов, изменённых в том же коммите
-C -C искать и в файлах, которые в этом коммите только создавались
-C -C -C искать во всех файлах любой ревизии — очень дорого, но находит переезды кода целиком
--ignore-rev <sha> исключить конкретный коммит (массовый реформат) из назначения авторства

Практический дефолт для расследования: git blame -w -C -L 118,124 -- src/pricing.py. Практический дефолт для репозитория — файл со списком «шумных» коммитов, чтобы каждый разработчик не вспоминал --ignore-rev руками:

$ git log --oneline -1 --grep="reformat all"
9c3f1a2 chore: прогнать black по всему проекту

$ echo "9c3f1a25e8b7d0c4f6a2e1b9d7c5f3a1e0b8d6c4" >> .git-blame-ignore-revs
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
$ git config blame.markIgnoredLines true   # такие строки помечаются знаком ?

Файл .git-blame-ignore-revs версионируется, лежит в корне и понимается не только Git, но и веб-интерфейсом GitHub — один коммит, и «слепые зоны» blame исчезают у всей команды.

Обратный blame отвечает на противоположный вопрос: не «кто написал строку», а «в какой ревизии строка ещё существовала». Это единственный удобный способ найти коммит, где нужный код удалили:

$ git blame --reverse v2.0.0..HEAD -L 42,42 -- src/legacy.py
3e41e677 (Ada Lovelace 2026-03-11 42) def _fallback_rate(code):

Вывод читается так: последний коммит, в котором строка ещё была на месте, — 3e41e677; значит, удалили её в одном из его потомков.

Чего blame не умеет в принципе. Он работает с текстом, а не со смыслом: переезд функции в другой файл без -C -C -C выглядит как «Ada написала 200 новых строк»; строка, которая ломает сборку, могла быть корректной три года и перестать работать из-за обновления зависимости; наконец, blame показывает последнее касание, а не первопричину. Поэтому «культура blame» — искать виноватого по выводу этой команды — не только вредна социально, но и технически бессмысленна. Инструмент отвечает на вопрос «где искать контекст», а не «кто виноват»; про работу с чужим и старым кодом см. «Легаси-код».

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

Когда есть воспроизводимый признак поломки, все предыдущие инструменты — вторичны. Бисекция находит виноватый коммит механически, не требуя понимать код.

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

$ git rev-list --bisect-vars HEAD ^v2.3.0
bisect_rev='c0553f7d7d088841d1c92ef7b738cf72627d0d83'
bisect_nr=203      # кандидатов останется после этой проверки
bisect_good=203    # если коммит окажется хорошим
bisect_bad=204     # если плохим
bisect_all=407     # всего кандидатов сейчас
bisect_steps=7     # осталось шагов

Отсюда честная оценка сложности: O(log₂ N) проверок при N коммитов-кандидатов. Для 10 000 коммитов это 14 запусков теста; при тесте на минуту — четверть часа против дней ручного чтения.

Ручная сессия выглядит так:

$ git bisect start
$ git bisect bad                  # текущий HEAD сломан
$ git bisect good v2.3.0          # на этом теге точно работало
Bisecting: 203 revisions left to test after this (roughly 8 steps)
[9f83f3f9176360793a12bd79a06c3d935246c4e4] refactor: вынести расчёт скидки

# ... собрали, проверили ...
$ git bisect bad                  # или good, или skip
...
dac7d6e9fd3b15f262a319502a93298420200829 is the first bad commit
$ git bisect reset                # вернуться туда, где были

bisect run: автоматизация

Ручная бисекция — это 10–15 раз переключиться, собрать, проверить, набрать good/bad. Ошибиться на одном шаге — испортить весь результат. Поэтому в реальности бисекцию почти всегда запускают скриптом.

$ git bisect start HEAD v2.3.0
$ git bisect run ./scripts/check.sh

Контракт скрипта задан кодами возврата, и его надо знать наизусть:

Код возврата Значение
0 коммит хороший
1127, кроме 125 коммит плохой
125 коммит непроверяем (не собирается) — эквивалент git bisect skip
128255 прервать бисекцию с ошибкой

Правильный скрипт учитывает четыре вещи, о которых забывают: чистую сборку, обновление подмодулей, флейки-тесты и то, что сам скрипт не должен лежать в проверяемом деревеgit checkout на каждом шаге его перезапишет или удалит.

#!/usr/bin/env bash
# /home/ada/bisect/check.sh — ЛЕЖИТ ВНЕ РЕПОЗИТОРИЯ, иначе checkout его затрёт
set -u

# подмодули обязаны соответствовать проверяемому коммиту
git submodule update --init --recursive --quiet || exit 125

# сборка не удалась — это не «плохой коммит», это «непроверяемый»
if ! make -s build 2>/dev/null; then
  exit 125
fi

# флейки: три прогона, «плохо» только если падает стабильно
fails=0
for _ in 1 2 3; do
  pytest -q tests/test_orders.py::test_pagination >/dev/null 2>&1 || fails=$((fails + 1))
done

[ "$fails" -ge 2 ] && exit 1   # плохой
exit 0                         # хороший

Реальный прогон печатает ход поиска и заканчивается вердиктом:

running '/home/ada/bisect/check.sh'
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[52ededf210dc776978f207e50f363580bf1a7718] commit 11
running '/home/ada/bisect/check.sh'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[9f83f3f9176360793a12bd79a06c3d935246c4e4] commit 10
running '/home/ada/bisect/check.sh'
dac7d6e9fd3b15f262a319502a93298420200829 is the first bad commit
commit dac7d6e9fd3b15f262a319502a93298420200829
Author: Ada <ada@example.com>
Date:   Sun Jul 12 20:27:27 2026 +0300

    feat: кэшировать расчёт скидки

 src/pricing.py | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)
bisect found first bad commit

Тонкости, которые решают исход

  • git bisect skip удобен, но не бесплатен: пропущенные коммиты остаются в множестве кандидатов как «неизвестные», и в худшем случае Git закончит не одним коммитом, а диапазоном («The first bad commit could be any of…»). Лечится сборочным скриптом, который умеет собирать больше коммитов, а не более щедрым skip.
  • git bisect start --first-parent (Git 2.29+) бисектит только по стволу, считая merge-коммит атомарной единицей. Незаменимо, когда внутри влитых веток промежуточные коммиты не собираются, — а это норма для команд, где squash-мерж не принят.
  • Ограничение путями: git bisect start HEAD v2.3.0 -- src/billing/ сузит множество кандидатов до коммитов, трогавших каталог. Осторожно: если поломка на самом деле в другом месте, поиск честно приведёт не туда.
  • Инвертированный поиск. bisect не привязан к «поломке». Термины переименовываются, и та же машина ищет коммит, где баг починили: git bisect start --term-old broken --term-new fixed, дальше git bisect broken / git bisect fixed.
  • Журнал и воспроизведение. git bisect log > bisect.log сохраняет всю сессию; git bisect replay bisect.log повторяет её. Это же — способ передать расследование коллеге и способ откатить ошибочный ответ good/bad, не начиная заново.
  • Что делать с «нечистыми» коммитами. Если половина истории не собирается, бисекция превращается в мучение. Это не проблема Git, а обратная связь о качестве истории: см. рекомендации про rebase --exec в «Merge и rebase» и про режимы слияния в «Совместной работе».

Бисекция по метрике, а не по тесту (например, «когда эндпоинт стал медленнее 200 мс») — тот же bisect run, только «тест» сравнивает результат бенчмарка с порогом. Подробный разбор с примерами — в «Рабочем процессе оптимизации».

Сквозной сценарий: от алерта до коммита

Соберём инструменты в один рабочий процесс. Ситуация: с 3 июля мониторинг показывает, что у 2% заказов сумма со скидкой отличается от ожидаемой на копейку.

# 1. Сузить окно: что вообще приехало в прод между релизами
$ git log --oneline --first-parent v2.3.0..v2.4.0 | wc -l
38

# 2. Нащупать область: кто трогал расчёт денег
$ git log --oneline --full-history v2.3.0..v2.4.0 -- src/pricing.py src/billing/
7c0a5f2 fix: округление скидки
dac7d6e feat: кэшировать расчёт скидки
4d1e8b7 refactor: вынести округление в утилиту

# 3. Проверить гипотезу «поменяли способ округления»
$ git log -S 'round(' --pickaxe-all -p v2.3.0..v2.4.0 -- src/
# видно: round(x, 2) заменили на Decimal.quantize с ROUND_HALF_EVEN

# 4. Подтвердить механически, а не рассуждениями
$ cat > /tmp/check.sh <<'EOF'
#!/bin/sh
make -s build 2>/dev/null || exit 125
pytest -q tests/test_pricing.py::test_discount_rounding >/dev/null 2>&1
EOF
$ chmod +x /tmp/check.sh
$ git bisect start v2.4.0 v2.3.0
$ git bisect run /tmp/check.sh
dac7d6e9fd3b15f262a319502a93298420200829 is the first bad commit

# 5. Прочитать коммит целиком и понять намерение
$ git show dac7d6e
$ git log -1 --format='%an <%ae>%n%n%B' dac7d6e

# 6. Понять радиус: в каких релизах и ветках эта ошибка уже есть
$ git describe --contains dac7d6e
v2.4.0~18^2~3
$ git branch -r --contains dac7d6e
  origin/main
  origin/release/2.4

# 7. Точечный откат вместо отката всего релиза
$ git revert dac7d6e

Обратите внимание на порядок: сначала сузили область дешёвыми командами, потом потратили дорогую бисекцию на маленький диапазон. Обратный порядок (сразу bisect по всей истории) тоже сработает, но обойдётся в лишние прогоны тестов.

Горячие точки: чтение истории как данных

История — это ещё и датасет. Два запроса из неё дают больше, чем неделя разговоров о техдолге.

Частота изменений (churn). Файл, который правят каждую неделю, — это либо сердце продукта, либо место, где никто не может сделать правильно с первого раза. В сочетании с низким покрытием тестами это лучший кандидат на рефакторинг.

# топ-15 самых часто изменяемых файлов за год
git log --since=1.year --format= --name-only --no-merges \
  | grep -v '^$' | sort | uniq -c | sort -rn | head -15

# сколько людей трогало файл — прокси «размытого владения»
git log --format='%an' --no-merges -- src/pricing.py | sort -u | wc -l

# кто вообще пишет в этот каталог (после нормализации имён через .mailmap)
git shortlog -sn --no-merges -- src/billing/

Логическая связанность (temporal coupling). Два файла, которые почти всегда меняются в одном коммите, связаны — даже если в коде между ними нет ни одной ссылки. Это самый честный детектор «дырявой» абстракции.

#!/usr/bin/env python3
"""Логическая связанность файлов: какие пары меняются вместе чаще всего.

Запуск: python3 coupling.py [дней] [минимум-совместных-изменений]
Сложность: O(K * F^2) по времени (K коммитов, F файлов в коммите),
O(P) по памяти, где P — число встретившихся пар.
"""
from __future__ import annotations

import subprocess
import sys
from collections import Counter
from itertools import combinations

days = sys.argv[1] if len(sys.argv) > 1 else "365"
min_pair = int(sys.argv[2]) if len(sys.argv) > 2 else 5

raw = subprocess.run(
    ["git", "log", f"--since={days}.days", "--format=%H", "--name-only", "--no-merges"],
    capture_output=True, text=True, check=True,
).stdout

pairs: Counter[tuple[str, str]] = Counter()
singles: Counter[str] = Counter()

for block in raw.split("\n\n"):
    lines = [ln for ln in block.splitlines() if ln.strip()]
    files = sorted(set(lines[1:]))          # первая строка — хеш коммита
    if not 2 <= len(files) <= 30:           # мега-коммиты только шумят
        continue
    for f in files:
        singles[f] += 1
    for a, b in combinations(files, 2):
        pairs[(a, b)] += 1

print(f"{'вместе':>7} {'связь':>6}  файлы")
for (a, b), n in pairs.most_common(200):
    if n < min_pair:
        break
    # доля совместных изменений от изменений более «редкого» файла
    ratio = n / min(singles[a], singles[b])
    if ratio >= 0.5:
        print(f"{n:>7} {ratio:>6.0%}  {a}  <->  {b}")

Вывод вида «src/pricing.py <-> src/billing/invoice.py — 23 раза вместе, 88%» означает ровно одно: контракт между этими модулями протекает, и любое изменение цены требует правки счёта. Методика подробно разобрана у Адама Торнхилла в «Your Code as a Crime Scene»; открытая реализация — code-maat.

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

История, которую можно расследовать

Все инструменты выше работают ровно настолько хорошо, насколько хороша история. Пять свойств, которые превращают репозиторий в пригодную для расследования базу:

  1. Атомарные коммиты. Один коммит — одно логическое изменение. Иначе bisect укажет на «коммит-монолит», и расследование начнётся заново, уже внутри диффа на 2000 строк.
  2. Каждый коммит собирается. Иначе bisect вырождается в череду skip и заканчивается диапазоном вместо ответа. Обеспечивается git rebase --exec 'make test' перед публикацией.
  3. Сообщение отвечает на «почему». «Что» видно из диффа; в теле коммита ценно только то, чего в диффе нет: причина, отвергнутые альтернативы, ссылка на инцидент. Подробно — в «Совместной работе».
  4. Реформатирование — отдельным коммитом и в .git-blame-ignore-revs. Иначе blame показывает автора автоформаттера.
  5. .mailmap в корне. Один человек с тремя адресами превращает любую статистику в мусор; правится файлом, а не переписыванием истории (см. «Переписывание истории»).

Типичные ошибки

  • Читать git log -- path без --full-history при расследовании. Коммиты боковых веток не показываются, и «фикс потерялся» превращается в «фикса не было». Самая дорогая ошибка из списка.
  • Начинать с blame, когда есть воспроизводимый баг. blame покажет последнего, кто трогал строку; сломать могло другое место. bisect отвечает на нужный вопрос и не требует гипотез.
  • Искать grep‘ом по рабочему каталогу то, чего в нём уже нет. Удалённая строка живёт только в истории — это git log -S.
  • Класть bisect-скрипт в репозиторий. Первый же checkout на старый коммит его заменит или удалит, и сессия сломается непредсказуемо.
  • Считать плохим коммит, который просто не собирается. Для этого есть код 125; без него в «первый плохой» попадёт случайный сосед.
  • Бисектить флейки-тест. Один прогон — одна случайная метка good/bad, и результат бесполезен. Прогонять тест несколько раз или чинить тест до бисекции.
  • Забыть git bisect reset. Останетесь в detached HEAD и через час будете писать в трекер, что «Git потерял мою ветку». Ветка на месте — см. «Восстановление».
  • Использовать blame как аргумент в разговоре о людях. Технически он показывает последнее касание, а не причину; социально — разрушает то самое доверие, на котором держится ревью.

Мини-итог

  • Три вопроса — три инструмента: «когда изменилось содержимое» → pickaxe (-S/-G), «кто трогал строку» → blame, «какой коммит сломал» → bisect.
  • git log с путём по умолчанию показывает упрощённую историю: коммиты боковых веток, чьё содержимое не дошло до результата, исчезают. Для расследования — --full-history, лучше вместе с --simplify-merges.
  • -S ловит изменение числа вхождений (появилось/исчезло), -G — любое упоминание в тексте диффа, включая перемещения. --pickaxe-all показывает коммит целиком, --find-object ищет по хешу блоба.
  • git log -L :функция:файл даёт полную эволюцию одной функции — то, для чего обычно вручную листают историю файла.
  • blame без -w -C систематически врёт на реформатах и переездах кода; .git-blame-ignore-revs в корне репозитория чинит это для всей команды разом. --reverse отвечает на вопрос «когда строку удалили».
  • bisect — бинарный поиск не по списку, а по DAG: O(log₂ N) проверок, выбор кандидата считается через git rev-list --bisect-vars.
  • Коды возврата bisect run: 0 — хороший, 1..127 кроме 125 — плохой, 125 — непроверяемый, ≥128 — прервать. Скрипт держать вне репозитория.
  • Эффективность всех инструментов задаётся качеством истории: атомарные собирающиеся коммиты, отдельные коммиты реформата, .mailmap, осмысленные сообщения.
  • История — ещё и датасет: churn и логическая связанность файлов показывают точки техдолга объективнее любых опросов команды.

Источники

Что дальше

Мы научились задавать истории вопросы: сузить диапазон, найти строку, назначить строке автора, вычислить сломавший коммит за логарифм проверок. Всё это происходило в одном репозитории — вашем локальном. Осталась последняя большая тема трека: что происходит, когда репозиториев два и между ними сеть. Почему push отклоняется с «fetch first», что такое refspec и почему git push origin :ветка её удаляет, как выглядит на проводе диалог want/have, чем --force-with-lease отличается от --force-if-includes и как перенести репозиторий туда, где интернета нет вообще.

Удалённые репозитории и протокол: refspec, fetch, push, транспорт

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов