| #!/bin/bash |
| # 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. |
| |
| # In Python this is a triple-quoted string, but when this script is run by Bash, |
| # it ignores the four quotes (interpreting them as two single-quoted empty |
| # strings) and re-execs the script using the prebuilt Python interpreter, |
| # ensuring that the script won't fail if run on a machine that has an outdated |
| # version of Python. |
| # |
| # TODO(fxbug.dev/79905): Remove this workaround once `fx` itself uses |
| # hermetic-env to exec subcommands. |
| # pylint: disable=pointless-string-statement |
| ''''exec "$FUCHSIA_DIR/scripts/hermetic-env" python3 -u -- "$0" ${1+"$@"} # ''' |
| |
| #### CATEGORY=Source tree |
| ### 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:]+[-+][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) |
| self.help: bool = args.help |
| self.state: str = args.state |
| self.dry_run: bool = args.dry_run |
| self.force: bool = args.force |
| |
| @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)) |
| |
| self.confirm_ok_to_change_source_code() |
| |
| # 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) |
| |
| # 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") |
| |
| 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 reset_checkout(self) -> None: |
| """Sync all repos back to JIRI_HEAD.""" |
| # 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("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}") |
| |
| def sync_to_snapshot(self, snapshot_file: pathlib.Path) -> None: |
| integration_remote = self.jiri( |
| "manifest", |
| "-element=integration", |
| "-template={{.Remote}}", |
| snapshot_file, |
| dry_run_safe=True, |
| stdout=subprocess.PIPE, |
| ).stdout.strip() |
| self.assert_correct_integration_repo(integration_remote) |
| print(f"Syncing to Jiri snapshot {str(snapshot_file)!r}") |
| self.jiri("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.""" |
| 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("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.""" |
| 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: |
| jiri_manifest = self.fuchsia_dir.joinpath(".jiri_manifest") |
| local_remote = self.jiri( |
| "manifest", |
| "-element=integration", |
| "-template={{.Remote}}", |
| jiri_manifest, |
| dry_run_safe=True, |
| stdout=subprocess.PIPE, |
| ).stdout.strip() |
| if local_remote != remote: |
| raise Error( |
| f"Integration remote {remote!r} does not match local" |
| f" checkout remote {local_remote!r}." |
| f"\nIf syncing to a public build ID, use a public checkout." |
| f" You can create a public checkout in a separate directory" |
| f" by following the directions here:" |
| f"\nhttps://fuchsia.dev/fuchsia-src/get-started/get_fuchsia_source#download-the-fuchsia-source-code" |
| f"\n" |
| f"\nThen cd into the public checkout directory and run:" |
| f"\n ./scripts/fx sync-to {self.state}" |
| ) |
| |
| def assert_correct_integration_manifest(self, checkout_info: dict) -> None: |
| jiri_manifest = self.fuchsia_dir.joinpath(".jiri_manifest") |
| local_manifest = self.jiri( |
| "manifest", |
| "-element=integration", |
| "-template={{.Manifest}}", |
| jiri_manifest, |
| dry_run_safe=True, |
| stdout=subprocess.PIPE, |
| ).stdout.strip() |
| 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 |
| 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 == "integration": |
| 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("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)}") |
| |
| print("Dry-run: nothing will be changed on your local repo.") |
| |
| 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("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) |