История потерянного коммита: как разгадать эту загадку

Был уже вечер, когда со мной связался разработчик. Патч фиксации deadbeef исчез из основной ветки.

Они показали мне доказательства: результат двух команд. Первый был:

git show deadbeef

Это показало изменения в файле: назовем его Page.php. К нему был добавлен метод canBeEdited и его использование.

И вывод второй команды

git log -p Page.php

не содержал фиксации deadbeef. Текущая версия файла Page.php также не имеет метода canBeEdited.

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

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

Кто-то сделал это специально? Неужели файл был переименован?

Моей отправной точкой в ​​поиске проблемы был запрос о помощи, опубликованный в чате группы разработчиков релиза. Помимо прочего, они несут ответственность за размещение репозитория и автоматизированные процессы, связанные с Git. Честно говоря, они могли быть теми, кто удалили патч, но если бы они это сделали, то не оставили бы никаких следов.

Один из разработчиков релиза предложил запустить git log с параметром —-follow option. Возможно, файл был переименован, что означало, что Git не отображал некоторые изменения.

—-follow

Продолжить перечисление истории файла без переименований (работает только для одного файла).

Мы обнаружили deadbeef в выводе git log —-follow Page.php, но не нашли ни удалений, ни переименований файла. Более того, не было никаких признаков того, что метод canBeEdited был где-либо удален. Казалось, что опция - следовать сыграла некоторую роль в этой истории, но все еще не было ясно, к чему привели изменения.

К сожалению, рассматриваемый репозиторий - один из самых крупных. С момента добавления первого патча и до его исчезновения было совершено не менее 21 000 коммитов. При этом нам также повезло, что соответствующий файл редактировали только десять из них. Я просмотрел их все, но ничего интересного не нашел.

Вызовите свидетелей! Нам нужен живой медведь

Подожди минутку! Я думал, что мы только что искали говядину. Давайте рассмотрим это логически: должна быть фиксация, назовем ее livebear, после чего deadbeef перестал отображаться в истории файлов. Возможно, это ни к чему не привело, но давало мне пищу для размышлений.

Для поиска в истории Git существует команда git bisect. В документации сказано, что она позволяет найти фиксацию, в которой впервые появилась ошибка. На практике вы можете использовать его для поиска любого момента в истории, если знаете, как определить, наступил ли этот момент. Для нас ошибкой было то, что в коде не было никаких изменений. Я мог бы проверить это с помощью другой команды: git grep. Для меня было бы достаточно знать, был ли метод canBeEdited на странице Page.php. Небольшая отладка и некоторая документация для чтения:

livebear [build]: Merge branch origin/XXX into build_web_yyyy.mm.dd.hh

Это похоже на обычную фиксацию слияния: слияние ветки задачи с веткой выпуска. Однако этот коммит позволил мне повторить проблему:

$ 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 log принимает несколько коммитов в качестве входных параметров и должна показывать пользователю их родительские коммиты, кроме родительских коммитов, которым предшествует символ ^. Итак, git log A ^ B должен показывать коммиты, которые являются родительскими для A, но не для B.

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

// commit is a type and a name of the variable
commit commit;
rev_info revs;
revs = setup_revisions(revisions_range);
while (commit = get_revision(revs)) {
    log_tree_commit(commit);
}

Здесь функция get_revision принимает обороты - набор управляющих флагов - в качестве входного параметра. Каждый раз, когда он вызывается, он должен как бы отправлять в правильном порядке следующую фиксацию для обработки (или пустое значение, когда мы дойдем до конца). Также есть функция setup_revisions, которая заполняет структуру revs, и log_tree_commit, которая экспортирует информацию на экран.

Я чувствовал, что понял, где искать проблему. Я отправил команду конкретному файлу (Page.php), потому что меня интересовали только его изменения. Это означает, что в журнале git должна быть логика для фильтрации «лишних» коммитов. Функции 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- ›коммиты). В этот список добавляется первый элемент (верхний) в дереве фиксации. Затем коммиты постепенно берутся из начала этого списка, а их родители добавляются в конец списка.

Читая код, я обнаружил, что среди «тумана» вспомогательных функций есть сложная логика для фильтрации коммитов - именно то, что я так долго искал. Вот что происходит в функции 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;
        }
    }
}

В случае слияния нескольких ветвей, если состояние файла остается таким же, как в одной из ветвей, нет смысла рассматривать другие ветки. Если состояние файла нигде не изменилось, мы оставим только первую ветку.

Вот пример. Отметим коммиты, в которых файл не изменился, знаком «0», те, в которых файл был изменен, знаком «1», а слияние ветвей - знаком «X».

В этой ситуации код не учитывает функциональную ветку - в нем нет никаких изменений. Если файл там действительно был изменен, то изменения были отброшены в X, а это означает, что их история не очень актуальна: рассматриваемого кода больше нет.

Нечто подобное произошло и в нашем случае. Два разработчика внесли изменения в один файл - Page.php. Один из них внес изменения в главную ветку, в коммит deadbeef; другой внес изменения в ветку, относящиеся к их задаче.

Когда второй разработчик отправил изменения из основной ветки в ветвь задачи, произошел конфликт. Поскольку второй разработчик находился в процессе разрешения этого конфликта, он просто выбросил изменения из мастера. Прошло время, они закончили работу над задачей, и ветка задачи была отправлена ​​мастеру, тем самым удалив изменения из мертвой фиксации.

При этом коммит остался. Однако, если вы запустите git log с параметром Page.php, вы не увидите фиксацию deadbeef в выводе.

Оптимизация - это нехорошо

Я сразу же приступил к тщательному изучению правил отправки изменений и ошибок в сам Git. В конце концов, я думал, что обнаружил серьезную проблему. Подумать только об этом: некоторые коммиты просто исчезнут из вывода, и это было поведение по умолчанию! К счастью, правил оказалось много, было поздно, и к следующему утру мое рвение угасло.

Я понял, что эта оптимизация действительно ускоряет работу Git на больших репозиториях, таких как наш. Более того, мы нашли документацию по нему в man git-rev-list, и это поведение очень легко отключить.

Кстати, какова была роль —-follow в этой истории?

На самом деле существует множество способов повлиять на работу этой логики. Мы нашли конкретный комментарий 13 лет назад относительно флага Follow в коде Git:

Невозможно удалить коммиты с последующим переименованием: пути меняются.

P.S: Лично я работаю в команде релизов Bumble уже несколько лет, и многие из нас считают, что знаем о Git все.

В связи с этим нам необходимо разобраться в проблемах, которые возникают в этой системе. Некоторые из них, как мне кажется, довольно интригующие, как, например, тот, что описан в этой статье. Проблемы очень часто решаются быстро. Мы уже столкнулись со многими вещами; некоторые хорошо описаны в документации. Этот случай был исключением.

На самом деле в документации был раздел под названием «Упрощение истории», но я его пропустил.

И для тех, кто дочитал эту статью до конца, у меня есть небольшой бонус, который вы можете забрать домой. У меня есть небольшой репозиторий, в котором тиражируется рассматриваемая проблема:

$ 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

Спасибо за прочтение!