Был уже вечер, когда ко мне обратился разработчик. Из мастер-ветки пропал патч — коммит deadbeef.



Мне показали доказательства: вывод двух команд. Первая из них —

 git show deadbeef 

— показывала изменения файла, назовём его Page.php. В него добавились метод canBeEdited и его использование.

А в выводе второй команды —

 git log -p Page.php 

— коммита deadbeef не было. Да и в текущей версии файла Page.php не было метода canBeEdited.

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

”offtopic”
Я старался сделать этот текст понятным людям, не очень хорошо знакомым с 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

Спасибо за внимание!

(*) UPD: Оказалось, что раздел History Simplification был в документации команды git log намного дольше, чем полгода, а я его просто пропустил. Спасибо youROCK, что обратил на это внимание!