Расследование по истории: 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.
признак поломки" --> B["git bisect run
log2(N) проверок"] Q1 -- "Известна строка/символ
в коде" --> Q2{"Строка есть сейчас
или уже исчезла?"} Q1 -- "Известен файл целиком" --> L["git log --full-history -- путь
git log --follow"] Q2 -- "есть в HEAD" --> BL["git blame -w -C -L
затем git show найденного SHA"] Q2 -- "исчезла" --> P["git log -S 'строка'
git log -G 'регулярка'"] B --> R["Коммит найден"] BL --> R P --> R L --> R R --> W["git show, git log --ancestry-path,
git describe --contains: в какой релиз попало"]
Язык вопросов: выбор ревизий
Прежде чем искать, надо уметь называть множество коммитов, в котором ищем. Синтаксис описан в 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 -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 |
коммит хороший |
1–127, кроме 125 |
коммит плохой |
125 |
коммит непроверяем (не собирается) — эквивалент git bisect skip |
128–255 |
прервать бисекцию с ошибкой |
Правильный скрипт учитывает четыре вещи, о которых забывают: чистую сборку, обновление подмодулей, флейки-тесты и то, что сам скрипт не должен лежать в проверяемом дереве — 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.
Важная оговорка: эти метрики — про код, а не про людей. «Кто больше коммитил» не измеряет вклад; в разборе метрик разработки (см. «Совместную работу») это классический пример показателя, который ломается ровно в тот день, когда его начинают измерять.
История, которую можно расследовать
Все инструменты выше работают ровно настолько хорошо, насколько хороша история. Пять свойств, которые превращают репозиторий в пригодную для расследования базу:
- Атомарные коммиты. Один коммит — одно логическое изменение. Иначе
bisectукажет на «коммит-монолит», и расследование начнётся заново, уже внутри диффа на 2000 строк. - Каждый коммит собирается. Иначе
bisectвырождается в чередуskipи заканчивается диапазоном вместо ответа. Обеспечиваетсяgit rebase --exec 'make test'перед публикацией. - Сообщение отвечает на «почему». «Что» видно из диффа; в теле коммита ценно только то, чего в диффе нет: причина, отвергнутые альтернативы, ссылка на инцидент. Подробно — в «Совместной работе».
- Реформатирование — отдельным коммитом и в
.git-blame-ignore-revs. Иначе blame показывает автора автоформаттера. .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 и логическая связанность файлов показывают точки техдолга объективнее любых опросов команды.
Источники
- Pro Git, глава «Debugging with Git» —
blameиbisectв каноническом изложении. - Документация: git-log(1) (обязательно раздел «History Simplification»), gitrevisions(7), git-blame(1), git-bisect(1), git-grep(1), git-rev-list(1), git-shortlog(1).
- Christian Couder, «Fighting regressions with git bisect» — доклад с Linux Kernel Summit 2009, лучший разбор алгоритма выбора кандидата.
- Bisecting a bug — инструкция сообщества ядра Linux, где бисекция ежедневная рутина.
- Derrick Stolee, «Supercharging the Git Commit Graph IV: Bloom Filters» — почему
git log -- pathможет быть быстрым. - Adam Tornhill, «Your Code as a Crime Scene» и code-maat — анализ истории как данных: горячие точки, связанность, размытое владение.
- На портале: модель данных — «Внутри Git»; потерянные коммиты и reflog — «Восстановление»; бисекция по бенчмарку — «Рабочий процесс оптимизации»; работа со старым кодом — «Легаси-код».
Что дальше
Мы научились задавать истории вопросы: сузить диапазон, найти строку, назначить строке автора, вычислить сломавший коммит за логарифм проверок. Всё это происходило в одном репозитории — вашем локальном. Осталась последняя большая тема трека: что происходит, когда репозиториев два и между ними сеть. Почему push отклоняется с «fetch first», что такое refspec и почему git push origin :ветка её удаляет, как выглядит на проводе диалог want/have, чем --force-with-lease отличается от --force-if-includes и как перенести репозиторий туда, где интернета нет вообще.
Удалённые репозитории и протокол: refspec, fetch, push, транспорт