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

в 12:58, , рубрики: badoo, Git, автоматизация тестирования, баду, Блог компании Badoo, Веб-разработка, деплой, релизы, метки: , , , ,

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

  • действия выполняются вручную, что требует сосредоточенности и аккуратности;
  • жёсткие временные рамки.

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

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

Git flow

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

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

BFG-9000_All_developers_should_be_given_a_years_holiday_(paid)

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

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

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

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

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

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

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

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

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

Рассмотрим, что можно использовать для автоматизации процесса:
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. В процессе удаления задачи из релиза будут решены все простые конфликты слияния.

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

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

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

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

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

Реализация

Теперь пошагово рассмотрим, что же будет делать наш скрипт (в примере используется только автоматический 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('/^CONFLICTs(content):sMergesconflictsins(.*)$/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:sCommits' . $stopped_sha . 'sissasmergesbutsnos-msoptionswassgiven/m', $output)) {
           $res = self::FLAG_REBASE_CONTINUE;
       } elseif (preg_match('/error:scouldsnotsapply.*Whensyoushavesresolvedsthissproblemsruns"gitsrebases--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('/_rd+$/', '', $build);
    $new = preg_replace('/^.*Merge.*branch.*' . $ticket . '.*intos' . $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 rebase «по кнопке»

Заключение

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

Илья Агеев, QA Lead и Владислав Чернов, Release engineer

Автор: vchernov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js