blob: 4607feccabff59da189c7e32592cd88331cf82ed [file] [log] [blame]
# Copyright 2013 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import itertools
import re
from recipe_engine import recipe_api
from recipe_engine import util as recipe_util
class GitApi(recipe_api.RecipeApi):
_GIT_HASH_RE = re.compile('[0-9a-f]{40}', re.IGNORECASE)
def __call__(self, *args, **kwargs):
"""Returns a git command step."""
name = kwargs.pop('name', 'git ' + args[0])
infra_step = kwargs.pop('infra_step', True)
git_cmd = ['git']
options = kwargs.pop('git_config_options', {})
for k, v in sorted(options.items()):
git_cmd.extend(['-c', '%s=%s' % (k, v)])
with self.m.context(cwd=(self.m.context.cwd or self.m.path.checkout_dir)):
return self.m.step(name, git_cmd + list(args), infra_step=infra_step,
**kwargs)
def fetch_tags(self, remote_name=None, **kwargs):
"""Fetches all tags from the remote."""
kwargs.setdefault('name', 'git fetch tags')
remote_name = remote_name or 'origin'
return self('fetch', remote_name, '--tags', **kwargs)
def cat_file_at_commit(self, file_path, commit_hash, remote_name=None,
**kwargs):
"""Outputs the contents of a file at a given revision."""
self.fetch_tags(remote_name=remote_name, **kwargs)
kwargs.setdefault('name', 'git cat-file %s:%s' % (commit_hash, file_path))
return self('cat-file', 'blob', '%s:%s' % (commit_hash, file_path),
**kwargs)
def count_objects(self, previous_result=None, raise_on_failure=False, **kwargs):
"""Returns `git count-objects` result as a dict.
Args:
* previous_result (dict): the result of previous count_objects call.
If passed, delta is reported in the log and step text.
* raise_on_failure (bool): if True, an exception will be raised if the
operation fails. Defaults to False.
Returns:
A dict of count-object values, or None if count-object run failed.
"""
if previous_result:
assert isinstance(previous_result, dict)
assert all(isinstance(v, int) for v in previous_result.values())
assert 'size' in previous_result
assert 'size-pack' in previous_result
step_result = None
try:
step_result = self(
'count-objects', '-v', stdout=self.m.raw_io.output(),
raise_on_failure=raise_on_failure, **kwargs)
if not step_result.stdout:
return None
result = {}
for line in step_result.stdout.splitlines():
line = line.decode('utf-8')
name, value = line.split(':', 1)
result[name] = int(value.strip())
def results_to_text(results):
return [' %s: %s' % (k, v) for k, v in sorted(results.items())]
step_result.presentation.logs['result'] = results_to_text(result)
if previous_result:
delta = {
key: value - previous_result[key]
for key, value in result.items()
if key in previous_result}
step_result.presentation.logs['delta'] = (
['before:'] + results_to_text(previous_result) +
['', 'after:'] + results_to_text(result) +
['', 'delta:'] + results_to_text(delta)
)
size_delta = (
result['size'] + result['size-pack']
- previous_result['size'] - previous_result['size-pack'])
# size_delta is in KiB.
step_result.presentation.step_text = (
'size delta: %+.2f MiB' % (size_delta / 1024.0))
return result
except Exception as ex:
if step_result:
step_result.presentation.logs['exception'] = recipe_util.format_ex(ex)
step_result.presentation.status = self.m.step.WARNING
if raise_on_failure:
raise recipe_api.InfraFailure('count-objects failed: %s' % ex)
return None
def checkout(self,
url,
ref=None,
dir_path=None,
recursive=False,
submodules=True,
submodule_update_force=False,
keep_paths=None,
step_suffix=None,
curl_trace_file=None,
raise_on_failure=True,
set_got_revision=False,
remote_name=None,
display_fetch_size=None,
file_name=None,
submodule_update_recursive=True,
use_git_cache=False,
progress=True,
tags=False,
depth=None):
"""Performs a full git checkout and returns sha1 of checked out revision.
Args:
* url (str): url of remote repo to use as upstream
* ref (str): ref to fetch and check out
* dir_path (Path): optional directory to clone into
* recursive (bool): whether to recursively fetch submodules or not
* submodules (bool): whether to sync and update submodules or not
* submodule_update_force (bool): whether to update submodules with --force
* keep_paths (iterable of strings): paths to ignore during git-clean;
paths are gitignore-style patterns relative to checkout_path.
* step_suffix (str): suffix to add to a each step name
* curl_trace_file (Path): if not None, dump GIT_CURL_VERBOSE=1 trace to that
file. Useful for debugging git issue reproducible only on bots. It has
a side effect of all stderr output of 'git fetch' going to that file.
* raise_on_failure (bool): if False, ignore errors during fetch or checkout.
* set_got_revision (bool): if True, resolves HEAD and sets got_revision
property.
* remote_name (str): name of the git remote to use
* display_fetch_size (bool): if True, run `git count-objects` before and
after fetch and display delta. Adds two more steps. Defaults to False.
* file_name (str): optional path to a single file to checkout.
* submodule_update_recursive (bool): if True, updates submodules
recursively.
* use_git_cache (bool): if True, git cache will be used for this checkout.
WARNING, this is EXPERIMENTAL!!! This wasn't tested with:
* submodules
* since origin url is modified
to a local path, may cause problem with scripts that do
"git fetch origin" or "git push origin".
* arbitrary refs such refs/whatever/not-fetched-by-default-to-cache
progress (bool): whether to show progress for fetch or not
* tags (bool): Also fetch tags.
* depth (int): if > 0, limit fetching to the given number of commits from
the tips of the remote tree.
Returns: If the checkout was successful, this returns the commit hash of
the checked-out-repo. Otherwise this returns None.
"""
retVal = None
# TODO(robertocn): Break this function and refactor calls to it.
# The problem is that there are way too many unrealated use cases for
# it, and the function's signature is getting unwieldy and its body
# unreadable.
display_fetch_size = display_fetch_size or False
if not dir_path:
dir_path = url.rsplit('/', 1)[-1]
if dir_path.endswith('.git'): # ex: https://host/foobar.git
dir_path = dir_path[:-len('.git')]
# ex: ssh://host:repo/foobar/.git
dir_path = dir_path or dir_path.rsplit('/', 1)[-1]
dir_path = self.m.path.start_dir.join(dir_path)
if 'checkout' not in self.m.path:
self.m.path.checkout_dir = dir_path
git_setup_args = ['--path', dir_path, '--url', url]
if remote_name:
git_setup_args += ['--remote', remote_name]
else:
remote_name = 'origin'
step_suffix = '' if step_suffix is None else ' (%s)' % step_suffix
self.m.step(
'git setup%s' % step_suffix,
['python3', '-u', self.resource('git_setup.py')] + git_setup_args)
# Some of the commands below require depot_tools to be in PATH.
path = self.m.path.pathsep.join([
str(self.repo_resource()), '%(PATH)s'])
with self.m.context(cwd=dir_path):
if use_git_cache:
with self.m.context(env={'PATH': path}):
self('cache', 'populate', '-c',
self.m.path.cache_dir.join('git'), url,
name='populate cache',
raise_on_failure=raise_on_failure)
dir_cmd = self(
'cache', 'exists', '--quiet',
'--cache-dir', self.m.path.cache_dir.join('git'), url,
raise_on_failure=raise_on_failure,
stdout=self.m.raw_io.output(),
step_test_data=lambda:
self.m.raw_io.test_api.stream_output('mirror_dir'))
mirror_dir = dir_cmd.stdout.strip().decode('utf-8')
self('remote', 'set-url', 'origin', mirror_dir,
raise_on_failure=raise_on_failure)
# There are five kinds of refs we can be handed:
# 0) None. In this case, we default to api.buildbucket.gitiles_commit.ref.
# 1) A fully qualified branch name, e.g. 'refs/heads/main'.
# Chop off 'refs/heads/' and now it matches case (4).
# 2) A 40-character SHA1 hash.
# 3) A fully-qualifed arbitrary ref, e.g. 'refs/foo/bar/baz'.
# 4) A branch name, e.g. 'main'.
# Note that 'FETCH_HEAD' can be many things (and therefore not a valid
# checkout target) if many refs are fetched, but we only explicitly fetch
# one ref here, so this is safe.
if not ref: # Case 0.
ref = self.m.buildbucket.gitiles_commit.ref or 'main'
# If it's a fully-qualified branch name, trim the 'refs/heads/' prefix.
if ref.startswith('refs/heads/'): # Case 1.
ref = ref[len('refs/heads/'):]
fetch_args = []
if self._GIT_HASH_RE.match(ref): # Case 2.
fetch_remote = remote_name
fetch_ref = ''
checkout_ref = ref
else: # Cases 3 and 4.
fetch_remote = remote_name
fetch_ref = ref
checkout_ref = 'FETCH_HEAD'
fetch_args = [x for x in (fetch_remote, fetch_ref) if x]
if recursive:
fetch_args.append('--recurse-submodules')
if progress:
fetch_args.append('--progress')
fetch_env = {'PATH': path}
fetch_stderr = None
if curl_trace_file:
fetch_env['GIT_CURL_VERBOSE'] = '1'
fetch_stderr = self.m.raw_io.output(leak_to=curl_trace_file)
if tags:
fetch_args.append('--tags')
if depth:
assert isinstance(depth, int)
fetch_args += ['--depth', depth]
fetch_step_name = 'git fetch%s' % step_suffix
if display_fetch_size:
count_objects_before_fetch = self.count_objects(
name='count-objects before %s' % fetch_step_name,
step_test_data=lambda: self.m.raw_io.test_api.stream_output(
self.test_api.count_objects_output(1000)))
with self.m.context(env=fetch_env):
self('fetch', *fetch_args,
name=fetch_step_name,
stderr=fetch_stderr,
raise_on_failure=raise_on_failure)
if display_fetch_size:
self.count_objects(
name='count-objects after %s' % fetch_step_name,
previous_result=count_objects_before_fetch,
step_test_data=lambda: self.m.raw_io.test_api.stream_output(
self.test_api.count_objects_output(2000)))
if file_name:
self('checkout', '-f', checkout_ref, '--', file_name,
name='git checkout%s' % step_suffix,
raise_on_failure=raise_on_failure)
else:
self('checkout', '-f', checkout_ref,
name='git checkout%s' % step_suffix,
raise_on_failure=raise_on_failure)
rev_parse_step = self('rev-parse', 'HEAD',
name='read revision',
stdout=self.m.raw_io.output_text(),
raise_on_failure=False,
step_test_data=lambda:
self.m.raw_io.test_api.stream_output_text('deadbeef'))
if rev_parse_step.presentation.status == 'SUCCESS':
sha = rev_parse_step.stdout.strip()
retVal = sha
rev_parse_step.presentation.step_text = "<br/>checked out %r<br/>" % sha
if set_got_revision:
rev_parse_step.presentation.properties['got_revision'] = sha
clean_args = list(itertools.chain(
*[('-e', path) for path in keep_paths or []]))
self('clean', '-f', '-d', '-x', *clean_args,
name='git clean%s' % step_suffix,
raise_on_failure=raise_on_failure)
if submodules:
self('submodule', 'sync',
name='submodule sync%s' % step_suffix,
raise_on_failure=raise_on_failure)
submodule_update = ['submodule', 'update', '--init']
if submodule_update_recursive:
submodule_update.append('--recursive')
if submodule_update_force:
submodule_update.append('--force')
self(*submodule_update,
name='submodule update%s' % step_suffix,
raise_on_failure=raise_on_failure)
return retVal
def get_timestamp(self, commit='HEAD', test_data=None, **kwargs):
"""Find and return the timestamp of the given commit."""
step_test_data = None
if test_data is not None:
step_test_data = lambda: self.m.raw_io.test_api.stream_output(test_data)
return self('show', commit, '--format=%at', '-s',
stdout=self.m.raw_io.output(),
step_test_data=step_test_data).stdout.rstrip().decode('utf-8')
def rebase(self, name_prefix, branch, dir_path, remote_name=None,
**kwargs):
"""Runs rebase HEAD onto branch
Args:
* name_prefix (str): a prefix used for the step names
* branch (str): a branch name or a hash to rebase onto
* dir_path (Path): directory to clone into
* remote_name (str): the remote name to rebase from if not origin
"""
remote_name = remote_name or 'origin'
with self.m.context(cwd=dir_path):
try:
self('rebase', '%s/main' % remote_name,
name="%s rebase" % name_prefix, **kwargs)
except self.m.step.StepFailure:
self('rebase', '--abort', name='%s rebase abort' % name_prefix,
**kwargs)
raise
def config_get(self, prop_name, **kwargs):
"""Returns git config output.
Args:
* prop_name: (str) The name of the config property to query.
* kwargs: Forwarded to '__call__'.
Returns: (str) The Git config output, or None if no output was generated.
"""
kwargs['name'] = kwargs.get('name', 'git config %s' % (prop_name,))
result = self('config', '--get', prop_name, stdout=self.m.raw_io.output(),
**kwargs)
value = result.stdout
if value:
value = value.strip()
result.presentation.step_text = value.decode('utf-8')
return value
def get_remote_url(self, remote_name=None, **kwargs):
"""Returns the remote Git repository URL, or None.
Args:
* remote_name: (str) The name of the remote to query, defaults to 'origin'.
* kwargs: Forwarded to '__call__'.
Returns: (str) The URL of the remote Git repository, or None.
"""
remote_name = remote_name or 'origin'
return self.config_get('remote.%s.url' % (remote_name,), **kwargs)
def bundle_create(self, bundle_path, rev_list_args=None, **kwargs):
"""Runs 'git bundle create' on a Git repository.
Args:
* bundle_path (Path): The path of the output bundle.
* refs (list): The list of refs to include in the bundle. If None, all
refs in the Git checkout will be bundled.
* kwargs: Forwarded to '__call__'.
"""
if not rev_list_args:
rev_list_args = ['--all']
self('bundle', 'create', bundle_path, *rev_list_args, **kwargs)
def new_branch(self,
branch,
name=None,
upstream=None,
upstream_current=False,
**kwargs):
"""Runs git new-branch on a Git repository, to be used before git cl
upload.
Args:
* branch (str): new branch name, which must not yet exist.
* name (str): step name.
* upstream (str): to origin/main.
* upstream_current (bool): whether to use '--upstream_current'.
* kwargs: Forwarded to '__call__'.
"""
if upstream and upstream_current:
raise ValueError('Can not define both upstream and upstream_current')
env = self.m.context.env
env['PATH'] = self.m.path.pathsep.join([
str(self.repo_resource()), '%(PATH)s'])
args = ['new-branch', branch]
if upstream:
args.extend(['--upstream', upstream])
if upstream_current:
args.append('--upstream_current')
if not name:
name = 'git new-branch %s' % branch
with self.m.context(env=env):
return self(*args, name=name, **kwargs)
def number(self, commitrefs=None, test_values=None):
"""Computes the generation number of some commits.
Args:
* commitrefs (list[str]): A list of commit references. If none are
provided, the generation number for HEAD will be retrieved.
* test_values (list[str]): A list of numbers to use as the return
value during tests. It is an error if the length of the list
does not match the number of commitrefs (1 if commitrefs is not
provided).
Returns:
A list of strings containing the generation numbers of the commits.
If non-empty commitrefs was provided, the order of the returned
numbers will correspond to the order of the provided commitrefs.
"""
def step_test_data():
refs = commitrefs or ['HEAD']
if test_values:
assert len(test_values) == len(refs)
values = test_values or range(3000, 3000 + len(refs))
output = '\n'.join(str(v) for v in values)
return self.m.raw_io.test_api.stream_output_text(output)
args = ['number']
args.extend(commitrefs or [])
# Put depot_tools on the path so that git-number can be found
with self.m.depot_tools.on_path():
# git-number is only meant for use on bots, so it prints an error message
# if CHROME_HEADLESS is not set
with self.m.context(env={'CHROME_HEADLESS': '1'}):
step_result = self(*args,
stdout=self.m.raw_io.output_text(add_output_log=True),
step_test_data=step_test_data)
return [l.strip() for l in step_result.stdout.strip().splitlines()]