| #!/usr/bin/env fuchsia-vendored-python | |
| # Copyright 2021 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| #### CATEGORY=Source tree | |
| #### PREBUILTS=${PREBUILT_GSUTIL} ${PREBUILT_PYTHON3} | |
| ### Sync the local Fuchsia source tree to a given state | |
| import argparse | |
| import enum | |
| import functools | |
| import json | |
| import os | |
| import pathlib | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from typing import List, Union | |
| import urllib.parse | |
| HELP = """\ | |
| ## usage: fx sync-to [-h|--help] <STATE> | |
| ## | |
| ## To reproduce builds at a given repo state, or bisect bugs among a series of | |
| ## checkins, this command synchronizes the local Fuchsia source tree to a given | |
| ## state. The state can be specified in one of the following ways: | |
| ## | |
| ## BUILD_ID: a large number like '8938070794014050064' (preceded or not by a | |
| ## 'b' letter), which is the "Build #" in a builder's "Ended builds" page, | |
| ## for example: | |
| ## https://luci-milo.appspot.com/p/fuchsia/builders/try/core.x64-asan | |
| ## | |
| ## RELEASE_TAG: a string like "releases/0.20190927.1.1", representing a | |
| ## git tag in the //integration repository. To find available tags, run | |
| ## `git tags -l` in your local integration repository. | |
| ## | |
| ## BRANCH_NAME: a string like "refs/heads/releases/f1", representing a | |
| ## git branch (head) in the //integration repository. | |
| ## | |
| ## JIRI_HISTORY_TIMESTAMP: a timestamp like "2019-03-21T15:30:00-07:00". | |
| ## This is local to your tree, and represents a moment where you | |
| ## previously ran `jiri update`. To find available timestamps, look at | |
| ## files in ${FUCHSIA_DIR}/.jiri_root/update_history/ | |
| ## | |
| ## INTEGRATION_GIT_COMMIT: a 3-40 character commit hash like "e9d97d1" in the | |
| ## integration repo. Can be optionally prefixed with 'git:' (e.g. | |
| ## "git:e9d97d1") to disambiguate from BUILD_ID. To find valid commits, | |
| ## look at your integration commit history: | |
| ## git -C ${FUCHSIA_DIR}/integration log --oneline | |
| ## | |
| ## "reset": | |
| ## Use "reset" to return to the top of the tree. This is equivalent to: | |
| ## git -C ${FUCHSIA_DIR}/integration checkout JIRI_HEAD && jiri update -gc | |
| ## | |
| ## Known limitations: | |
| ## - Does not work on CI builds triggered on repos other than integration (very | |
| ## rare). It works on all CQ builds and all CI builds triggered by | |
| ## integration.git commits. | |
| ## - Does not respect `attributes`. Local attributes will not be overridden (so | |
| ## the checkout may contain some extra repositories), and | |
| ## attributes used by an infra build will not be reproduced | |
| ## | |
| ## Examples: | |
| ## | |
| ## # Sync to the source used by build https://ci.chromium.org/b/8835832080588336881 | |
| ## fx sync-to 8835832080588336881 | |
| ## | |
| ## # Sync to the source tagged as release 0.20210822.2.5: | |
| ## fx sync-to releases/0.20210822.2.5 | |
| ## | |
| ## # Sync to the same tree updated in 2021-08-28T14:26:22-07:00 (this is | |
| ## # local to your local tree - to reproduce, look for timestamps in your | |
| ## # own .jiri_root/update_history directory): | |
| ## fx sync-to 2021-08-28T14:26:22-07:00 | |
| ## | |
| ## # Sync to integration commit 901ed5b | |
| ## # (https://fuchsia.googlesource.com/integration/+/901ed5bf7db253bb6feb4832ac1a752248e2361d): | |
| ## fx sync-to 901ed5b | |
| ## | |
| ## # Sync to release branch f1: | |
| ## fx sync-to refs/heads/releases/f1 | |
| ## | |
| ## # Restore your source to the top of the tree: | |
| ## fx sync-to reset | |
| ## | |
| """ | |
| # The name of the temporary integration.git branch that we'll use for checking | |
| # out an integration revision to sync to. | |
| TEMP_BRANCH_NAME = "_fx-sync-to" | |
| # Matches release branch names in the integration repository. | |
| RELEASE_BRANCH_REGEX = r"^refs/heads/([/0-9.A-Za-z-]+)$" | |
| # Matches release tags in the integration repository. | |
| RELEASE_TAG_REGEX = r"^releases\/[0-9.A-Z]+$" | |
| # Matches a timestamp of the form that Jiri uses for names of snapshot files in | |
| # the .jiri_root/update_history dir. | |
| TIMESTAMP_REGEX = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]+(Z|[-+][0-9:]+)$" | |
| # Matches a git revision, with an optional "git:" prefix to distinguish an | |
| # all-digit revision from a LUCI build ID. | |
| GIT_REVISION_REGEX = r"^(git:)?[0-9a-f]{3,40}$" | |
| # Matches a LUCI build ID, with an optional leading "b" like is included in LUCI | |
| # build URLs. | |
| BUILD_ID_REGEX = r"^b?[0-9]+$" | |
| class Error(Exception): | |
| """Raise this type to present a user-friendly error instead of a stacktrace. | |
| By default, causes the program to return a nonzero return code. To cause the | |
| program to emit a return code of zero, pass `fail=False`. | |
| """ | |
| def __init__(self, msg: str, fail: bool = True): | |
| self.msg = msg | |
| self.fail = fail | |
| class Color(enum.Enum): | |
| RESET = "\33[0m" | |
| RED = "\33[31m" | |
| GREEN = "\33[32m" | |
| YELLOW = "\33[33m" | |
| def colorize(text: str, color: Color) -> str: | |
| """Wrap `text` in terminal color directives. | |
| The return value will show up as the given color when printed in a terminal. | |
| """ | |
| return f"{color.value}{text}{Color.RESET.value}" | |
| class SyncToCommand: | |
| def __init__(self, args: argparse.Namespace, fuchsia_dir: str): | |
| self.fuchsia_dir = pathlib.Path(fuchsia_dir) | |
| # TODO(olivernewman): Infer the project name from the git remote | |
| self.project = "integration" | |
| self.help: bool = args.help | |
| self.state: str = args.state | |
| self.dry_run: bool = args.dry_run | |
| self.force: bool = args.force | |
| self.verbose: bool = args.verbose | |
| self.jiri_update_prefix_args: list[str] = ["-v"] if self.verbose else [] | |
| @property | |
| def integration_dir(self) -> pathlib.Path: | |
| return self.fuchsia_dir.joinpath("integration") | |
| @functools.cached_property | |
| def gsutil_path(self) -> str: | |
| gsutil = shutil.which("gsutil") | |
| if not gsutil: | |
| raise Error( | |
| "Cannot find gsutil. Please install gcloud and run 'gcloud init'.\n" | |
| "See https://cloud.google.com/storage/docs/gsutil_install" | |
| ) | |
| return gsutil | |
| def run(self) -> None: | |
| if self.help: | |
| for line in HELP.splitlines(): | |
| print(line[3:]) | |
| return | |
| if not self.state: | |
| raise Error("A single positional argument is required.") | |
| if self.dry_run: | |
| print( | |
| colorize( | |
| "Running in dry-run mode. No changes will be made to your checkout.", | |
| Color.GREEN, | |
| ) | |
| ) | |
| if self.state == "reset": | |
| self.reset_checkout() | |
| elif re.match(RELEASE_BRANCH_REGEX, self.state): | |
| self.sync_to_release_branch() | |
| elif re.match(RELEASE_TAG_REGEX, self.state): | |
| self.sync_to_release_tag() | |
| elif re.match(TIMESTAMP_REGEX, self.state): | |
| self.sync_to_timestamp() | |
| elif not self.state.startswith("git:") and re.match( | |
| BUILD_ID_REGEX, self.state | |
| ): | |
| self.sync_to_build_id() | |
| # Git short SHA-1s can be any length up to 40, but assume at least three | |
| # characters. | |
| elif re.match(GIT_REVISION_REGEX, self.state): | |
| self.sync_to_integration_commit() | |
| else: | |
| raise Error( | |
| f"Unsupported state definition format: {self.state!r}.\n" | |
| "Use -h for help." | |
| ) | |
| if not self.dry_run: | |
| self.print_synced_revision() | |
| def set_baseline(self) -> None: | |
| """Set the checkout to a known-good state. | |
| Should be run before taking any syncing actions that change the state of | |
| the checkout. | |
| """ | |
| self.integration_fetch() | |
| self.confirm_ok_to_change_source_code() | |
| # Force-checkout every repository at JIRI_HEAD to ensure subsequent | |
| # `jiri update` calls won't skip repos not at JIRI_HEAD. This will | |
| # intentionally fail if there are any uncommitted changes (which should | |
| # have been detected by `confirm_ok_to_change_source_code` anyway) to | |
| # avoid permanently deleting code. | |
| self.jiri( | |
| "runp", | |
| f"-exit-on-error={str(not self.force).lower()}", | |
| "git checkout --quiet JIRI_HEAD", | |
| ) | |
| @functools.cache # Need only be run once per `sync-to` run. | |
| def integration_fetch(self) -> None: | |
| """Fetch all remote refs of the integration repo. | |
| This ensures the local listing of valid integration refs is up-to-date. | |
| """ | |
| # Always update the integration repo to avoid missing refs. This should | |
| # be a safe operation, and it avoids false negatives. | |
| print("Fetching refs in the integration repo...") | |
| self.run_command( | |
| "git", | |
| "-C", | |
| self.integration_dir, | |
| "fetch", | |
| "--quiet", | |
| "origin", | |
| stderr=subprocess.PIPE, | |
| dry_run_safe=True, | |
| ) | |
| def reset_checkout(self) -> None: | |
| """Sync all repos back to JIRI_HEAD.""" | |
| self.confirm_ok_to_change_source_code() | |
| # TODO(olivernewman): Have `fx sync-to` create a snapshot of the | |
| # checkout prior to syncing and have `reset` restore the exact contents | |
| # of the old checkout instead of always going back to JIRI_HEAD. | |
| print("Resetting the tree to the latest...") | |
| self.jiri(*self.jiri_update_prefix_args, "update", "-gc") | |
| def sync_to_release_branch(self) -> None: | |
| print(f"{self.state!r} looks like a branch name") | |
| branch = "origin/" + remove_prefix(self.state, "refs/heads/") | |
| if not self.is_valid_integration_rev(branch): | |
| raise Error( | |
| f"Invalid remote branch, is {self.state!r} a valid head in" | |
| " an integration repo other than the one your source is using?\n\n" | |
| "See valid branches by running:\n\n" | |
| " git -C ${FUCHSIA_DIR}/integration ls-remote --heads" | |
| ) | |
| self.sync_to_integration_ref(branch) | |
| def sync_to_release_tag(self) -> None: | |
| print(f"{self.state!r} looks like a release tag") | |
| if not self.is_valid_integration_rev(self.state): | |
| raise Error( | |
| f"Invalid release tag, is {self.state!r} a tag from" | |
| " an integration repo other than the one your source is using?\n\n" | |
| "See valid tags by running:\n\n" | |
| " git -C ${FUCHSIA_DIR}/integration tag -l" | |
| ) | |
| self.sync_to_integration_ref(f"tags/{self.state}") | |
| def sync_to_integration_commit(self) -> None: | |
| print(f"{self.state!r} looks like an integration git commit") | |
| revision = remove_prefix(self.state, "git:") | |
| if not self.is_valid_integration_rev(revision): | |
| raise Error( | |
| f"Commit not found, is {self.state!r} a commit from a repo other" | |
| " than your local integration repo?" | |
| "See valid commits by running:\n" | |
| " git -C ${FUCHSIA_DIR}/integration log --oneline" | |
| ) | |
| self.sync_to_integration_ref(revision) | |
| def sync_to_timestamp(self) -> None: | |
| """Sync the checkout based on a Jiri snapshot XML file from a past timestamp.""" | |
| print(f"{self.state!r} looks like a local Jiri update timestamp") | |
| update_history_dir = self.fuchsia_dir.joinpath( | |
| ".jiri_root", "update_history" | |
| ) | |
| snapshot_file = update_history_dir.joinpath(self.state) | |
| if not os.path.exists(snapshot_file): | |
| raise Error( | |
| "Invalid Jiri history timestamp. See valid options by running:\n" | |
| f" ls {update_history_dir}" | |
| ) | |
| self.sync_to_snapshot(snapshot_file) | |
| def sync_to_snapshot(self, snapshot_file: pathlib.Path) -> None: | |
| integration_remote = ( | |
| self.jiri( | |
| "manifest", | |
| "-element=" + self.project, | |
| "-template={{.Remote}}", | |
| snapshot_file, | |
| dry_run_safe=True, | |
| stdout=subprocess.PIPE, | |
| ) | |
| .stdout.strip() | |
| .splitlines()[-1] | |
| ) | |
| self.assert_correct_integration_repo(integration_remote) | |
| self.set_baseline() | |
| print(f"Syncing to Jiri snapshot {str(snapshot_file)!r}") | |
| self.jiri( | |
| *self.jiri_update_prefix_args, | |
| "update", | |
| "-local-manifest", | |
| "-gc", | |
| snapshot_file, | |
| ) | |
| def sync_to_integration_ref(self, ref: str) -> None: | |
| """Sync to a branch, tag, or revision of the integration repo.""" | |
| self.set_baseline() | |
| print(f"Checking out integration repo at {ref}") | |
| self.run_command( | |
| "git", | |
| "-C", | |
| self.integration_dir, | |
| "checkout", | |
| # Create a new branch; if we checked out without branching, `jiri | |
| # update` would reset integration back to JIRI_HEAD. | |
| "-B", | |
| TEMP_BRANCH_NAME, | |
| # Don't auto-track any remote branch. Otherwise `fx sync-to reset` | |
| # might fail to switch off a branch that's behind the remote branch | |
| # it's tracking. | |
| "--no-track", | |
| ref, | |
| ) | |
| try: | |
| self.jiri( | |
| *self.jiri_update_prefix_args, | |
| "update", | |
| "-local-manifest", | |
| "-gc", | |
| ) | |
| except subprocess.CalledProcessError: | |
| print("Sync failed, restoring the integration repo to JIRI_HEAD") | |
| self.run_command( | |
| "git", "-C", self.integration_dir, "checkout", "JIRI_HEAD" | |
| ) | |
| raise | |
| def is_valid_integration_rev(self, rev: str) -> bool: | |
| """Determine whether `rev` exists in the local integration repo.""" | |
| self.integration_fetch() | |
| return ( | |
| self.run_command( | |
| "git", | |
| "-C", | |
| self.integration_dir, | |
| "rev-parse", | |
| "--verify", | |
| "-q", | |
| rev, | |
| check=False, | |
| dry_run_safe=True, | |
| ).returncode | |
| == 0 | |
| ) | |
| def assert_correct_integration_repo(self, remote: str) -> None: | |
| local_remote = self.run_command( | |
| "git", | |
| "-C", | |
| self.integration_dir, | |
| "remote", | |
| "get-url", | |
| "origin", | |
| stdout=subprocess.PIPE, | |
| dry_run_safe=True, | |
| ).stdout.strip() | |
| if self.https_to_sso(local_remote) != self.https_to_sso(remote): | |
| raise Error( | |
| f"Integration remote {remote!r} does not match local" | |
| f" checkout remote {local_remote!r}." | |
| f"\n" | |
| f"\nIf syncing to a public build ID, use a public checkout:" | |
| f"\nhttps://fuchsia.dev/fuchsia-src/get-started/get_fuchsia_source#download-the-fuchsia-source-code" | |
| f"\n" | |
| f"\nOtherwise, ensure you are using the appropriate internal checkout." | |
| ) | |
| def https_to_sso(self, url: str) -> str: | |
| return url.replace("https://", "sso://").replace(".googlesource.com", "") | |
| def assert_correct_integration_manifest(self, checkout_info: dict) -> None: | |
| jiri_manifest = self.fuchsia_dir.joinpath(".jiri_manifest") | |
| local_manifest = ( | |
| self.jiri( | |
| "manifest", | |
| "-element=" + self.project, | |
| "-template={{.Manifest}}", | |
| jiri_manifest, | |
| dry_run_safe=True, | |
| stdout=subprocess.PIPE, | |
| ) | |
| .stdout.strip() | |
| .splitlines()[-1] | |
| ) | |
| manifest = checkout_info["manifest"] | |
| if local_manifest != manifest: | |
| workaround = ( | |
| f"create a separate Fuchsia checkout that uses the {manifest!r} manifest and " | |
| f"re-run your command there." | |
| ) | |
| if not checkout_info.get("patches"): | |
| rev = checkout_info["base_manifest_revision"] | |
| workaround += ( | |
| f"\nAlternatively, you can ignore this error by running `fx sync-to {rev}`, " | |
| f"which will sync to the correct revision but may not include the same " | |
| f"repositories and packages used by the build." | |
| ) | |
| raise Error( | |
| f"Manifest {manifest!r} does not match local manifest {local_manifest!r}, so " | |
| f"unable to reproduce the build's checkout.\n" | |
| f"As a workaround, you can {workaround}" | |
| ) | |
| def sync_to_build_id(self) -> None: | |
| """Sync to the checkout version used by an infra build.""" | |
| print(f"{self.state!r} looks like a build ID") | |
| build_id = remove_prefix(self.state, "b") | |
| print( | |
| f"Syncing to state used by build https://ci.chromium.org/b/{build_id}" | |
| ) | |
| bb = self.fuchsia_dir.joinpath("prebuilt", "tools", "buildbucket", "bb") | |
| proc = self.run_command( | |
| bb, | |
| "auth-info", | |
| dry_run_safe=True, | |
| check=False, | |
| # Silence stdout to avoid printing the auth info. We | |
| # only care whether the command passes or fails. | |
| stdout=subprocess.DEVNULL, | |
| ) | |
| if proc.returncode: | |
| print("Please login to Buildbucket first.") | |
| self.run_command(bb, "auth-login") | |
| proc = self.run_command( | |
| bb, | |
| "get", | |
| build_id, | |
| "--json", | |
| "--fields", | |
| "status,builder,output.properties", | |
| dry_run_safe=True, | |
| capture_output=True, | |
| ) | |
| build = json.loads(proc.stdout) | |
| checkout_info = build["output"]["properties"].get("checkout_info") | |
| if not checkout_info: | |
| # `checkout_info` may not available if the build ran against a | |
| # release branch that used an old version of recipes that didn't | |
| # emit the `checkout_info` property. So fall back to legacy behavior | |
| # of downloading a Jiri snapshot from GCS. | |
| self.sync_to_legacy_build_id(build_id) | |
| return | |
| self.project = checkout_info.get("manifest_project", "integration") | |
| # Check that self.project corresponds to current checkout | |
| integration_remote = checkout_info["manifest_remote"] | |
| self.assert_correct_integration_repo(integration_remote) | |
| self.assert_correct_integration_manifest(checkout_info) | |
| self.sync_to_integration_ref(checkout_info["base_manifest_revision"]) | |
| has_integration_patch = False | |
| for patch in checkout_info.get("patches", []): | |
| project = patch["project"] | |
| if project == self.project: | |
| has_integration_patch = True | |
| args = ["patch"] | |
| # Some old versions of recipes didn't emit `base_revision`. If no | |
| # `base_revision` is available then we can't reproduce the checkout | |
| # exactly (the build's checkout may have included some extra commits | |
| # between the commit pinned in integration and the patched commit) | |
| # but patching on top of the integration-pinned commit is probably | |
| # close enough in most cases. | |
| if "base_revision" in patch: | |
| args.extend( | |
| ["-rebase", "-rebase-revision", patch["base_revision"]] | |
| ) | |
| else: | |
| warning = colorize("WARNING", Color.YELLOW) | |
| print( | |
| f"{warning}: No base revision available for project {project}," | |
| " rebasing on current main branch instead." | |
| ) | |
| args.extend( | |
| [ | |
| "-host", | |
| patch["host"], | |
| "-project", | |
| project, | |
| patch["ref"], | |
| ] | |
| ) | |
| self.jiri(*args) | |
| # If we patched in a change to the integration repo, we have to `jiri | |
| # update` again to make sure we respect any project/package pin updates. | |
| if has_integration_patch: | |
| self.jiri( | |
| *self.jiri_update_prefix_args, | |
| "update", | |
| "-local-manifest", | |
| "-rebase-tracked", | |
| "-gc", | |
| ) | |
| def sync_to_legacy_build_id(self, build_id: str) -> None: | |
| """Sync to a build's Jiri snapshot from GCS. | |
| This is necessary for builds on old release branches that don't emit the | |
| `checkout_info` output property. | |
| """ | |
| print("Build did not emit checkout info, checking GCS for snapshots...") | |
| tempdir = tempfile.TemporaryDirectory() | |
| try: | |
| snapshot_file = self.download_build_snapshot( | |
| pathlib.Path(tempdir.name), build_id | |
| ) | |
| except Exception: | |
| tempdir.cleanup() | |
| raise | |
| try: | |
| self.sync_to_snapshot(snapshot_file) | |
| except Exception: | |
| print( | |
| f"Preserving the snapshot directory for debugging: {tempdir.name}" | |
| ) | |
| raise | |
| tempdir.cleanup() | |
| def download_build_snapshot( | |
| self, dest_dir: pathlib.Path, build_id: str | |
| ) -> pathlib.Path: | |
| """Download a Jiri snapshot produced by an infra build from GCS. | |
| Depending on the nature of the infra build, the snapshot file may come | |
| from one of several buckets that have different schemas. | |
| """ | |
| possible_urls = [ | |
| # Buckets where each build ID file is the snapshot file itself. | |
| f"gs://fuchsia-snapshots/{build_id}", | |
| ] + [ | |
| # Buckets where each build is a folder with a jiri_snapshot.xml | |
| # inside. | |
| f"gs://{bucket}/builds/{build_id}/jiri_snapshot.xml" | |
| for bucket in [ | |
| "fuchsia-artifacts-release", | |
| "fuchsia-artifacts-internal", | |
| "fuchsia-artifacts", | |
| ] | |
| ] | |
| snapshot_url = "" | |
| for url in possible_urls: | |
| proc = self.gsutil( | |
| "ls", | |
| url, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| check=False, | |
| dry_run_safe=True, | |
| ) | |
| if proc.returncode == 0: | |
| # Found the bucket that contains the snapshot, so no need to | |
| # check the remaining buckets. | |
| snapshot_url = url | |
| break | |
| if not snapshot_url: | |
| locations = "\n".join(f" {url}" for url in possible_urls) | |
| raise Error( | |
| f"Cannot find a valid snapshot in any of the following locations:\n{locations}\n" | |
| f"Possible reasons:\n" | |
| f" - You are not logged in to gcloud. Try running 'gcloud auth list' and,\n" | |
| f" if necessary, 'gcloud auth login'\n" | |
| f" - The build {build_id} may have ended prematurely, so the step that\n" | |
| f" uploads its artifacts to GCS did not execute.\n" | |
| f" Compare https://ci.chromium.org/b/{build_id} with a successful\n" | |
| f" build of the same builder and see if the failed build stopped before\n" | |
| f" running a step like 'upload artifacts'" | |
| ) | |
| bucket = urllib.parse.urlparse(snapshot_url).hostname | |
| print(f"Downloading Jiri snapshot from {bucket}...") | |
| snapshot_file = dest_dir.joinpath("jiri_snapshot.xml") | |
| self.gsutil( | |
| "-q", # Quiet (no progress indicator) | |
| "-m", # Download files in parallel | |
| "cp", | |
| snapshot_url, | |
| snapshot_file, | |
| dry_run_safe=True, | |
| ) | |
| return snapshot_file | |
| def print_synced_revision(self) -> None: | |
| print() | |
| if self.state == "reset": | |
| print("Reset success! You are now tracking JIRI_HEAD.") | |
| else: | |
| print( | |
| "Success! To switch back to the latest, use `fx sync-to reset`." | |
| ) | |
| print("//integration HEAD is now at:") | |
| self.run_command( | |
| "git", | |
| "-C", | |
| self.integration_dir, | |
| "--no-pager", | |
| "show", | |
| "--no-patch", | |
| "--decorate", | |
| "--format=format: %h - (%ar) %an\n %s\n", | |
| dry_run_safe=True, | |
| log=False, | |
| ) | |
| def run_command( | |
| self, | |
| *args: Union[str, pathlib.Path], | |
| dry_run_safe: bool = False, | |
| check: bool = True, | |
| log: bool = True, | |
| **kwargs, | |
| ) -> subprocess.CompletedProcess: | |
| cmd = [str(a) for a in args] # Convert any Path objects to strings. | |
| cmd_str = " ".join(cmd) | |
| if self.dry_run and not dry_run_safe: | |
| print("Dry-run: %s" % colorize(cmd_str, Color.GREEN)) | |
| return subprocess.CompletedProcess(args=cmd, returncode=0) | |
| if log: | |
| print("Running: %s" % colorize(" ".join(cmd), Color.YELLOW)) | |
| try: | |
| return subprocess.run(cmd, check=check, text=True, **kwargs) | |
| except subprocess.CalledProcessError as e: | |
| # Override CalledProcessError to make a more user-friendly error | |
| # message and hide the stacktrace. | |
| raise Error( | |
| f"Command {cmd_str!r} returned non-zero exit status {e.returncode}." | |
| ) | |
| def gsutil( | |
| self, | |
| *args: Union[str, pathlib.Path], | |
| **kwargs, | |
| ) -> subprocess.CompletedProcess: | |
| return self.run_command(self.gsutil_path, *args, **kwargs) | |
| def jiri( | |
| self, | |
| *args: Union[str, pathlib.Path], | |
| **kwargs, | |
| ) -> subprocess.CompletedProcess: | |
| # Prefer to run Jiri via $PATH so that logs are concise and don't | |
| # include the full path to Jiri, but fall back to using Jiri from the | |
| # local checkout if it's not on $PATH. | |
| jiri_path = "jiri" | |
| if not shutil.which(jiri_path): | |
| jiri_path = os.path.join( | |
| self.fuchsia_dir, ".jiri_root", "bin", "jiri" | |
| ) | |
| return self.run_command(jiri_path, "-color=always", *args, **kwargs) | |
| def confirm_ok_to_change_source_code(self) -> None: | |
| if self.dry_run: | |
| # In dry-run mode we're not actually going to change the source | |
| # code, so we shouldn't bother getting the user's approval or doing | |
| # any safety checks. | |
| return | |
| # Check if any repo has uncommitted changes and exit early if so, | |
| # because we can't sync without discarding the uncommitted changes, | |
| # which could be bad. | |
| # | |
| # First we must update all indexes; otherwise `diff-index` might report | |
| # false positive diffs. | |
| self.jiri("runp", "git update-index -q --refresh") | |
| output = ( | |
| self.jiri( | |
| "runp", "git diff-index --quiet HEAD", capture_output=True | |
| ).stdout | |
| or "" | |
| ) | |
| dirty_repos = self.jiri_runp_failures(output) | |
| if dirty_repos: | |
| repos_list = "\n".join(f" - {repo}" for repo in dirty_repos) | |
| if self.force: | |
| warning = colorize("WARNING", Color.YELLOW) | |
| print( | |
| f"{warning}: " " rebasing on current main branch instead." | |
| ) | |
| else: | |
| discard_cmd = f"jiri runp 'git checkout -f HEAD'" | |
| force_cmd = f"fx sync-to --force {self.state!r}" | |
| # Suggest running `git checkout JIRI_HEAD` *after* rerunning fx | |
| # sync-to instead of automatically discarding changes before, | |
| # because the workarounds for some Jiri bugs require making | |
| # temporary local modifications before syncing. | |
| # TODO(olivernewman): Run this automatically if the sync fails | |
| # with an "untracked changes" error. | |
| fixup_cmd = "jiri runp -uncommitted=true 'git checkout --force JIRI_HEAD'" | |
| raise Error( | |
| f"Cannot sync to a different version as the following projects" | |
| f" have uncommitted changes:\n{repos_list}" | |
| f"\nCommit or discard these changes and try again." | |
| f"\n To discard changes in all projects, run the following and then" | |
| f" retry `fx sync-to`:" | |
| f"\n {colorize(discard_cmd, Color.RED)}" | |
| f"\nOr if that still doesn't work, run:" | |
| f"\n {colorize(force_cmd, Color.RED)}" | |
| f"\n {colorize(fixup_cmd, Color.RED)}" | |
| ) | |
| while True: # Loop until we get a valid input. | |
| if self.force: | |
| uncomitted_info = ( | |
| f"Because you set --force, " | |
| f"{colorize('ANY UNCOMMITTED CHANGES WILL BE DISCARDED', Color.RED)}." | |
| ) | |
| else: | |
| uncomitted_info = ( | |
| "No untracked changes will be discarded, but any" | |
| " repos not currently on JIRI_HEAD will not be" | |
| " automatically restored to their original revisions." | |
| ) | |
| print( | |
| f"I'm about to change the state of your source code.\n{uncomitted_info}" | |
| ) | |
| yn = input("Are you sure you want to continue [y/n]? ") | |
| if yn.startswith(("y", "Y")): | |
| return | |
| elif yn.startswith(("n", "N")): | |
| raise Error("Aborting.", fail=False) | |
| else: | |
| print(colorize(f"Invalid choice: {yn!r}", Color.RED)) | |
| def jiri_runp_failures(self, output: str) -> List[str]: | |
| """Return the projects on which `jiri runp` failed.""" | |
| failed_prefix = "FAILED: " | |
| return [ | |
| remove_prefix(line, failed_prefix).split("=")[0] | |
| for line in output.strip().splitlines() | |
| if line.startswith(failed_prefix) | |
| ] | |
| def remove_prefix(s, prefix: str) -> str: | |
| if s.startswith(prefix): | |
| return s[len(prefix) :] | |
| return s | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(add_help=False) | |
| parser.add_argument("-h", "--help", action="store_true") | |
| parser.add_argument("-n", "--dry-run", dest="dry_run", action="store_true") | |
| parser.add_argument("-f", "--force", dest="force", action="store_true") | |
| parser.add_argument("-v", "--verbose", dest="verbose", action="store_true") | |
| parser.add_argument("state", nargs="?", default="") | |
| return parser.parse_args() | |
| def main() -> None: | |
| fuchsia_dir = os.getenv("FUCHSIA_DIR") | |
| if fuchsia_dir is None: | |
| raise Error("FUCHSIA_DIR must be set") | |
| os.chdir(fuchsia_dir) | |
| args = parse_args() | |
| SyncToCommand(args, fuchsia_dir).run() | |
| if __name__ == "__main__": | |
| try: | |
| main() | |
| except Error as e: | |
| if e.fail: | |
| prefix = colorize("ERROR", Color.RED) | |
| print(f"{prefix}: {e.msg}") | |
| sys.exit(1) | |
| else: | |
| print(e.msg) |