| # -*- coding: utf-8 -*- |
| # Copyright 2020 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. |
| |
| # Copied from |
| # https://chromium.googlesource.com/chromiumos/infra/recipes/+/HEAD/recipe_modules/repo/ |
| """API for working with the 'repo' VCS tool. |
| |
| See: https://chromium.googlesource.com/external/repo/ |
| """ |
| |
| import collections |
| import os |
| import types |
| from xml.etree import cElementTree as ElementTree |
| |
| from recipe_engine import recipe_api |
| |
| MANIFEST_MOCK = """ |
| <manifest> |
| <project path="SAMPLE" revision="FROM_REV"/> |
| </manifest> |
| """ |
| |
| ManifestDiff = collections.namedtuple( |
| 'ManifestDiff', ['name', 'path', 'from_rev', 'to_rev'] |
| ) |
| |
| ProjectInfo = collections.namedtuple( |
| 'ProjectInfo', ['name', 'path', 'remote', 'branch'] |
| ) |
| |
| _REPO_URL = 'https://pigweed.googlesource.com/third_party/gerrit/git-repo' |
| _REPO_REV = 'b750b48f50eb4a11087ca6775161d5bf4d5c47d5' |
| |
| |
| class RepoApi(recipe_api.RecipeApi): |
| """A module for interacting with the repo tool.""" |
| |
| ManifestDiff = ManifestDiff # pylint: disable=invalid-name |
| |
| def __init__(self, *args, **kwargs): |
| super().__init__(*args, **kwargs) |
| self._repo = None |
| |
| @property |
| def repo(self): |
| return self.resource('repo') |
| |
| def _find_root(self): |
| """Starting from cwd, find an ancestor with a '.repo' subdir.""" |
| # We need a copy of cwd that we can modify. join() with no arguments |
| # returns the instance, so we add an element, and then remove it. |
| candidate = self.m.context.cwd |
| while candidate.pieces: |
| if self.m.path.exists(candidate / '.repo'): |
| return candidate |
| candidate = self.m.path.dirname(candidate) |
| return None |
| |
| def _step(self, args, name=None, attempts=1, **kwargs): |
| """Executes 'repo' with the supplied arguments. |
| |
| Args: |
| args (list): A list of arguments to supply to 'repo'. |
| name (str): The name of the step. If None, generate from the args. |
| attempts (int): Number of attempts. |
| **kwargs: See 'step.__call__'. |
| |
| Returns: |
| StepData: See 'step.__call__'. |
| """ |
| if name is None: |
| name = 'repo' |
| # Add first non-flag argument to name. |
| for arg in args: |
| if isinstance(arg, str) and arg[:1] != '-': |
| name += ' ' + arg |
| break |
| kwargs.setdefault('infra_step', True) |
| for i in range(attempts): |
| try: |
| return self.m.step(name, [self.repo] + args, **kwargs) |
| except self.m.step.StepFailure: |
| if i == attempts - 1: |
| raise |
| |
| def _clear_git_locks(self): |
| """Removes any git locks found in the entire repo checkout.""" |
| |
| repo_cmd = [ |
| 'find', |
| '.repo/', |
| '-type', |
| 'f', |
| '-name', |
| '*.lock', |
| '-print', |
| '-delete', |
| ] |
| self.m.step('clear repo locks', repo_cmd, infra_step=True) |
| |
| try: |
| git_cmd = [ |
| 'forall', |
| '--ignore-missing', |
| '-j', |
| '32', |
| '-c', |
| 'find', |
| '.git/', |
| '-type', |
| 'f', |
| '-name', |
| '*.lock', |
| '-print', |
| '-delete', |
| ] |
| self._step(git_cmd, 'clear git locks') |
| except recipe_api.StepFailure: # pragma: nocover |
| # try again without the --ignore-missing |
| git_cmd = [ |
| 'forall', |
| '-j', |
| '32', |
| '-c', |
| 'find', |
| '.git/', |
| '-type', |
| 'f', |
| '-name', |
| '*.lock', |
| '-print', |
| '-delete', |
| ] |
| self._step(git_cmd, 'retry clear git locks') |
| |
| def init( # pylint: disable=invalid-name |
| self, |
| manifest_url, |
| _kwonly=(), |
| manifest_branch=None, |
| manifest_name=None, |
| reference=None, |
| groups=None, |
| depth=None, |
| repo_url=_REPO_URL, |
| repo_rev=_REPO_REV, |
| timeout=15 * 60, |
| **kwargs, |
| ): |
| """Executes 'repo init' with the given arguments. |
| |
| Args: |
| manifest_url (str): URL of the manifest repository to clone. |
| _kwonly: Fake argument. |
| manifest_branch (str): Manifest repository branch to checkout. |
| manifest_name (str): Manifest file to use. |
| reference (str): Location of a mirror directory to bootstrap sync. |
| groups (list): Groups to checkout (see `repo init --groups`). |
| depth (int): Create a shallow clone of the given depth. |
| repo_url (str): URL of the repo repository. |
| repo_rev (str): Repo binary revision to use. |
| timeout (int): Timeout in seconds. |
| **kwargs: Passed through to self.m.step(). |
| """ |
| assert ( |
| _kwonly == () |
| ), ( # pylint: disable=g-explicit-bool-comparison |
| 'init accepts only 1 positional arg' |
| ) |
| cmd = ['init', '--manifest-url', manifest_url, '--groups', 'all'] |
| if manifest_branch is not None: |
| cmd += ['--manifest-branch', manifest_branch] |
| if reference is not None: |
| cmd += ['--reference', reference] |
| if groups is not None: |
| assert not isinstance(groups, str) |
| cmd += ['--groups', ','.join(groups)] |
| if depth is not None: |
| cmd += ['--depth', '%d' % depth] |
| if repo_url is not None: |
| cmd += ['--repo-url', repo_url] |
| if repo_rev is not None: |
| cmd += ['--repo-rev', repo_rev, '--no-repo-verify'] |
| if manifest_name: |
| cmd += ['--manifest-name', manifest_name] |
| self._step(cmd, timeout=timeout, **kwargs) |
| self._clear_git_locks() |
| |
| if self.m.context.cwd: |
| self.m.path.mock_add_paths(self.m.context.cwd / '.repo') |
| |
| def sync( # pylint: disable=invalid-name |
| self, |
| _kwonly=(), |
| force_sync=False, |
| detach=False, |
| current_branch=False, |
| jobs=None, |
| manifest_name=None, |
| no_tags=False, |
| optimized_fetch=False, |
| cache_dir=None, |
| timeout=None, |
| attempts=1, |
| verbose=True, |
| **kwargs, |
| ): |
| """Executes 'repo sync' with the given arguments. |
| |
| Args: |
| _kwonly: Fake argument. |
| force_sync (bool): Overwrite existing git directories if needed. |
| detach (bool): Detach projects back to manifest revision. |
| current_branch (bool): Fetch only current branch. |
| jobs (int): Projects to fetch simultaneously. |
| manifest_name (str): Temporary manifest to use for this sync. |
| no_tags (bool): Don't fetch tags. |
| optimized_fetch (bool): Only fetch projects if revision doesn't exist. |
| cache_dir (Path): Use git-cache with this cache directory. |
| timeout (int): Timeout in seconds. |
| attempts (int): Number of attempts. |
| verbose (bool): Show all output. |
| **kwargs: Passed through to self.m.step(). |
| """ |
| assert ( |
| _kwonly == () |
| ), ( # pylint: disable=g-explicit-bool-comparison |
| 'init accepts only 1 positional arg' |
| ) |
| cmd = ['sync'] |
| if force_sync: |
| cmd += ['--force-sync'] |
| if detach: |
| cmd += ['--detach'] |
| if current_branch: |
| cmd += ['--current-branch'] |
| if jobs is not None: |
| cmd += ['--jobs', '%d' % jobs] |
| if manifest_name is not None: |
| cmd += ['--manifest-name', manifest_name] |
| if no_tags: |
| cmd += ['--no-tags'] |
| if optimized_fetch: |
| cmd += ['--optimized-fetch'] |
| if cache_dir is not None: |
| cmd += ['--cache-dir', cache_dir] |
| if verbose: |
| cmd += ['--verbose'] |
| |
| # Doing retry logic here instead of using that in _step() because we |
| # need to clean up the checkout or we sometimes get inconsistent |
| # results. |
| for i in range(attempts): |
| try: |
| return self._step(cmd, name=None, timeout=timeout, **kwargs) |
| except self.m.step.StepFailure: |
| if i == attempts - 1: |
| raise |
| self._step( |
| ['forall', '-c', 'git reset --hard HEAD'], ok_ret='any' |
| ) |
| |
| def sync_manifest(self, manifest_data, **kwargs): |
| """Sync to the given manifest file data. |
| |
| Args: |
| manifest_data (str): Manifest XML data to use for the sync. |
| **kwargs: Keyword arguments to pass to 'repo.sync'. |
| """ |
| repo_root = self._find_root() |
| assert repo_root is not None, 'no repo root found' |
| |
| manifest_path = self.m.path.mkstemp('manifest') |
| self.m.file.write_raw('write manifest', manifest_path, manifest_data) |
| |
| repo_manifests_path = repo_root / '.repo' / 'manifests' |
| manifest_relpath = os.path.relpath( |
| str(manifest_path), str(repo_manifests_path) |
| ) |
| self.sync(manifest_name=manifest_relpath, **kwargs) |
| |
| def start(self, branch, projects=None): |
| """Start a new branch in the given projects, or all projects if not set. |
| |
| Args: |
| branch (str): The new branch name. |
| projects (list[str]): The projects for which to start a branch. |
| """ |
| cmd = ['start', branch] |
| if projects is not None: |
| cmd.extend(projects) |
| else: |
| cmd.append('--all') |
| self._step(cmd) |
| |
| def project_infos(self, projects=()): |
| """Uses 'repo forall' to gather project information. |
| |
| Args: |
| projects (List[str]): Project names or paths to return info for. Defaults |
| to all projects. |
| |
| Returns: |
| List[ProjectInfo]: Requested project infos. |
| """ |
| |
| def step_test_data(): |
| data = '\n'.join( |
| f'{p}|src/{p}|cros|refs/heads/main' |
| for p in projects or ['a', 'b', 'c'] |
| ) |
| return self.m.raw_io.test_api.stream_output_text(data) |
| |
| cmd = ['forall'] + list(projects) |
| cmd += [ |
| '-c', |
| r'echo $REPO_PROJECT\|$REPO_PATH\|$REPO_REMOTE\|$REPO_RREV', |
| ] |
| step_data = self._step( |
| cmd, |
| stdout=self.m.raw_io.output_text(add_output_log=True), |
| step_test_data=step_test_data, |
| ) |
| |
| infos = [] |
| for line in step_data.stdout.strip().split('\n'): |
| name, path, remote, rrev = line.split('|') |
| branch = None |
| if rrev.startswith('refs/heads/'): |
| branch = rrev |
| infos.append(ProjectInfo(name, path, remote, branch)) |
| return infos |
| |
| def project_info(self, project): |
| """Use 'repo forall' to gather project information for one project. |
| |
| Args: |
| project (str|Path): Project name or path to return info for. |
| |
| Returns: |
| ProjectInfo: The request project info. |
| """ |
| project_infos = self.project_infos(projects=[project]) |
| assert len(set(project_infos)) == 1, 'expected one project' |
| return project_infos[0] |
| |
| def manifest_snapshot(self, manifest_file=None): |
| """Uses repo to create a manifest snapshot and returns it as a string. |
| |
| By default uses the internal .repo manifest, but can optionally take |
| another manifest to use. |
| |
| Args: |
| manifest_file (Path): If given, path to alternate manifest file to use. |
| |
| Returns: |
| str: The manifest XML as a string. |
| """ |
| step_test_data = lambda: ( # pylint: disable=g-long-lambda |
| self.m.raw_io.test_api.stream_output_text('<manifest></manifest>') |
| ) |
| |
| cmd = ['manifest', '-r'] |
| if manifest_file: |
| cmd += ['-m', manifest_file] |
| |
| step_data = self._step( |
| cmd, |
| stdout=self.m.raw_io.output_text(add_output_log=True), |
| step_test_data=step_test_data, |
| ) |
| return step_data.stdout.strip() |
| |
| def diff_manifests(self, from_manifest_str, to_manifest_str): |
| """Diffs the two manifests and returns an array of differences. |
| |
| Given the two manifest XML strings, generates an array of `ManifestDiff`. |
| This only returns **CHANGED** projects, it skips over projects that were |
| added or deleted. |
| |
| Args: |
| from_manifest_str (str): The from manifest XML string |
| to_manifest_str (str): The to manifest XML string. |
| |
| Returns: |
| List[ManifestDiff]: An array of `ManifestDiff` namedtuple for any existing |
| changed project (excludes added/removed projects). |
| """ |
| |
| def project_paths(xml_data): |
| xml = ElementTree.fromstring(xml_data) |
| attrs = [proj.attrib for proj in xml.iterfind('project')] |
| # Make sure `path` is set, use `name` if `path` is missing |
| for attr in attrs: |
| attr['path'] = attr.get('path', attr.get('name')) |
| # Key them by path |
| return {attr['path']: attr for attr in attrs} |
| |
| from_paths = project_paths(from_manifest_str) |
| to_paths = project_paths(to_manifest_str) |
| changes = [] |
| for from_path, from_attrs in from_paths.items(): |
| if from_path not in to_paths: |
| # Project was deleted, we don't care. Move on, nothing to see here! |
| continue |
| from_name = from_attrs['name'] |
| from_revision = from_attrs['revision'] |
| to_revision = to_paths[from_path]['revision'] |
| if from_revision == to_revision: |
| # The revision didn't change (aka no CLs landed between the last |
| # snapshot and this one for that path. |
| continue |
| changes.append( |
| ManifestDiff(from_name, from_path, from_revision, to_revision) |
| ) |
| return changes |
| |
| def diff_manifests_informational( |
| self, old_manifest_path, new_manifest_path |
| ): |
| """Informational step that logs a "manifest diff". |
| |
| Args: |
| old_manifest_path (Path): Path to old manifest file. |
| new_manifest_path (Path): Path to new manifest file. |
| """ |
| name = 'manifest diff' |
| # Manifest paths must be relative to the current repo .repo/manifests dir. |
| repo_root = self._find_root() |
| if repo_root is None: |
| step = self.m.step(name, []) |
| step.presentation.step_text = ( |
| 'manifest diff failed; no repo root found' |
| ) |
| return |
| manifests_dir = self.m.path.abspath(repo_root / '.repo' / 'manifests') |
| |
| cmd = [ |
| 'diffmanifests', |
| os.path.relpath(str(old_manifest_path), manifests_dir), |
| os.path.relpath(str(new_manifest_path), manifests_dir), |
| ] |
| self._step(cmd, name=name) |
| |
| def _git_clean_checkout(self, root_path): |
| """Ensure the given repo does not contain untracked files or directories. |
| |
| We're assuming that the root path provided has already been validated. |
| |
| Args: |
| root_path (Path): Path to the repo root. |
| """ |
| with self.m.step.nest('ensure clean checkout'): |
| with self.m.context(cwd=root_path, infra_steps=True): |
| cmd = [ |
| 'forall', |
| '--ignore-missing', |
| '-j', |
| '32', |
| '-c', |
| 'git', |
| 'clean', |
| '-d', |
| '-f', |
| ] |
| self._step( |
| cmd, stdout=self.m.raw_io.output_text(add_output_log=True) |
| ) |
| |
| def ensure_synced_checkout( |
| self, root_path, manifest_url, init_opts=None, sync_opts=None |
| ): |
| """Ensure the given repo checkout exists and is synced. |
| |
| Args: |
| root_path (Path): Path to the repo root. |
| manifest_url (str): Manifest URL for 'repo.init`. |
| init_opts (dict): Extra keyword arguments to pass to 'repo.init'. |
| sync_opts (dict): Extra keyword arguments to pass to 'repo.sync'. |
| """ |
| with self.m.step.nest('ensure synced checkout'): |
| self.m.file.ensure_directory('ensure root path', root_path) |
| with self.m.context(cwd=root_path, infra_steps=True): |
| for retries in range(2): |
| try: |
| # Remove .repo/manifests and .repo/manifests.git to avoid potential |
| # problems when switching to a different manifest repo or branch. |
| for manifest_dir in ('manifests', 'manifests.git'): |
| self.m.file.rmtree( |
| f'remove .repo/{manifest_dir}', |
| root_path / '.repo' / manifest_dir, |
| ) |
| |
| self.init(manifest_url, **(init_opts or {})) |
| self._git_clean_checkout(root_path) |
| self._binary_selfupdate(root_path) |
| self.sync(**(sync_opts or {})) |
| break |
| except recipe_api.StepFailure: |
| if retries < 1: |
| self.m.file.rmcontents( |
| 'clean up root path and retry', root_path |
| ) |
| else: |
| raise |
| |
| # Sanity check since `repo init` will happily reuse a repository in the |
| # cwd's ancestor directories. |
| assert self.m.path.exists(root_path / '.repo'), '.repo not created!' |
| |
| def _binary_selfupdate(self, root_path): |
| """Issues a repo selfupdate to update the binary. |
| |
| Args: |
| root_path (Path): Path to the repo root. |
| """ |
| with self.m.step.nest('repo binary update'): |
| with self.m.context(cwd=root_path, infra_steps=True): |
| cmd = ['selfupdate'] |
| self._step(cmd, ok_ret='any') |