Кто виноват? Ищем виновника с помощью git blame
git blame: как читать вывод, ограничивать строки с -L, -w, -M, -C, пропускать массовые коммиты и найти реальное изменение строки.
git blame снабжает каждую строку файла аннотацией: коммитом, который последним её изменил, а также автором и датой этого коммита.
Коммит, который он называет, часто оказывается не тем. Вы смотрите на странную строку в коде, который писали не вы, и blame выдаёт вам коммит «apply prettier» на 4000 файлов восемнадцатимесячной давности.
Такой тупик — нормальный результат работы голого git blame, и умение выбраться из него и есть настоящий навык. В этой статье рассматривается, как читать вывод по умолчанию, как сузить и очистить его от шума с помощью -L, -w, -M и -C, как двигаться назад по родительским коммитам, пока не доберётесь до действительно значимого изменения, а также два недавних нововведения: --diff-algorithm (Git 2.53 и новее) и git last-modified (Git 2.52 и новее).
Ключевые выводы
- Коммит, который git blame показывает для строки, — это последний коммит, затронувший её, и часто это переформатирование, переименование или перемещение, а не то изменение, которое придало строке смысл.
- Повторный запуск blame на родителе найденного коммита (
git blame <hash>^ -- file) с последующим повторением — надёжный способ добраться до исходного изменения;--ignore-revи--ignore-revs-fileавтоматически пропускают известные «шумные» коммиты. -wигнорирует пробельные символы,-Mотслеживает строки, перемещённые внутри файла (порог по умолчанию — 20 буквенно-цифровых символов), а-Cотслеживает строки, скопированные из других файлов (по умолчанию 40); до трёх флагов-Cрасширяют область поиска.- В Git 2.53 в git blame добавлен
--diff-algorithm, принимающий значенияpatience,minimal,histogramилиmyers, причёмmyersиспользуется по умолчанию. - В Git 2.52 появилась экспериментальная команда
git last-modified, которая за один обход истории сообщает последний коммит, затронувший каждый путь в каталоге.
Как читать вывод git blame по умолчанию?
Каждая строка вывода git blame по умолчанию содержит четыре поля в следующем порядке: сокращённый хеш коммита, имя автора, дату автора и номер строки, после которых идёт содержимое самой строки. Эти поля перечислены в разделе о формате по умолчанию man-страницы; Git по умолчанию сокращает хеш до семи шестнадцатеричных цифр и оставляет ещё одну колонку свободной под символ ^, отмечающий граничный коммит (самые старые коммиты, до которых blame смог добраться). Даты выводятся в формате ISO, если --date или blame.date не указывают иное.
git blame src/router.js
a1b2c3d4 (Jane Doe 2024-03-08 14:22:31 +0100 42) return cache.get(key) ?? fetchRoute(key);
Читаем слева направо: a1b2c3d4 — коммит, Jane Doe и временная метка — идентификатор автора этого коммита, 42 — номер строки в текущем файле, а всё после закрывающей скобки — сама строка.
Важно понимать, чем этот хеш не является. Это не тот коммит, который ввёл данную логику. Это самый недавний коммит, чей diff затронул строку, а в кодовой базе с форматтерами, линтерами и рефакторингами это часто механическое изменение. Относитесь к первому результату blame как к зацепке, а не как к приговору.
Как ограничить git blame диапазоном строк с помощью -L?
git blame -L 40,60 -- src/router.js ограничивает аннотацию строками с 40 по 60, а git blame -L :handleRoute -- src/router.js — телом функции, имя которой соответствует указанному регулярному выражению. Обе формы описаны в разделе про опцию -L, которую можно указывать несколько раз.
git blame -L 40,60 -- src/router.js
git blame -L :handleRoute -- src/router.js
Форма :funcname не разбирает ваш язык. Она распознаёт имена функций тем же способом, каким git diff определяет, что выводить в заголовке ханка, и это поведение можно настроить для каждого типа файлов через атрибут diff в gitattributes. Обе границы диапазона также принимают шаблоны /regex/, а конечная граница принимает смещения +N, так что -L '/^function handleRoute/,+15' — тоже корректная запись.
Как заставить git blame игнорировать пробелы и перемещённый код?
Передача -w заставляет git blame игнорировать пробельные символы при сравнении версий, так что переформатирование, затронувшее только отступы, больше не «присваивает» себе изменённые строки. -M отслеживает строки, сдвинувшиеся внутри одного файла, а -C расширяет поиск на строки, пришедшие из других файлов, изменённых тем же коммитом; man-страница указывает их пороги совпадения по умолчанию — 20 и 40 буквенно-цифровых символов соответственно.
| Симптом | Флаг |
|---|---|
| Строка приписана коммиту с изменением отступов или удалением концевых пробелов | -w |
| Строка приписана коммиту, который переупорядочил код внутри файла | -M |
| Строка появилась в результате копирования или перемещения из другого файла | -C (можно указывать несколько раз) |
| Строка приписана известному массовому коммиту | --ignore-rev <hash> |
git blame -w -- src/router.js
git blame -M -- src/router.js
git blame -C -C -C -- src/router.js
Каждый дополнительный -C расширяет круг файлов, в которых git blame ищет скопированные строки:
-Cищет в других файлах, изменённых тем же коммитом.-C -Cдополнительно ищет в файлах, затронутых коммитом, который впервые добавил этот файл.-C -C -Cрасширяет поиск ещё раз — на файлы из любого коммита.
Если несколько флагов -C содержат числовой порог, действует последний из них. Для переименования файла целиком флаг вообще не нужен: blame сам продолжает отслеживать строки через переименование, и Git в настоящее время не даёт способа отключить это поведение.
Как найти коммит, предшествовавший массовому переформатированию?
Чтобы пройти дальше механического коммита, запустите blame заново на его родителе — git blame <hash>^ -- src/router.js — и повторяйте, пока показанный коммит не окажется тем, который действительно изменил поведение строки. Суффикс ^ — это стандартный синтаксис gitrevisions для первого родителя, так что blame стартует с состояния файла непосредственно перед тем, как приземлился шумный коммит.
- Выполните
git blame -L 40,60 -- src/router.jsи запомните хеш на интересующей строке. - Проверьте коммит командой
git show --stat <hash>. Если это переформатирование, переименование или перемещение — продолжайте. - Выполните
git blame -n <hash>^ -L 40,60 -- src/router.js. Флаг-nпечатает номер каждой строки в исходном коммите, а это важно, потому что номера строк смещаются между ревизиями и на следующем проходе-L, возможно, придётся перенацелить. - Повторяйте с шага 2, пока показанный коммит не окажется тем, который меняет поведение строки.
git blame -n a1b2c3d4^ -L 40,60 -- src/router.js
Если в репозитории есть известные шумные коммиты, ручной обход можно пропустить. --ignore-rev <hash> указывает git blame приписывать строки коммитам до указанного, а --ignore-revs-file делает то же самое для целого файла с хешами, записанными полностью, по одному на строку. Задайте blame.markIgnoredLines, чтобы помечать переназначенные строки символом ?, и blame.markUnblamableLines, чтобы помечать символом * строки, которые переназначить не удалось.
git blame --ignore-rev a1b2c3d4 -- src/router.js
git blame --ignore-revs-file .git-blame-ignore-revs -- src/router.js
git config blame.markIgnoredLines true
Как закоммитить такой список под именем .git-blame-ignore-revs и указать на него в blame.ignoreRevsFile, рассказано в статье 5 Git Dotfiles Every Developer Should Know.
Пробуем другой алгоритм diff (Git 2.53 и новее)
В Git 2.53 в git blame добавлена опция --diff-algorithm, принимающая значения patience, minimal, histogram или myers (при этом default является псевдонимом для myers), и myers используется по умолчанию. Это нововведение упомянуто в примечаниях к выпуску Git 2.53, а допустимые значения перечислены в разделе про опцию —diff-algorithm на man-странице.
Blame определяет, каким строкам потомка соответствуют строки родителя, сравнивая две версии, и разные алгоритмы сопоставляют строки по-разному. Когда коммит чередует изменённые и неизменённые строки — а переформатирования часто так и делают, — один алгоритм может приписать строку переформатированию, а другой — коммиту, который изначально её написал.
git blame -L 40,60 -- src/router.js
git blame -L 40,60 --diff-algorithm=patience -- src/router.js
Ни один алгоритм в документации не назван более правильным, чем остальные. Если атрибуция по умолчанию выглядит неправдоподобно, запуск той же команды с patience или histogram стоит одного дополнительного вызова и даёт второе мнение для сравнения.
Запрос по каталогу с помощью git last-modified
В Git 2.52 появилась команда git last-modified, которая за один обход истории сообщает, какой коммит последним изменил каждый путь в каталоге, вместо отдельного git log -1 на каждый файл; команда помечена как экспериментальная, и её поведение может измениться. Man-страница git-last-modified указывает экспериментальный статус в строке NAME и показывает формат вывода как <oid> TAB <path>, по одной строке на путь, с полным идентификатором объекта и без автора, даты и темы коммита.
git last-modified -r -- src/
Без -r (или ненулевого --max-depth) вы получите только записи, соответствующие самому pathspec, без спуска в расположенные ниже подкаталоги. Переименования и изменения режима файла считаются модификациями. Цикл по файлам, который она заменяет, заново обходит одни и те же коммиты для каждого файла; last-modified обходит их один раз. Она отвечает на вопрос «что недавно изменилось в этом модуле» — это другой вопрос, нежели «почему эта строка существует», — и к ней стоит обратиться до того, как начинать разбирать отдельные файлы с помощью blame.
Blame — это вопрос, а не приговор
Вывод git blame называет последнего человека, тронувшего строку, и это имя почти никогда не является нужным вам ответом. Запускайте blame с -L, чтобы сфокусироваться, с -w и -M/-C, чтобы убрать механический шум, а затем шагайте назад по родителям (или ведите ignore-файл), пока показанный коммит не будет содержать сообщение, объясняющее строку. Как только такой коммит найден, git show <hash> даст вам diff и обоснование — а в этом и состоит смысл всего упражнения: понять, зачем этот код здесь, чтобы изменить его, не повторив тот инцидент, который изначально его породил.
Часто задаваемые вопросы
Как узнать, кто удалил строку, если git blame показывает только существующие строки?
git blame ничего не сообщает о строках, которые были удалены или перезаписаны, — об этом сказано на его man-странице. Вместо него используйте «кайло» (pickaxe): git log -S'some text' -- src/router.js перечислит все коммиты, добавившие или удалившие эту строку, а добавление -p покажет и само удаление. Как вариант, git blame --reverse a1b2c3d..HEAD -- src/router.js проходит историю вперёд от указанного коммита и называет самую свежую ревизию, в которой каждая строка ещё присутствовала.
Почему git blame показывает 00000000 и 'Not Committed Yet' на некоторых строках?
Эти строки содержат незакоммиченные изменения. Без аргумента-ревизии git blame аннотирует копию файла из рабочего дерева, поэтому любая строка, отличающаяся от HEAD, получает нулевой хеш и 'Not Committed Yet' вместо имени автора. Закоммитьте или отложите изменение через stash, либо выполните git blame HEAD -- src/router.js, чтобы аннотировать закоммиченную версию и полностью проигнорировать локальные правки.
Учитывает ли режим blame на GitHub файл .git-blame-ignore-revs?
Да. GitHub автоматически применяет файл с именем .git-blame-ignore-revs из корня репозитория в своём представлении blame, используя тот же механизм --ignore-revs-file, что и командная строка, и показывает при этом баннер 'Ignoring revisions'. Строки, которые не удалось переназначить более раннему коммиту, по-прежнему показывают игнорируемый коммит. Файл не настраивает локальный git; каждому разработчику всё равно нужно выполнить git config blame.ignoreRevsFile .git-blame-ignore-revs.
В чём разница между git blame и git log -L?
git blame сообщает по одному коммиту на строку: самый недавний коммит, затронувший её в одной конкретной версии файла. git log -L 40,60:src/router.js, напротив, отслеживает строки с 40 по 60 через всю историю и выводит каждый коммит, который их изменял, вместе с diff по этому диапазону, начиная с самых новых. Используйте blame, чтобы быстро определить подозреваемого, и log -L, чтобы проследить эволюцию строк. Обе команды принимают форму :funcname, например git log -L :handleRoute:src/router.js.