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

ссылка на оригинал статьи http://habrahabr.ru/company/badoo/blog/193258/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *