blob: b2e52d23b418c5678e951f2c3a0ef9800bbac139 [file] [log] [blame]
# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Module containing the branch stages."""
from __future__ import print_function
import os
import re
from xml.etree import ElementTree
from chromite.cbuildbot import config_lib
from chromite.cbuildbot import constants
from chromite.cbuildbot import manifest_version
from chromite.cbuildbot.stages import generic_stages
from chromite.lib import cros_build_lib
from chromite.lib import cros_logging as logging
from chromite.lib import git
from chromite.lib import parallel
site_config = config_lib.GetConfig()
class BranchError(Exception):
"""Raised by branch creation code on error."""
class BranchUtilStage(generic_stages.BuilderStage):
"""Creates, deletes and renames branches, depending on cbuildbot options.
The two main types of branches are release branches and non-release
branches. Release branches have the form 'release-*' - e.g.,
'release-R29-4319.B'.
On a very basic level, a branch is created by parsing the manifest of a
specific version of Chrome OS (e.g., 4319.0.0), and creating the branch
remotely for each checkout in the manifest at the specified hash.
Once a branch is created however, the branch component of the version on the
newly created branch needs to be incremented. Additionally, in some cases
the Chrome major version (i.e, R29) and/or the Chrome OS version (i.e.,
4319.0.0) of the source branch must be incremented
(see _IncrementVersionOnDiskForSourceBranch docstring). Finally, the external
and internal manifests of the new branch need to be fixed up (see
FixUpManifests docstring).
"""
COMMIT_MESSAGE = 'Bump %(target)s after branching %(branch)s'
def __init__(self, builder_run, **kwargs):
super(BranchUtilStage, self).__init__(builder_run, **kwargs)
self.skip_remote_push = (self._run.options.skip_remote_push or
self._run.options.debug_forced)
self.branch_name = self._run.options.branch_name
self.rename_to = self._run.options.rename_to
def _RunPush(self, checkout, src_ref, dest_ref, force=False):
"""Perform a git push for a checkout.
Args:
checkout: A dictionary of checkout manifest attributes.
src_ref: The source local ref to push to the remote.
dest_ref: The local remote ref that correspond to destination ref name.
force: Whether to override non-fastforward checks.
"""
# Convert local tracking ref to refs/heads/* on a remote:
# refs/remotes/<remote name>/<branch> to refs/heads/<branch>.
# If dest_ref is already refs/heads/<branch> it's a noop.
dest_ref = git.NormalizeRef(git.StripRefs(dest_ref))
push_to = git.RemoteRef(checkout['push_remote'], dest_ref)
git.GitPush(checkout['local_path'], src_ref, push_to, force=force,
skip=self.skip_remote_push)
def _FetchAndCheckoutTo(self, checkout_dir, remote_ref):
"""Fetch a remote ref and check out to it.
Args:
checkout_dir: Path to git repo to operate on.
remote_ref: A git.RemoteRef object.
"""
git.RunGit(checkout_dir, ['fetch', remote_ref.remote, remote_ref.ref],
print_cmd=True)
git.RunGit(checkout_dir, ['checkout', 'FETCH_HEAD'], print_cmd=True)
def _GetBranchSuffix(self, manifest, checkout):
"""Return the branch suffix for the given checkout.
If a given project is checked out to multiple locations, it is necessary
to append a branch suffix. To be safe, we append branch suffixes for all
repositories that use a non-standard branch name (e.g., if our default
revision is "master", then any repository which does not use "master"
has a non-standard branch name.)
Args:
manifest: The associated ManifestCheckout.
checkout: The associated ProjectCheckout.
"""
# Get the default and tracking branch.
suffix = ''
if len(manifest.FindCheckouts(checkout['name'])) > 1:
default_branch = git.StripRefs(manifest.default['revision'])
tracking_branch = git.StripRefs(checkout['tracking_branch'])
suffix = '-%s' % (tracking_branch,)
if default_branch != 'master':
suffix = re.sub('^-%s-' % re.escape(default_branch), '-', suffix)
return suffix
def _GetSHA1(self, checkout, branch):
"""Get the SHA1 for the specified |branch| in the specified |checkout|.
Args:
checkout: The ProjectCheckout to look in.
branch: Remote branch to look for.
Returns:
If the branch exists, returns the SHA1 of the branch. Otherwise, returns
the empty string. If branch is None, return None.
"""
if branch:
cmd = ['show-ref', branch]
result = git.RunGit(checkout['local_path'], cmd, error_code_ok=True)
if result.returncode == 0:
# Output looks like:
# a00733b...30ee40e0c2c1 refs/remotes/cros/test-4980.B
return result.output.strip().split()[0]
return ''
def _CopyBranch(self, src_checkout, src_branch, dst_branch, force=False):
"""Copy the given |src_branch| to |dst_branch|.
Args:
src_checkout: The ProjectCheckout to work in.
src_branch: The remote branch ref to copy from.
dst_branch: The remote branch ref to copy to.
force: If True then execute the copy even if dst_branch exists.
"""
logging.info('Creating new branch "%s" for %s.', dst_branch,
src_checkout['name'])
self._RunPush(src_checkout, src_ref=src_branch, dest_ref=dst_branch,
force=force)
def _DeleteBranch(self, src_checkout, branch):
"""Delete the given |branch| in the given |src_checkout|.
Args:
src_checkout: The ProjectCheckout to work in.
branch: The branch ref to delete. Must be a remote branch.
"""
logging.info('Deleting branch "%s" for %s.', branch, src_checkout['name'])
self._RunPush(src_checkout, src_ref='', dest_ref=branch)
def _ProcessCheckout(self, src_manifest, src_checkout):
"""Performs per-checkout push operations.
Args:
src_manifest: The ManifestCheckout object for the current manifest.
src_checkout: The ProjectCheckout object to process.
"""
if not src_checkout.IsBranchableProject():
# We don't have the ability to push branches to this repository. Just
# use TOT instead.
return
checkout_name = src_checkout['name']
remote = src_checkout['push_remote']
src_ref = src_checkout['revision']
suffix = self._GetBranchSuffix(src_manifest, src_checkout)
# The source/destination branches depend on options.
if self.rename_to:
# Rename flow. Both src and dst branches exist.
src_branch = '%s%s' % (self.branch_name, suffix)
dst_branch = '%s%s' % (self.rename_to, suffix)
elif self._run.options.delete_branch:
# Delete flow. Only dst branch exists.
src_branch = None
dst_branch = '%s%s' % (self.branch_name, suffix)
else:
# Create flow (default). Only dst branch exists. Source
# for the branch will just be src_ref.
src_branch = None
dst_branch = '%s%s' % (self.branch_name, suffix)
# Normalize branch refs to remote. We only process remote branches.
src_branch = git.NormalizeRemoteRef(remote, src_branch)
dst_branch = git.NormalizeRemoteRef(remote, dst_branch)
# Determine whether src/dst branches exist now, by getting their sha1s.
if src_branch:
src_sha1 = self._GetSHA1(src_checkout, src_branch)
elif git.IsSHA1(src_ref):
src_sha1 = src_ref
dst_sha1 = self._GetSHA1(src_checkout, dst_branch)
# Complain if the branch already exists, unless that is expected.
force = self._run.options.force_create or self._run.options.delete_branch
if dst_sha1 and not force:
# We are either creating a branch or renaming a branch, and the
# destination branch unexpectedly exists. Accept this only if the
# destination branch is already at the revision we want.
if src_sha1 != dst_sha1:
raise BranchError('Checkout %s already contains branch %s. Run with '
'--force-create to overwrite.'
% (checkout_name, dst_branch))
logging.info('Checkout %s already contains branch %s and it already'
' points to revision %s', checkout_name, dst_branch,
dst_sha1)
elif self._run.options.delete_branch:
# Delete the dst_branch, if it exists.
if dst_sha1:
self._DeleteBranch(src_checkout, dst_branch)
else:
raise BranchError('Checkout %s does not contain branch %s to delete.'
% (checkout_name, dst_branch))
elif self.rename_to:
# Copy src_branch to dst_branch, if it exists, then delete src_branch.
if src_sha1:
self._CopyBranch(src_checkout, src_branch, dst_branch)
self._DeleteBranch(src_checkout, src_branch)
else:
raise BranchError('Checkout %s does not contain branch %s to rename.'
% (checkout_name, src_branch))
else:
# Copy src_ref to dst_branch.
self._CopyBranch(src_checkout, src_ref, dst_branch,
force=self._run.options.force_create)
def _UpdateManifest(self, manifest_path):
"""Rewrite |manifest_path| to point at the right branch.
Args:
manifest_path: The path to the manifest file.
"""
src_manifest = git.ManifestCheckout.Cached(self._build_root,
manifest_path=manifest_path)
doc = ElementTree.parse(manifest_path)
root = doc.getroot()
# Use the local branch ref.
new_branch_name = self.rename_to if self.rename_to else self.branch_name
new_branch_name = git.NormalizeRef(new_branch_name)
logging.info('Updating manifest for %s', new_branch_name)
default_nodes = root.findall('default')
for node in default_nodes:
node.attrib['revision'] = new_branch_name
for node in root.findall('project'):
path = node.attrib['path']
checkout = src_manifest.FindCheckoutFromPath(path)
if checkout.IsBranchableProject():
# Point at the new branch.
node.attrib.pop('revision', None)
node.attrib.pop('upstream', None)
suffix = self._GetBranchSuffix(src_manifest, checkout)
if suffix:
node.attrib['revision'] = '%s%s' % (new_branch_name, suffix)
logging.info('Pointing project %s at: %s', node.attrib['name'],
node.attrib['revision'])
elif not default_nodes:
# If there isn't a default node we have to add the revision directly.
node.attrib['revision'] = new_branch_name
else:
if checkout.IsPinnableProject():
git_repo = checkout.GetPath(absolute=True)
repo_head = git.GetGitRepoRevision(git_repo)
node.attrib['revision'] = repo_head
logging.info('Pinning project %s at: %s', node.attrib['name'],
node.attrib['revision'])
else:
logging.info('Updating project %s', node.attrib['name'])
# We can't branch this repository. Leave it alone.
node.attrib['revision'] = checkout['revision']
logging.info('Project %s UNPINNED using: %s', node.attrib['name'],
node.attrib['revision'])
# Can not use the default version of get() here since
# 'upstream' can be a valid key with a None value.
upstream = checkout.get('upstream')
if upstream is not None:
node.attrib['upstream'] = upstream
doc.write(manifest_path)
return [node.attrib['name'] for node in root.findall('include')]
def _FixUpManifests(self, repo_manifest):
"""Points the checkouts at the new branch in the manifests.
Within the branch, make sure all manifests with projects that are
"branchable" are checked out to "refs/heads/<new_branch>". Do this
by updating all manifests in the known manifest projects.
"""
assert not self._run.options.delete_branch, 'Cannot fix a deleted branch.'
# Use local branch ref.
branch_ref = git.NormalizeRef(self.branch_name)
logging.debug('Fixing manifest projects for new branch.')
for project in site_config.params.MANIFEST_PROJECTS:
manifest_checkout = repo_manifest.FindCheckout(project)
manifest_dir = manifest_checkout['local_path']
push_remote = manifest_checkout['push_remote']
# Checkout revision can be either a sha1 or a branch ref.
src_ref = manifest_checkout['revision']
if not git.IsSHA1(src_ref):
src_ref = git.NormalizeRemoteRef(push_remote, src_ref)
git.CreateBranch(
manifest_dir, manifest_version.PUSH_BRANCH, src_ref)
# We want to process default.xml and official.xml + their imports.
pending_manifests = [constants.DEFAULT_MANIFEST,
constants.OFFICIAL_MANIFEST]
processed_manifests = []
while pending_manifests:
# Canonicalize the manifest name (resolve dir and symlinks).
manifest_path = os.path.join(manifest_dir, pending_manifests.pop())
manifest_path = os.path.realpath(manifest_path)
# Don't process a manifest more than once.
if manifest_path in processed_manifests:
continue
processed_manifests.append(manifest_path)
if not os.path.exists(manifest_path):
logging.info('Manifest not found: %s', manifest_path)
continue
logging.debug('Fixing manifest at %s.', manifest_path)
included_manifests = self._UpdateManifest(manifest_path)
pending_manifests += included_manifests
git.RunGit(manifest_dir, ['add', '-A'], print_cmd=True)
message = 'Fix up manifest after branching %s.' % branch_ref
git.RunGit(manifest_dir, ['commit', '-m', message], print_cmd=True)
push_to = git.RemoteRef(push_remote, branch_ref)
git.GitPush(manifest_dir, manifest_version.PUSH_BRANCH, push_to,
skip=self.skip_remote_push)
def _IncrementVersionOnDisk(self, incr_type, push_to, message):
"""Bumps the version found in chromeos_version.sh on a branch.
Args:
incr_type: See docstring for manifest_version.VersionInfo.
push_to: A git.RemoteRef object.
message: The message to give the git commit that bumps the version.
"""
version_info = manifest_version.VersionInfo.from_repo(
self._build_root, incr_type=incr_type)
version_info.IncrementVersion()
version_info.UpdateVersionFile(message,
dry_run=self.skip_remote_push,
push_to=push_to)
@staticmethod
def DetermineBranchIncrParams(version_info):
"""Determines the version component to bump for the new branch."""
# We increment the left-most component that is zero.
if version_info.branch_build_number != '0':
if version_info.patch_number != '0':
raise BranchError('Version %s cannot be branched.' %
version_info.VersionString())
return 'patch', 'patch number'
else:
return 'branch', 'branch number'
@staticmethod
def DetermineSourceIncrParams(source_name, dest_name):
"""Determines the version component to bump for the original branch."""
if dest_name.startswith('refs/heads/release-'):
return 'chrome_branch', 'Chrome version'
elif source_name == 'refs/heads/master':
return 'build', 'build number'
else:
return 'branch', 'branch build number'
def _IncrementVersionOnDiskForNewBranch(self, push_remote):
"""Bumps the version found in chromeos_version.sh on the new branch
When a new branch is created, the branch component of the new branch's
version needs to bumped.
For example, say 'stabilize-link' is created from a the 4230.0.0 manifest.
The new branch's version needs to be bumped to 4230.1.0.
Args:
push_remote: a git remote name where the new branch lives.
"""
# This needs to happen before the source branch version bumping above
# because we rely on the fact that since our current overlay checkout
# is what we just pushed to the new branch, we don't need to do another
# sync. This also makes it easier to implement skip_remote_push
# functionality (the new branch doesn't actually get created in
# skip_remote_push mode).
# Use local branch ref.
branch_ref = git.NormalizeRef(self.branch_name)
push_to = git.RemoteRef(push_remote, branch_ref)
version_info = manifest_version.VersionInfo(
version_string=self._run.options.force_version)
incr_type, incr_target = self.DetermineBranchIncrParams(version_info)
message = self.COMMIT_MESSAGE % {
'target': incr_target,
'branch': branch_ref,
}
self._IncrementVersionOnDisk(incr_type, push_to, message)
def _IncrementVersionOnDiskForSourceBranch(self, overlay_dir, push_remote,
source_branch):
"""Bumps the version found in chromeos_version.sh on the source branch
The source branch refers to the branch that the manifest used for creating
the new branch came from. For release branches, we generally branch from a
'master' branch manifest.
To work around crbug.com/213075, for both non-release and release branches,
we need to bump the Chrome OS version on the source branch if the manifest
used for branch creation is the latest generated manifest for the source
branch.
When we are creating a release branch, the Chrome major version of the
'master' (source) branch needs to be bumped. For example, if we branch
'release-R29-4230.B' from the 4230.0.0 manifest (which is from the 'master'
branch), the 'master' branch's Chrome major version in chromeos_version.sh
(which is 29) needs to be bumped to 30.
Args:
overlay_dir: Absolute path to the chromiumos overlay repo.
push_remote: The remote to push to.
source_branch: The branch that the manifest we are using comes from.
"""
push_to = git.RemoteRef(push_remote, source_branch)
self._FetchAndCheckoutTo(overlay_dir, push_to)
# Use local branch ref.
branch_ref = git.NormalizeRef(self.branch_name)
tot_version_info = manifest_version.VersionInfo.from_repo(self._build_root)
if (branch_ref.startswith('refs/heads/release-') or
tot_version_info.VersionString() == self._run.options.force_version):
incr_type, incr_target = self.DetermineSourceIncrParams(
source_branch, branch_ref)
message = self.COMMIT_MESSAGE % {
'target': incr_target,
'branch': branch_ref,
}
try:
self._IncrementVersionOnDisk(incr_type, push_to, message)
except cros_build_lib.RunCommandError:
# There's a chance we are racing against the buildbots for this
# increment. We shouldn't quit the script because of this. Instead, we
# print a warning.
self._FetchAndCheckoutTo(overlay_dir, push_to)
new_version = manifest_version.VersionInfo.from_repo(self._build_root)
if new_version.VersionString() != tot_version_info.VersionString():
logging.warning('Version number for branch %s was bumped by another '
'bot.', push_to.ref)
else:
raise
def PerformStage(self):
"""Run the branch operation."""
# Setup and initialize the repo.
super(BranchUtilStage, self).PerformStage()
repo_manifest = git.ManifestCheckout.Cached(self._build_root)
checkouts = repo_manifest.ListCheckouts()
logging.debug('Processing %d checkouts from manifest in parallel.',
len(checkouts))
args = [[repo_manifest, x] for x in checkouts]
parallel.RunTasksInProcessPool(self._ProcessCheckout, args, processes=16)
if not self._run.options.delete_branch:
self._FixUpManifests(repo_manifest)
# Increment versions for a new branch.
if not (self._run.options.delete_branch or self.rename_to):
overlay_name = 'chromiumos/overlays/chromiumos-overlay'
overlay_checkout = repo_manifest.FindCheckout(overlay_name)
overlay_dir = overlay_checkout['local_path']
push_remote = overlay_checkout['push_remote']
self._IncrementVersionOnDiskForNewBranch(push_remote)
source_branch = repo_manifest.default['revision']
self._IncrementVersionOnDiskForSourceBranch(overlay_dir, push_remote,
source_branch)