Your browser is not supported anymore. Please update to a more recent one.


Download Chrome

Download Firefox

Download
Internet Explorer

Download Safari

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 в процессе разработки и формирования релиза, пригодится и наш скрипт.