company_banner

История потерянного коммита

    Был уже вечер, когда ко мне обратился разработчик. Из мастер-ветки пропал патч — коммит 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, что обратил на это внимание!
    Badoo
    Big Dating

    Comments 45

      +5

      Саш, я помню, что раздел про history simplification в мане про git-log был ещё лет 5 назад… Впрочем, может быть, ты прав, что он был только в rev-list.


      Я напарывался на похожие проблемы, когда для системы переводов пытался надежно определить последний коммит, в котором менялся файл, и, на удивление, не смог, именно из-за сложностей обработки merge-коммитов, и в итоге на эту идею плюнул :).


      Ещё в целом прикольные подводные камни возникают, когда в прямо merge-коммите что-то дополнительно добавляют или убирают, а не просто разрешают конфликты. В таких случаях раньше git show -p для такого коммита ничего не показывал, а git log <filename> по-моему не показывает merge-коммиты по умолчанию :).

        +2
        Ну про полгода явно ошибка.
        web.archive.org/web/20200103050848/https://git-scm.com/docs/git-log

        В январе этого года (почти год назад) этот раздел уже был. Более старое затрудняюсь посмотреть, вебархив сбоит сильно. Болеет видимо :-(
          +1
          Спасибо, я действительно ошибся. Я точно помню что не обратил внимание на этот раздел, когда смотрел документацию в попытке разобраться, что происходит. Могу только предположить, что искал в документации какие-то комментарии к флагу --follow. А на раздел наткнулся уже после того, как залез в код.

          Намного позже, когда писал статью, решил что не просто не заметил этого куска, а его не было. Конкретно из-за этого коммита: в нем в документацию git-log добавилось
          include::rev-list-description.txt[]
          а вот
          include::rev-list-options.txt[]
          уже там было (похоже где-то лет 10 как). Простите, немного человеческой невнимательности :)
          +2
          Ответил про history simplification в ветке, похоже что ты прав.

          Искренне считаю, что самый крутой вариант — это когда в merge добавляют что-нибудь, даже если никакого конфликта не было :)
          +2

          Спасибо за интересную историю.

            +1
            Да, присоединяюсь. Понравилась манера подачи, читается как детективчик :)
            С удовольствием провел время с пользой, спасибо.
            +3
            парни, у вас как-то много свободного времени) надо тасок добавить и утилизировать этот ресурс)
              +8

              О, это может никак не помочь отвлечь мозг от недоисследованной проблемы. Такие цепляют и хочется понять "да как оно так то?! и как избежать/починить в будущем?"(иногда добавляется "так, а как оно раньше то работать вообще умудрялось!?").


              И порой проще действительно вот как тут закопаться и докопаться — и пойти дальше по таскам.

                +1

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

                  0

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

                    +1

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

                      0

                      Согласен.

              +3
              Еще один повод не пользоваться merge в git. Лично я предпочитаю rebase и cherry-pick.
                +6
                хехе. после этих rebase-ов еще и не такие приколы поиметь можно.
                c merge-ами хотя бы можно разобраться что к чему было, кто, когда и зачем
                  +3

                  Как раз таки при merge такие проблемы чаще всего и остаются незаметными, т.к. большая пачка изменений conflict resolution идёт в итоговый merge commit. При rebase удаление кода из мастера было бы частью непосредственно коммита, который добавляет новую версию кода — и сразу привлекало бы внимание как при ревью кода, так и при просмотре истории.


                  А вообще лучше всего использовать rebase и merge одновременно (с пустыми merge commit), и получать преимущества обоих вариантов!

                +8
                Программист удалил конфликтный код при слиянии. По-моему, все логично. Коммит есть, а кода нет.
                  +4
                  Ну, только merge коммита в выводе git log {filename} в таком случае тоже не будет видно. Поэтому коммит есть, кода нет, merge коммита тоже нет :)
                    0

                    (del)

                      0

                      Понять не могу: как получится этот коммит afea493a?
                      Если я мержу changekiller в master, то появляется конфликт и 2 варианта:


                      • решаем конфликт в пользу changekiller — мерж-коммит не пустой (видно изменение)
                      • решаем конфликт в пользу master — мерж-коммит пустой, но и в master ожидаемое 3, а не 4

                      Буду признателен если кто-нибудь распишет последовательность действий.

                        0

                        Я слепой… Использовался git merge -s ours

                          0

                          В обсуждении уже восстановили минимальную последовательность действий. Читайте до конца.

                        +1

                        Причём я тоже натыкался на подобное. Происходит от непонимания работы git со стороны разработчика. Если я правильно понял, то механизм примерно такой:


                        • Разработчик ответвляется от master в feature ветку. Пока всё ОК.
                        • Фигачит код, коммитит, пушит к себе в ветку. Пока всё почти ОК, кроме того, что если приходится атомарные изменения делать несколько дней, то это "симптомчик" про сложность кода.
                        • master (логично) убегает вперёд. Разработчик подмёрживает к себе изменения master. А там конфликт, чтобы не греть голову чувак оставил только свои изменения. Вот это и есть основная засада (ниже перечислю что не так).
                        • Потом еще что-то коммитит в свою ветку.
                        • Потом замержил с master (уже как будто "без конфликтов").

                        Что не так:


                        1. Неаккуратный мерж к себе.
                        2. По хорошему всю ветку надо либо аккуратно ребейзить в хвост мастера, либо делать squash своих изменений в один коммит (если он атомарен). Ключевое слово "аккуратно". Заодно история станет прозрачнее.
                        3. В гите в истории нет понятия "коммит был в такой-то ветке". Обе сливающиеся ветки (или все N веток) равноправны. И поэтому делая мерж от master к себе каждый разработчик отвечает за этот мержевый коммит. Поэтому публикуя пулл/мерж реквест разработчик отвечает за каждый коммит, добавляемый в основной граф. В том числе и за такие "технические" мержи. Поэтому пулл/мерж реквест должен по-хорошему содержать коммиты от максимально свежего, не содержать лишних ветвлений и мержей — это позволит сильно экономить мыслетопливо при чтении истории.
                          0

                          Какой еще rebase после push?

                        0

                        Вопрос можно? А не является ли эта история следствием каких-то организационных проблем?

                          0
                          Каких например?

                          Предположу, что странным может показаться что разработчик просто выкинул чужие изменения, но и конфликт там был в очень большом количестве файлов — в ветке делали рефакторинг системы, насколько я понял.
                            +1

                            Что разработчик сделал merge, a не pull/merge request, который, если не ошибаюсь, был бы отклонен как раз из-за конфликта. А дальше, см. stackoverflow.

                              +1
                              Так у нас такая же ситуация как по вашей ссылке на stackoverflow: у разработчика есть ветка с его кодом, которая конфликтует с мастером. Разработчик подтягивает мастер к себе в ветку, разрешая конфликт «в пользу своих изменений». Потом уже его ветка попадает в мастер, и уже без конфликтов, а мы получаем всю эту историю с потерянным коммитом.

                              Тогда скорее можно попробовать упрекнуть процесс/инструмент ревью кода. Или там не было видно изменений (и тогда это проблема инструмента) или изменения просто не заметили или пропустили (и тогда это проблема разработчика/процесса). К сожалению, у нас именно последний вариант (не заметили — изменений было банально слишком много).

                              В целом тут можно пофилосовствовать дальше: не стоило делать такие большие изменения в одной ветке, например. Это правда, но этого может быть тяжело добиться и тяжело заставить людей следовать такому правилу.
                                0

                                В вашем тестовом репозитории я этого не вижу. Ветка changekiller мержится в master.

                                  0
                                  В тестовом репозитории я пытался сделать минимум всего, просто чтобы показать проблему: коммита действительно не видно. Показалось что было бы приятно иметь возможность «пощупать» самому то, о чем идет рассказ :)
                                    +1

                                    Все видно в History файла. И даже в Log окне. Я про Pycharm.

                          0

                          Немного напоминает совет не пользоваться многократным undo и redo в редакторе кода, когда результат может не всегда совпадать с исходным.

                            +3
                            Можно подробнее, никогда с таким не сталкивался?
                            +1
                            Хорошая история, спасибо
                              +2

                              Уважаемый автор, я скачал ваш репозиторий, посмотрел на него PyCharm'ом и, да, в Log'е не не сразу очевидно, что произошло (надо всматриваться), а вот если попросить Git history файла test.php, то все видно мгновенно — то, что вы при разрешении merge conflict не приняли изменений в changekiller, в отличие от ситуации, описанной в статье.


                              P.S. И там еще много лишнего видно типа ветки interesting_changes — удалить надо?

                                0
                                Не обращал внимание, но вы правы, в IDE JetBrains на тестовом репозитории все отлично видно. К сожалению, сам привык пользоваться в Git в консоли :(

                                P.s. Лишнюю ветку удалил, спасибо!
                                  0
                                  Вот я тоже не понимал в чем собственно проблемма — посмотрел на граф, историю — и всё ясно, пока до меня не дошло, что автор только командной строкой пользуется.
                                0

                                Интересная проблема


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

                                  +1

                                  Там не было мержа в мастер, наоборот, мастер был подмержен к ветке программиста.

                                    0

                                    См. тестовый репозиторий и мою дискуссию с автором статьи выше. Т.е. в конце концов. таки (другой) программист, который делал рефакторинг, смержил свою ветку обратно в мастер. Иначе, как бы еще могли потеряться исправления в master'е?

                                      0

                                      Потом-то, конечно, всё отправилось в мастер.


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


                                      Ну т.е. там вообще проблему не так поняли.

                                        +1

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


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


                                        Как причину ошибки искать, это уже второй вопрос (с простым ответом, или консольные команды лучше знать, или GUI пользоваться), а вот как поймать и не допустить? Только если тестами, так ведь и их можно в ветке кривым мержем "откатить".

                                  +2
                                  Когда только начинал пользоваться GIT, спросил коллег чем он лучше моего старого подхода, когда каждый день создавал архив с названием в виде даты и заливал в облако. Мне объяснили, что будет видна история изменений, что ничего не потеряется и т.д. Через пару дней я сделал что то не так и приличный кусок правок исчез. Те же коллеги сказали, ну так бывает, в винде работают некоторые вещи не очень в гите. Мой текущий подход, это, конечно же, GIT, но привычку сохранять в архивы я оставил, на всякий случай.
                                    +2

                                    Вы, конечно, понимаете, что всё там в винде нормально работает, просто коллеги не достаточно хорошо прочитали документацию? :)

                                      0
                                      Я понимаю что работает достаточно хорошо, но инструмент сложный, и доверять ему на 100% не стоит как и любому другому сложному софту. Должен быть и запасной вариант.
                                    +1
                                    Хорошо написано, есть над чем подумать, спасибо

                                    Only users with full accounts can post comments. Log in, please.