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