company_banner

Git rebase «по кнопке»


    Когда мы говорим об автоматизации процесса разработки и тестирования, мы подразумеваем, что это очень масштабное действие, и это действительно так. А если разложить его по частям, то станут видны отдельные фрагменты всей картины ― такая фрагментация процесса очень важна в двух случаях:
    • действия выполняются вручную, что требует сосредоточенности и аккуратности;
    • жёсткие временные рамки.

    В нашем случае налицо лимит по времени: релизы формируются, тестируются и выкатываются на продакшн-сервер два раза в день. При ограниченных сроках в жизненном цикле релиза процесс удаления (отката) из релизной ветки задачи, содержащей ошибку, имеет важное значение. Для её выполнения мы используем git rebase. Так как git rebase ― это полностью ручная операция, которая требует внимательности и скрупулезности и занимает продолжительное время, мы автоматизировали процесс удаления задачи из релизной ветки.


    Git flow


    На данный момент Git является одной из самых распространённых систем контроля версий, и мы её успешно используем в Badoo.
    Процесс работы с Git довольно прост.


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

    BFG-9000_All_developers_should_be_given_a_years_holiday_(paid)

    Релиз мы собираем и тестируем из отдельной ветки (release), в которую сливаются завершённые и протестированные задачи на devel-окружении. Так как мы выкладываем код на продакшн-сервер дважды в день, то, соответственно, ежедневно мы создаём две новые ветки релиза.



    Релиз формируется путём сливания задач в релизную ветку с помощью инструмента automerge. Также у нас есть ветка master, которая является копией продакшн-сервера. После этапа интеграционного тестирования релиза и каждой отдельной задачи код отправляется на продакшн-сервер и сливается в ветку master.
    Когда релиз тестируется на staging-окружении и обнаруживается ошибка в одной из задач, а времени на исправление нет, мы просто удаляем данную задачу из релиза, используя git rebase.



    Примечание. Функцию git revert мы не используем в релизной ветке, потому что если удалить задачу из релизной ветки с помощью git revert и релизная ветка сольётся в master, из которого разработчик потом подтянет свежий код в ветку, в которой возникла ошибка, то ему придётся делать revert на revert, чтобы вернуть свои изменения.

    На следующем этапе мы собираем новую версию релиза, выкатываем её на staging-окружение, проверяем на отсутствие ошибок, запускаем автотесты и при положительном результате выкладываем код на продакшн-сервер.
    Основные моменты этой схемы полностью автоматизированы и работают в процессе непрерывной интеграции (до настоящего момента только удаление задачи из релизной ветки выполнялось вручную).




    Постановка задачи


    Рассмотрим, что можно использовать для автоматизации процесса:
    1. Ветка релиза, из которой мы собираемся откатывать тикет, состоит из коммитов двух категорий:
    • мерженный коммит, который получается при сливании в релизную ветку ветки задачи, содержит имя тикета в коммит-месседже, так как ветки именуются с префиксом задачи;
    • мерженный коммит, который получается в результате сливания ветки master в ветку релиза в автоматическом режиме. На master мы накладываем патчи в полуавтоматическом режиме через наш специальный инструмент DeployDashboard. Патчи прикладываются к соответствующему тикету, при этом в коммит-месседже указывается номер этого тикета и описание патча.
    2. Встроенный инструмент git rebase, который лучше всего использовать в интерактивном режиме благодаря удобной визуализации.

    Проблемы, с которыми можно столкнуться:

    1. При выполнении операции git rebase происходит перемерживание всех коммитов в ветке, начиная с того, который откатывается.
    2. Если при формировании ветки какой-либо конфликт слияния был разрешён вручную, то Git не сохранит решение данного конфликта в памяти, поэтому при выполнении операции git rebase нужно будет повторно исправить конфликты слияния в ручном режиме.
    3. Конфликты в конкретном алгоритме делятся на два вида:
    • простые ― такие конфликты возникают из-за того, что функциональность системы контроля версий не позволяет запоминать решённые ранее конфликты слияния;
    • сложные ― возникают из-за того, что код исправлялся в конкретной строке (файле) не только в коммите, который удаляется из ветки, но и в последующих коммитах, которые перемерживаются в процессе git rebase. При этом разработчик исправлял данный конфликт вручную и выполнял push в релизную ветку.

    У Git есть интересная функция git rerere, которая запоминает решение конфликтов при мерже. Она включается в автоматическом режиме, но, к сожалению, не может нам помочь в данном случае. Эта функция работает только тогда, когда есть две долгоживущие ветки, которые постоянно сливаются ― такие конфликты Git запоминает без проблем.
    У нас же всего одна ветка, и если не используется функция -force при выполнении git push изменений в репозиторий, то после каждого git rebase придётся создавать новую ветку с новым стволом изменений. Например, мы прописываем постфикс _r1,r2,r3 … после каждой успешной операции git rebase и выполняем git push новой релизной ветки в репозиторий. Таким образом, история решения конфликтов не сохраняется.


    Что же мы в итоге хотим получить?

    По нажатию определённой кнопки в нашем багтрекере:
    1. Задача будет автоматически удалена из релиза.
    2. Создастся новая ветка релиза.
    3. Статус у задачи будет переведен в Reopen.
    4. В процессе удаления задачи из релиза будут решены все простые конфликты слияния.

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

    Основные функции


    1. Наш скрипт использует интерактивный rebase и отлавливает в ветке релиза коммиты с номером задачи, которую нужно откатить.
    2. При нахождении нужных коммитов он удаляет их, при этом запоминает имена файлов, которые в них изменялись.
    3. Далее он перемерживает все коммиты, начиная с последнего удалённого нами в стволе ветки.
    4. Если возникает конфликт, то он проверяет файлы, которые участвуют в данном конфликте. Если эти файлы совпадают с файлами удалённых комиттов, то мы уведомляем разработчика и релиз-инженера о том, что возник сложный конфликт, который нужно решить вручную.
    5. Если файлы не совпадают, но конфликт возник, то это простой конфликт. Тогда мы берём код файлов из коммита, в котором разработчик уже решал этот конфликт, из origin-репозитория.

    Так «бежим до головы ветки».

    Вероятность того, что мы попадём на сложный конфликт, ничтожно мала, то есть 99% выполнений данного процесса будут проходить в автоматическом режиме.

    Реализация


    Теперь пошагово рассмотрим, что же будет делать наш скрипт (в примере используется только автоматический rebase и можно использовать скрипт просто в консоли):
    1. Очищаем репозиторий и вытягиваем последнюю версию ветки релиза.
    2. Получаем верхний коммит в стволе со слиянием в релиз ветки, которую хотим откатить.
         а. Если коммита нет, то сообщаем, что откатывать нечего.
    3. Генерируем скрипт-редактор, который только удаляет из ствола ветки хеши мержевых коммитов, таким образом удаляя их из истории.
    4. В окружение скрипта-ревертера задаем скрипт-редактор (EDITOR), который мы сгенерили на предыдущем этапе.
    5. Выполняем git rebase -ip для релиза. Проверяем код ошибки.
         а. Если 0, то все прошло хорошо. Переходим к пункту 2, чтобы найти возможные предыдущие коммиты удаляемой ветки задачи.
         b.Если не 0, значит, возник конфликт. Пробуем решить:
              i. Запоминаем хэш коммита, который не удалось наложить.
                Он лежит в файле .git/rebase-merge/stopped-sha
              ii. Разбираем вывод команды rebase, чтобы выяснить, что не так.
                 1. Если Git нам говорит “CONFLICT (content): Merge conflict in ”, то сравниваем этот файл с предыдущей ревизией от удаляемой, и если он не отличается (файл не менялся в коммите), то просто берём этот файл с головы ветки билда и коммитим. Если отличается, то выходим, а разработчик разрешает конфликт вручную.
                 2. Если Git говорит “fatal: Commit is a merge but no -m option was given”, то просто повторяем rebase с флажком --continue. Мержевый коммит пропустится, но изменения не потеряются. Обычно такое бывает с веткой master, но он уже подтягивался в голову ветки и данный мержевый коммит не нужен.
                 3. Если Git говорит “error: could not apply… When you have resolved this problem run «git rebase --continue”, то делаем git status, чтобы получить список файлов. Если хоть один файл из статуса есть в коммите, который мы откатываем, то пропускаем коммит (rebase --skip), который мы запомнили на шаге 5.b.i, написав об этом в лог, чтобы релиз-инженер это увидел и решил, нужен этот коммит или нет.
                 4. Если ничего из перечисленного не случилось, то выходим из скрипта и говорим, что произошло что-то необъяснимое.
    6. Повторяем пункт 5, пока не появится exit code 0 на выходе, либо счётчик в цикле не будет > 5, чтобы избежать ошибок зацикливания.

    Код скрипта
    /**
     * Код выдран из библиотеки деплоя, поэтому при копипасте не заработает.
     * Предназначен для ознакомления.
     */
    
        function runBuildRevert($args)
        {
           if (count($args) != 2) {
               $this->commandUsage("<build-name> <ticket-key>");
               return $this->error("Unknown build!");;
           }
    
           $build_name = array_shift($args);
           $ticket_key = array_shift($args);
    
           $build = $this->Deploy->buildForNameOrBranch($build_name);
           if (!$build) return false;
    
           if ($this->directSystem("git reset --hard && git clean -fdx")) {
               return $this->error("Can't clean directory!");
           }
           if ($this->directSystem("git fetch")) {
               return $this->error("Can't fetch from origin!");
           }
           if ($this->directSystem("git checkout " . $build['branch_name'])) {
               return $this->error("Can't checkout build branch!");
           }
           if ($this->directSystem("git pull origin " . $build['branch_name'])) {
               return $this->error("Can't pull build branch!");
           }
    
           $commit = $this->_getTopBranchToBuildMergeCommit($build['branch_name'], $ticket_key);
           $in_stream_count = 0;
           while (!empty($commit)) {
               $in_stream_count += 1;
               if ($in_stream_count >= 5) return $this->error("Seems rebase went to infinite loop!");
               $editor = $this->_generateEditor($build['branch_name'], $ticket_key);
    
               $output = '';
               $code = 0;
               $this->exec(
                   'git rebase -ip ' . $commit . '^^',
                   $output,
                   $code,
                   false
               );
    
               while ($code) {
                   $output = implode("\n", $output);
                   $conflicts_result = $this->_resolveRevertConflicts($output, $build['branch_name'], $commit);
                   if (self::FLAG_REBASE_STOP !== $conflicts_result) {
                       $command = '--continue';
                       if (self::FLAG_REBASE_SKIP === $conflicts_result) {
                           $command = '--skip';
                       }
                       $output = '';
                       $code = 0;
                       $this->exec(
                           'git rebase ' . $command,
                           $output,
                           $code,
                           false
                       );
                   } else {
                       unlink($editor);
                       return $this->error("Giving up, can't resolve conflicts! Do it manually.. Output was:\n" . var_export($output, 1));
                   }
               }
    
               unlink($editor);
               $commit = $this->_getTopBranchToBuildMergeCommit($build['branch_name'], $ticket_key);
           }
           if (empty($in_stream_count)) return $this->error("Can't find ticket merge in branchdiff with master!");
           return true;
        }
    
        protected function _resolveRevertConflicts($output, $build_branch, $commit)
        {
           $res = self::FLAG_REBASE_STOP;
           $stopped_sha = trim(file_get_contents('.git/rebase-merge/stopped-sha'));
           if (preg_match_all('/^CONFLICT\s\(content\)\:\sMerge\sconflict\sin\s(.*)$/m', $output, $m)) {
               $conflicting_files = $m[1];
               foreach ($conflicting_files as $file) {
                   $output = '';
                   $this->exec(
                       'git diff ' . $commit . '..' . $commit . '^ -- ' . $file,
                       $output
                   );
                   if (empty($output)) {
                       $this->exec('git show ' . $build_branch . ':' . $file . ' > ' . $file);
                       $this->exec('git add ' . $file);
                       $res = self::FLAG_REBASE_CONTINUE;
                   } else {
                       return $this->error("Can't resolve conflict, because file was changed in reverting branch!");
                   }
               }
           } elseif (preg_match('/fatal\:\sCommit\s' . $stopped_sha . '\sis\sa\smerge\sbut\sno\s\-m\soption\swas\sgiven/m', $output)) {
               $res = self::FLAG_REBASE_CONTINUE;
           } elseif (preg_match('/error\:\scould\snot\sapply.*When\syou\shave\sresolved\sthis\sproblem\srun\s"git\srebase\s\-\-continue"/sm', $output)) {
               $files_status = '';
               $this->exec(
                   'git status -s|awk \'{print $2;}\'',
                   $files_status
               );
               foreach ($files_status as $file) {
                   $diff_in_reverting = '';
                   $this->exec(
                       'git diff ' . $commit . '..' . $commit . '^ -- ' . $file,
                       $diff_in_reverting
                   );
                   if (!empty($diff_in_reverting)) {
                       $this->warning("Skipping commit " . $stopped_sha . " because it touches files we are reverting!");
                       $res = self::FLAG_REBASE_SKIP;
                       break;
                   }
               }
           }
           return $res;
        }
    
        protected function _getTopBranchToBuildMergeCommit($build_branch, $ticket)
        {
           $commit = '';
           $this->exec(
               'git log ' . $build_branch . ' ^origin/master --merges --grep ' . $ticket . ' -1 --pretty=format:%H',
               $commit
           );
           return array_shift($commit);
        }
    
        protected function _generateEditor($build_branch, $ticket, array $exclude_commits = array())
        {
           $filename = PHPWEB_PATH_TEMPORARY . uniqid($build_branch) . '.php';
           $content = <<<'CODE'
    #!/local/php5/bin/php
    <?php
    $build = '%s';
    $ticket = '%s';
    $commits = %s;
    $file = $_SERVER['argv'][1];
    if (!empty($file)) {
        $content = file_get_contents($file);
        $build = preg_replace('/_r\d+$/', '', $build);
        $new = preg_replace('/^.*Merge.*branch.*' . $ticket . '.*into\s' . $build . '.*$/m', '', $content);
        foreach ($commits as $exclude) {
           $new = preg_replace('/^.*' . preg_quote($exclude, '/') . '$/m', '', $new);
        }
        file_put_contents($file, $new);
    }
    CODE;
           $content = sprintf($content, $build_branch, $ticket, var_export($exclude_commits, 1));
           file_put_contents($filename, $content);
           $this->exec('chmod +x ' . $filename);
           putenv("EDITOR=" . $filename);
           return $filename;
        }
    



    Заключение


    В итоге мы получили скрипт, который удаляет задачу из релизной ветки в автоматическом режиме. Мы сэкономили время в процессе формирования и тестирования релиза, при этом почти полностью исключили человеческий фактор.
    Конечно же, наш скрипт подойдет не всем пользователям Git. В некоторых случаях проще использовать git revert, но лучше им не увлекаться (revert на revert на revert...). Мы надеемся, что не самая простая операция git rebase стала вам более понятной, а тем, кто постоянно использует git rebase в процессе разработки и формирования релиза, пригодится и наш скрипт.

    Илья Агеев, QA Lead и Владислав Чернов, Release engineer
    Badoo
    409.96
    Big Dating
    Share post

    Comments 13

      +5
      > Вероятность того, что мы попадём на сложный конфликт, ничтожно мала, то есть 99% выполнений данного процесса будут проходить в автоматическом режиме.

      Ну, не скажи! Тут и одного процента достаточно, особенно в более-менее сложных задачах, пересекающихся с другими. Сами знаете, то и дело конфликты возникают, пусть в основном и несложные.

      P.S. octodex.github.com/femalecodertocat/
        +3
        Мы рассматриваем наш практический опыт и у нас таких конфликтов не много, и как мы написали они действительно так же решаются вручную. Но для нас это важно было автоматизировать процесс, чтобы сократить время и уменьшить количество ошибок.
        +1
        А зачем вообще использовать rebase? Ведь установлено же что rebase это — абсолютное зло:
        1. habrahabr.ru/post/179123/
        2. habrahabr.ru/post/179673/
        3. geekblog.oneandoneis2.org/index.php/2013/04/30/please-stay-away-from-rebase
        The whole reason to use a VCS is to have it record your history. Rebasing destroys your history, and therefore destroys the point of using a VCS.

        Nuff said.
          +7
          Да в данных ситуациях, которые рассматриваются в статьях, это верно, изменять историю не очень хорошо. Но мы ее исправляем только для ветки релиза, и только для быстрого отката задачи, при этом история все равно сохраняется (в старой ветке), так как после ребейза мы создаем новую релизную ветку.
            +2
            Цитата верна, кроме тех случаев когда изменение истории является меньшим из зол. Например когда вы сливаете вместе несколько веток прежде чем влить их в master и в случае обнаружения проблем в одной из веток гораздо удобнее с помощью git rebase один раз убрать ее и отдать в доработку нежели использовать git revert несколько раз.
            +3
            Отличная статья! Задача непростая поставлена, и решение хорошее! Молодцы!
              0
              Сколько задач обычно попадает в релиз? Не будет ли проще пересоздать релиз и заново вмержить только нужные задачи в автоматическом режиме? Вероятность конфликтов при этом уменьшится.
                0
                В релиз может попадать от одной до нескольких десятков задач. В среднем ежедневно уходит 20-30 задач в одну выкладку. В обе выкладки, около 50 задач, соответственно.

                Вы правы, перемерживание всех задач в новый билд это тоже вариант. Но помимо смерженных веток в ветке релиза могут быть фиксы. Например, фиксы багов интеграции задач друг с другом. В случае перемерживания задач такие фиксы могут потеряться. А в случае удаления мержевого коммита из ствола, удалится только этот коммит. Остальные правки останутся в ветке.

                В том числе и поэтому мы проверяем при решении конфликтов отката (в алгоритме скрипта про это написано), нет ли правок откатываемых файлов в других коммитах, когда проходит ребейз. Чтобы исключить оставшиеся патчи на откатываемую задачу, например, после отката задачи.
                0
                Сначала нужно было дочитать;(
                  0
                  Нет, все-таки rerere должна помогать вам в вашей работе.
                  0
                  Поробоуйте использовать вот это: github.com/affinitybridge/git-bpf
                  Вписывается в ваш сценарий использования на 100%:

                  $ git recreate-branch -a release -b release_r1 -x BFG-9000-broken
                    0
                    Оно работает за счет шаринга кэша rerere
                      0
                      Спасибо, обязательно протестируем.

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