Когда мы говорим об автоматизации процесса разработки и тестирования, мы подразумеваем, что это очень масштабное действие, и это действительно так. А если разложить его по частям, то станут видны отдельные фрагменты всей картины ― такая фрагментация процесса очень важна в двух случаях:
- действия выполняются вручную, что требует сосредоточенности и аккуратности;
- жёсткие временные рамки.
В нашем случае налицо лимит по времени: релизы формируются, тестируются и выкатываются на продакшн-сервер два раза в день. При ограниченных сроках в жизненном цикле релиза процесс удаления (отката) из релизной ветки задачи, содержащей ошибку, имеет важное значение. Для её выполнения мы используем 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('/^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. В некоторых случаях проще использовать git revert, но лучше им не увлекаться (revert на revert на revert...). Мы надеемся, что не самая простая операция git rebase стала вам более понятной, а тем, кто постоянно использует git rebase в процессе разработки и формирования релиза, пригодится и наш скрипт.
Илья Агеев, QA Lead и Владислав Чернов, Release engineer
Автор: vchernov