История потерянного коммита
Был уже вечер, когда ко мне обратился разработчик. Из мастер-ветки пропал патч — коммит deadbeef.
Мне показали доказательства: вывод двух команд. Первая из них —
git show deadbeef
— показывала изменения файла, назовём его Page.php. В него добавились метод canBeEdited и его использование.
А в выводе второй команды —
git log -p Page.php
— коммита deadbeef не было. Да и в текущей версии файла Page.php не было метода canBeEdited.
Не найдя решения быстро, мы сделали ещё один патч в мастер, разложили изменения — и я решил, что вернусь к проблеме на свежую голову.
Я старался сделать этот текст понятным людям, не очень хорошо знакомым с Git. Если вы его основной контрибьютор или знаете об этой системе достаточно для написания собственной книги, возможно, некоторые части статьи могут показаться вам очевидными.
Это сделали специально? Файл переименовали?
Поиск проблемы я начал с обращения за помощью в чат команды релиз-инженеров. Они среди прочего отвечают за хостинг репозиториев и автоматизацию процессов, связанных с Git. Честно говоря, они, наверное, и патч могли удалить, но сделали бы это без следов.
Один из релиз-инженеров предложил запустить git log с опцией --follow. Возможно, файл переименовали и поэтому Git не показывает часть изменений.
--follow
Continue listing the history of a file beyond renames (works only for a single file).
(Показывать историю файла после его переименований (работает только для одиночных файлов))
В выводе git log --follow Page.php
нашёлся deadbeef, но удалений или переименований файла не было. А ещё не было видно, чтобы где-то удалялся метод canBeEdited. Казалось, что опция follow играет какую-то роль в этой истории, но куда делись изменения, все ещё было неясно.
К сожалению, рассматриваемый репозиторий — один из самых больших у нас. С момента внесения первого патча и до его исчезновения была совершена 21 000 коммитов. Повезло ещё, что нужный файл правился только в десяти из них. Я изучил их все и не нашёл ничего интересного.
Ищем свидетелей! Нам нужен livebear
Стоп! Мы же только что искали deadbeef? Давайте рассуждать логически: должен быть некий коммит, назовём его livebear, после которого deadbeef перестал отображаться в истории файла. Возможно, это нам ничего не даст, но натолкнёт на какие-то мысли.
Для поиска в истории Git есть команда git bisect. Согласно документации, она позволяет найти коммит, в котором впервые появился баг. На практике её можно использовать для поиска любого момента в истории, если знать, как определить, наступил ли этот момент. Нашим багом было отсутствие изменений в коде. Я мог это проверить с помощью другой команды — git grep. Ведь мне достаточно было знать, есть ли метод canBeEdited в Page.php. Немного отладки и чтения документации:
livebear [build]: Merge branch origin/XXX into build_web_yyyy.mm.dd.hh
Выглядит как обычное слияние (merge commit) ветки задачи с веткой релиза. Но с этим коммитом удалось воспроизвести проблему:
$ git checkout -b test livebear^1 2>/dev/null
$ grep -c canBeEdited Page.php
2
$ git merge —-no-edit -—no-stat livebear^2
Removing …
…
Removing …
Merge made by the ‘recursive’ strategy.
$ grep -c canBeEdited Page.php
0
$ git log -p Page.php | grep -c canBeEdited
0
Правда, ничего интересного в livebear я не нашёл, а его связь с нашей проблемой осталась неочевидна. Подумав немного, я отправил результаты своих поисков разработчику: мы сошлись на том, что, даже если доберёмся до истины, схема воспроизведения будет слишком сложной и мы не сможем подстраховаться от чего-то подобного в будущем. Поэтому официально мы решили прекратить поиски.
Однако моё любопытство осталось неудовлетворённым.
Упорство не порок, а большое свинство
Ещё несколько раз я возвращался к проблеме, прогонял git bisect и находил всё новые и новые коммиты. Все — подозрительные, все — слияния, но это ничего мне не дало. Мне кажется, что один коммит тогда попадался мне чаще других, но я не уверен, что именно он оказался виновником в итоге.
Конечно, я пробовал и другие методы поиска. Например, несколько раз перебирал 21 000 коммитов, которые были сделаны на момент возникновения проблемы. Это было не очень увлекательно, но мне попалась интересная закономерность. Я запускал одну и ту же команду:
git grep -c canBeEdited {commit} -- Page.php
Оказалось, что «плохие» коммиты, в которых не было нужного кода, были в одной и той же ветке! И поиск по этой ветке быстро привёл меня к разгадке:
changekiller Merge branch 'master' into TICKET-XXX_description
Это тоже было слияние двух веток. И при попытке повторить его локально возникал конфликт в нужном файле — Page.php. Судя по состоянию репозитория, разработчик оставил свою версию файла, выбросив изменения из мастера (а именно они и потерялись). Прошло много времени, и разработчик не помнил, что именно произошло, но на практике ситуация воспроизводилась простой последовательностью:
git checkout -b test changekiller^1
git merge -s ours changekiller^2
Осталось понять, как легитимная последовательность действий могла привести к такому результату. Не найдя ничего про это в документации, я полез в исходники.
Убийца — Git?
В документации было сказано, что команда git log получает на вход несколько коммитов и должна показать пользователю их родительские коммиты, исключая родителей коммитов, переданных с символом ^ перед ними. Выходит, что git log A ^B должен показать коммиты, которые являются родителями A и не являются родителями B.
Код команды оказался достаточно сложным. Там в изобилии были разные оптимизации для работы с памятью, да и в целом читать код на С никогда не казалось мне очень приятным занятием. Основную логику можно представить вот таким псевдокодом:
// здесь это и тип, и название переменной
commit commit;
rev_info revs;
revs = setup_revisions(revisions_range);
while (commit = get_revision(revs)) {
log_tree_commit(commit);
}
Здесь функция get_revision принимает на вход revs — набор управляющих флагов. Каждый её вызов как будто должен отдавать следующий коммит для обработки в нужном порядке (или пустоту, когда мы дошли до конца). Ещё есть функция setup_revisions, которая заполняет структуру revs и log_tree_commit, которая выводит информацию на экран.
У меня было ощущение, что я понял, где искать проблему. Я передавал команде конкретный файл (Page.php), потому что меня интересовали только его изменения. Значит, в git log должна быть какая-то логика фильтрации «лишних» коммитов. Функции setup_revisions и get_revision использовались во многих местах — вряд ли проблема была в них. Оставалась log_tree_commit.
К моей несказанной радости, в этой функции и правда нашёлся код, вычисляющий, какие изменения были сделаны в том или ином коммите. Я думал, что общая логика должна выглядеть как-то так:
void log_tree_commit(commit) {
if (tree_has_changed(commit, commit->parents)) {
log_tree_commit_1(commit);
}
}
Но чем дольше я всматривался в настоящий код, тем больше понимал, что ошибся. Эта функция лишь выводила сообщения. Вот и верь после этого своим ощущениям!
Я вернулся к функциям setup_revisions и get_revision. Логику их работы было сложно понять — мешал «туман» из вспомогательных функций, часть из которых нужна была для правильной работы с указателями и памятью. Всё выглядело так, словно основная логика — это простой обход дерева коммитов «в ширину», то есть достаточно стандартный алгоритм:
rev_info setup_revisions(revisions_range, ...) {
rev_info rev;
commit commit;
// этой функции в реальном коде нет — это моё упрощение
for (commit = get_commit_from_range(revisions_range)) {
revs->commits = commit_list_append(commit, revs->commits)
}
}
commit get_revision(rev_info revs) {
commit c;
commit l;
c = get_revision_1(revs);
for (l = c->parents; l; l = l->next) {
commit_list_insert(l, &revs->commits);
}
return c;
}
commit get_revision_1(rev_info revs) {
return pop_commit(revs->commits);
}
Заводится список (revs→commits), туда помещается первый (самый верхний) элемент дерева коммитов. Затем постепенно из этого списка забираются коммиты с начала, а их родители добавляются в конец.
Вчитываясь в код, я обнаружил, что среди «тумана» из вспомогательных функций встречается сложная логика фильтрации коммитов, которую я так долго искал. Это происходит в функции get_revision_1:
commit get_revision_1(rev_info revs) {
commit commit;
commit = pop_commit(revs->commits);
try_to_sipmlify_commit(commit);
return commit;
}
void try_to_simplify_commit(commit commit) {
for (parent = commit->parents; parent; parent = parent->next) {
if (rev_compare_tree(revs, parent, commit) == REV_TREE_SAME) {
parent->next = NULL;
commit->parents = parent;
}
}
}
В случае когда происходит слияние нескольких веток, если состояние файла осталось таким же, как в одной из них, нет смысла рассматривать другие ветки. Если же состояние файла не менялось нигде, мы оставим только первую ветку.
Пример. Обозначим нулём коммиты, в которых файл не менялся, единицей — те, в которых файл изменился, и X — слияние веток.
В этой ситуации код не станет рассматривать ветку feature — в ней и изменений нет. Если файл там всё-таки изменили, то в X изменения «выкинули», а значит, их история не очень релевантна: этого кода уже нет.
Что-то похожее произошло и у нас. Два разработчика сделали изменения в одном файле — Page.php, один — в ветке мастера, в коммите deadbeef, второй — в ветке своей задачи.
Когда второй разработчик сливал изменения из ветки мастера в ветку задачи, произошёл конфликт, в процессе разрешения которого изменения из мастера он просто выбросил. Прошло время, работу над задачей он завершил, и ветку задачи залили в мастер, удалив таким образом изменения из коммита deadbeef.
Сам коммит при этом остался. Но если запустить git log с параметром Page.php, коммита deadbeef в выводе видно не будет.
Оптимизация — дело неблагодарное
Я бросился внимательно изучать правила отправки изменений и багов в сам Git. Ведь я думал, что нашёл действительно серьёзную проблему: подумать только, часть коммитов просто пропадает из вывода — и это поведение по умолчанию! К счастью, правила оказались объёмными, время было позднее, а на следующее утро мой запал улетучился.
Я понял, что эта оптимизация сильно ускоряет работу Git на больших репозиториях, таких как наш. А ещё для неё нашлась документация в man git-rev-list, и это поведение можно очень легко отключить.
Кстати, а как в этой истории замешана --follow?
На самом деле, есть много способов повлиять на работу этой логики. Конкретно про флаг follow в коде Git нашёлся комментарий 13-летней давности:
Can’t prune commits with rename following: the paths change.
(Перевод: Не получится выбрасывать коммиты, когда обрабатываются переименования: пути могут меняться)
P. S.
Сам я работаю в команде релиз-инженеров Badoo уже несколько лет, и многие в компании считают, что мы разбираемся в Git.
(Перевод. Оригинал: xkcd.com/1597)
В связи с этим нам приходится разбираться с проблемами, возникающими в этой системе, и некоторые из них мне кажутся достаточно любопытными — как, например, описанная в этой статье. Очень часто проблемы решаются быстро: со многим мы уже сталкивались, что-то хорошо описано в документации. Этот случай был исключением.
На самом деле, в документации действительно был раздел History Simplification, но он был только для команды git rev-list и заглянуть туда я не догадался. Полгода назад этот раздел включили и в мануал команды git log, но наш случай произошёл несколько раньше — я просто не успевал дописать эту статью.
И напоследок у меня остался небольшой бонус для тех, кто дочитал до конца. У меня есть очень маленький репозиторий, где проблема воспроизводится:
$ git clone https://github.com/Md-Cake/lost-changes.git
Cloning into 'lost-changes'...
…
$ git log --oneline test.php
edfd6a4 master: print 3 between 1 and 2
096d4cf init
$ git log --oneline --full-history test.php
afea493 (HEAD -> master, origin/master, origin/HEAD) Merge branch 'changekiller'
57041b8 (origin/changekiller) print 4 between 1 and 2
edfd6a4 master: print 3 between 1 and 2
096d4cf init
Спасибо за внимание!