| # Copyright (C) 2008 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| import functools |
| import json |
| import os |
| import re |
| import subprocess |
| import sys |
| from typing import Any, Optional |
| |
| from error import GitError |
| from error import RepoExitError |
| from git_refs import HEAD |
| from git_trace2_event_log_base import BaseEventLog |
| import platform_utils |
| from repo_logging import RepoLogger |
| from repo_trace import IsTrace |
| from repo_trace import REPO_TRACE |
| from repo_trace import Trace |
| from wrapper import Wrapper |
| |
| |
| GIT = "git" |
| # NB: These do not need to be kept in sync with the repo launcher script. |
| # These may be much newer as it allows the repo launcher to roll between |
| # different repo releases while source versions might require a newer git. |
| # |
| # The soft version is when we start warning users that the version is old and |
| # we'll be dropping support for it. We'll refuse to work with versions older |
| # than the hard version. |
| # |
| # git-1.7 is in (EOL) Ubuntu Precise. git-1.9 is in Ubuntu Trusty. |
| MIN_GIT_VERSION_SOFT = (1, 9, 1) |
| MIN_GIT_VERSION_HARD = (1, 7, 2) |
| GIT_DIR = "GIT_DIR" |
| |
| LAST_GITDIR = None |
| LAST_CWD = None |
| DEFAULT_GIT_FAIL_MESSAGE = "git command failure" |
| ERROR_EVENT_LOGGING_PREFIX = "RepoGitCommandError" |
| # Common line length limit |
| GIT_ERROR_STDOUT_LINES = 1 |
| GIT_ERROR_STDERR_LINES = 10 |
| INVALID_GIT_EXIT_CODE = 126 |
| |
| logger = RepoLogger(__file__) |
| |
| |
| class _GitCall: |
| @functools.lru_cache(maxsize=None) |
| def version_tuple(self): |
| ret = Wrapper().ParseGitVersion() |
| if ret is None: |
| msg = "fatal: unable to detect git version" |
| logger.error(msg) |
| raise GitRequireError(msg) |
| return ret |
| |
| def __getattr__(self, name): |
| name = name.replace("_", "-") |
| |
| def fun(*cmdv): |
| command = [name] |
| command.extend(cmdv) |
| return GitCommand(None, command, add_event_log=False).Wait() == 0 |
| |
| return fun |
| |
| |
| git = _GitCall() |
| |
| |
| def RepoSourceVersion(): |
| """Return the version of the repo.git tree.""" |
| ver = getattr(RepoSourceVersion, "version", None) |
| |
| # We avoid GitCommand so we don't run into circular deps -- GitCommand needs |
| # to initialize version info we provide. |
| if ver is None: |
| env = GitCommand._GetBasicEnv() |
| |
| proj = os.path.dirname(os.path.abspath(__file__)) |
| env[GIT_DIR] = os.path.join(proj, ".git") |
| result = subprocess.run( |
| [GIT, "describe", HEAD], |
| stdout=subprocess.PIPE, |
| stderr=subprocess.DEVNULL, |
| encoding="utf-8", |
| env=env, |
| check=False, |
| ) |
| if result.returncode == 0: |
| ver = result.stdout.strip() |
| if ver.startswith("v"): |
| ver = ver[1:] |
| else: |
| ver = "unknown" |
| setattr(RepoSourceVersion, "version", ver) |
| |
| return ver |
| |
| |
| @functools.lru_cache(maxsize=None) |
| def GetEventTargetPath(): |
| """Get the 'trace2.eventtarget' path from git configuration. |
| |
| Returns: |
| path: git config's 'trace2.eventtarget' path if it exists, or None |
| """ |
| path = None |
| cmd = ["config", "--get", "trace2.eventtarget"] |
| # TODO(https://crbug.com/gerrit/13706): Use GitConfig when it supports |
| # system git config variables. |
| p = GitCommand( |
| None, |
| cmd, |
| capture_stdout=True, |
| capture_stderr=True, |
| bare=True, |
| add_event_log=False, |
| ) |
| retval = p.Wait() |
| if retval == 0: |
| # Strip trailing carriage-return in path. |
| path = p.stdout.rstrip("\n") |
| if path == "": |
| return None |
| elif retval != 1: |
| # `git config --get` is documented to produce an exit status of `1` |
| # if the requested variable is not present in the configuration. |
| # Report any other return value as an error. |
| logger.error( |
| "repo: error: 'git config --get' call failed with return code: " |
| "%r, stderr: %r", |
| retval, |
| p.stderr, |
| ) |
| return path |
| |
| |
| class UserAgent: |
| """Mange User-Agent settings when talking to external services |
| |
| We follow the style as documented here: |
| https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent |
| """ |
| |
| _os = None |
| _repo_ua = None |
| _git_ua = None |
| |
| @property |
| def os(self): |
| """The operating system name.""" |
| if self._os is None: |
| os_name = sys.platform |
| if os_name.lower().startswith("linux"): |
| os_name = "Linux" |
| elif os_name == "win32": |
| os_name = "Win32" |
| elif os_name == "cygwin": |
| os_name = "Cygwin" |
| elif os_name == "darwin": |
| os_name = "Darwin" |
| self._os = os_name |
| |
| return self._os |
| |
| @property |
| def repo(self): |
| """The UA when connecting directly from repo.""" |
| if self._repo_ua is None: |
| py_version = sys.version_info |
| self._repo_ua = "git-repo/%s (%s) git/%s Python/%d.%d.%d" % ( |
| RepoSourceVersion(), |
| self.os, |
| git.version_tuple().full, |
| py_version.major, |
| py_version.minor, |
| py_version.micro, |
| ) |
| |
| return self._repo_ua |
| |
| @property |
| def git(self): |
| """The UA when running git.""" |
| if self._git_ua is None: |
| self._git_ua = ( |
| f"git/{git.version_tuple().full} ({self.os}) " |
| f"git-repo/{RepoSourceVersion()}" |
| ) |
| return self._git_ua |
| |
| |
| user_agent = UserAgent() |
| |
| |
| def git_require(min_version, fail=False, msg=""): |
| git_version = git.version_tuple() |
| if min_version <= git_version: |
| return True |
| if fail: |
| need = ".".join(map(str, min_version)) |
| if msg: |
| msg = " for " + msg |
| error_msg = f"fatal: git {need} or later required{msg}" |
| logger.error(error_msg) |
| raise GitRequireError(error_msg) |
| return False |
| |
| |
| def _build_env( |
| _kwargs_only=(), |
| bare: Optional[bool] = False, |
| disable_editor: Optional[bool] = False, |
| ssh_proxy: Optional[Any] = None, |
| gitdir: Optional[str] = None, |
| objdir: Optional[str] = None, |
| ): |
| """Constucts an env dict for command execution.""" |
| |
| assert _kwargs_only == (), "_build_env only accepts keyword arguments." |
| |
| env = GitCommand._GetBasicEnv() |
| |
| if disable_editor: |
| env["GIT_EDITOR"] = ":" |
| if ssh_proxy: |
| env["REPO_SSH_SOCK"] = ssh_proxy.sock() |
| env["GIT_SSH"] = ssh_proxy.proxy |
| env["GIT_SSH_VARIANT"] = "ssh" |
| if "http_proxy" in env and "darwin" == sys.platform: |
| s = f"'http.proxy={env['http_proxy']}'" |
| p = env.get("GIT_CONFIG_PARAMETERS") |
| if p is not None: |
| s = p + " " + s |
| env["GIT_CONFIG_PARAMETERS"] = s |
| if "GIT_ALLOW_PROTOCOL" not in env: |
| env[ |
| "GIT_ALLOW_PROTOCOL" |
| ] = "file:git:http:https:ssh:persistent-http:persistent-https:sso:rpc" |
| env["GIT_HTTP_USER_AGENT"] = user_agent.git |
| |
| if objdir: |
| # Set to the place we want to save the objects. |
| env["GIT_OBJECT_DIRECTORY"] = objdir |
| |
| alt_objects = os.path.join(gitdir, "objects") if gitdir else None |
| if alt_objects and os.path.realpath(alt_objects) != os.path.realpath( |
| objdir |
| ): |
| # Allow git to search the original place in case of local or unique |
| # refs that git will attempt to resolve even if we aren't fetching |
| # them. |
| env["GIT_ALTERNATE_OBJECT_DIRECTORIES"] = alt_objects |
| if bare and gitdir is not None: |
| env[GIT_DIR] = gitdir |
| |
| return env |
| |
| |
| class GitCommand: |
| """Wrapper around a single git invocation.""" |
| |
| def __init__( |
| self, |
| project, |
| cmdv, |
| bare=False, |
| input=None, |
| capture_stdout=False, |
| capture_stderr=False, |
| merge_output=False, |
| disable_editor=False, |
| ssh_proxy=None, |
| cwd=None, |
| gitdir=None, |
| objdir=None, |
| verify_command=False, |
| add_event_log=True, |
| log_as_error=True, |
| ): |
| if project: |
| if not cwd: |
| cwd = project.worktree |
| if not gitdir: |
| gitdir = project.gitdir |
| |
| self.project = project |
| self.cmdv = cmdv |
| self.verify_command = verify_command |
| self.stdout, self.stderr = None, None |
| |
| # Git on Windows wants its paths only using / for reliability. |
| if platform_utils.isWindows(): |
| if objdir: |
| objdir = objdir.replace("\\", "/") |
| if gitdir: |
| gitdir = gitdir.replace("\\", "/") |
| |
| env = _build_env( |
| disable_editor=disable_editor, |
| ssh_proxy=ssh_proxy, |
| objdir=objdir, |
| gitdir=gitdir, |
| bare=bare, |
| ) |
| |
| command = [GIT] |
| if bare: |
| cwd = None |
| command_name = cmdv[0] |
| command.append(command_name) |
| # Need to use the --progress flag for fetch/clone so output will be |
| # displayed as by default git only does progress output if stderr is a |
| # TTY. |
| if sys.stderr.isatty() and command_name in ("fetch", "clone"): |
| if "--progress" not in cmdv and "--quiet" not in cmdv: |
| command.append("--progress") |
| command.extend(cmdv[1:]) |
| |
| event_log = ( |
| BaseEventLog(env=env, add_init_count=True) |
| if add_event_log |
| else None |
| ) |
| |
| try: |
| self._RunCommand( |
| command, |
| env, |
| capture_stdout=capture_stdout, |
| capture_stderr=capture_stderr, |
| merge_output=merge_output, |
| ssh_proxy=ssh_proxy, |
| cwd=cwd, |
| input=input, |
| ) |
| self.VerifyCommand() |
| except GitCommandError as e: |
| if event_log is not None: |
| error_info = json.dumps( |
| { |
| "ErrorType": type(e).__name__, |
| "Project": e.project, |
| "CommandName": command_name, |
| "Message": str(e), |
| "ReturnCode": str(e.git_rc) |
| if e.git_rc is not None |
| else None, |
| "IsError": log_as_error, |
| } |
| ) |
| event_log.ErrorEvent( |
| f"{ERROR_EVENT_LOGGING_PREFIX}:{error_info}" |
| ) |
| event_log.Write(GetEventTargetPath()) |
| if isinstance(e, GitPopenCommandError): |
| raise |
| |
| def _RunCommand( |
| self, |
| command, |
| env, |
| capture_stdout=False, |
| capture_stderr=False, |
| merge_output=False, |
| ssh_proxy=None, |
| cwd=None, |
| input=None, |
| ): |
| # Set subprocess.PIPE for streams that need to be captured. |
| stdin = subprocess.PIPE if input else None |
| stdout = subprocess.PIPE if capture_stdout else None |
| stderr = ( |
| subprocess.STDOUT |
| if merge_output |
| else (subprocess.PIPE if capture_stderr else None) |
| ) |
| |
| # tee_stderr acts like a tee command for stderr, in that, it captures |
| # stderr from the subprocess and streams it back to sys.stderr, while |
| # keeping a copy in-memory. |
| # This allows us to store stderr logs from the subprocess into |
| # GitCommandError. |
| # Certain git operations, such as `git push`, writes diagnostic logs, |
| # such as, progress bar for pushing, into stderr. To ensure we don't |
| # break git's UX, we need to write to sys.stderr as we read from the |
| # subprocess. Setting encoding or errors makes subprocess return |
| # io.TextIOWrapper, which is line buffered. To avoid line-buffering |
| # while tee-ing stderr, we unset these kwargs. See GitCommand._Tee |
| # for tee-ing between the streams. |
| # We tee stderr iff the caller doesn't want to capture any stream to |
| # not disrupt the existing flow. |
| # See go/tee-repo-stderr for more context. |
| tee_stderr = False |
| kwargs = {"encoding": "utf-8", "errors": "backslashreplace"} |
| if not (stdin or stdout or stderr): |
| tee_stderr = True |
| # stderr will be written back to sys.stderr even though it is |
| # piped here. |
| stderr = subprocess.PIPE |
| kwargs = {} |
| |
| dbg = "" |
| if IsTrace(): |
| global LAST_CWD |
| global LAST_GITDIR |
| |
| if cwd and LAST_CWD != cwd: |
| if LAST_GITDIR or LAST_CWD: |
| dbg += "\n" |
| dbg += ": cd %s\n" % cwd |
| LAST_CWD = cwd |
| |
| if GIT_DIR in env and LAST_GITDIR != env[GIT_DIR]: |
| if LAST_GITDIR or LAST_CWD: |
| dbg += "\n" |
| dbg += ": export GIT_DIR=%s\n" % env[GIT_DIR] |
| LAST_GITDIR = env[GIT_DIR] |
| |
| if "GIT_OBJECT_DIRECTORY" in env: |
| dbg += ( |
| ": export GIT_OBJECT_DIRECTORY=%s\n" |
| % env["GIT_OBJECT_DIRECTORY"] |
| ) |
| if "GIT_ALTERNATE_OBJECT_DIRECTORIES" in env: |
| dbg += ": export GIT_ALTERNATE_OBJECT_DIRECTORIES=%s\n" % ( |
| env["GIT_ALTERNATE_OBJECT_DIRECTORIES"] |
| ) |
| |
| dbg += ": " |
| dbg += " ".join(command) |
| if stdin == subprocess.PIPE: |
| dbg += " 0<|" |
| if stdout == subprocess.PIPE: |
| dbg += " 1>|" |
| if stderr == subprocess.PIPE: |
| dbg += " 2>|" |
| elif stderr == subprocess.STDOUT: |
| dbg += " 2>&1" |
| |
| with Trace( |
| "git command %s %s with debug: %s", LAST_GITDIR, command, dbg |
| ): |
| try: |
| p = subprocess.Popen( |
| command, |
| cwd=cwd, |
| env=env, |
| stdin=stdin, |
| stdout=stdout, |
| stderr=stderr, |
| **kwargs, |
| ) |
| except Exception as e: |
| raise GitPopenCommandError( |
| message=f"{command[1]}: {e}", |
| project=self.project.name if self.project else None, |
| command_args=self.cmdv, |
| ) |
| |
| if ssh_proxy: |
| ssh_proxy.add_client(p) |
| |
| self.process = p |
| |
| try: |
| if tee_stderr: |
| # tee_stderr streams stderr to sys.stderr while capturing |
| # a copy within self.stderr. tee_stderr is only enabled |
| # when the caller wants to pipe no stream. |
| self.stderr = self._Tee(p.stderr, sys.stderr) |
| else: |
| self.stdout, self.stderr = p.communicate(input=input) |
| finally: |
| if ssh_proxy: |
| ssh_proxy.remove_client(p) |
| self.rc = p.wait() |
| |
| @staticmethod |
| def _Tee(in_stream, out_stream): |
| """Writes text from in_stream to out_stream while recording in buffer. |
| |
| Args: |
| in_stream: I/O stream to be read from. |
| out_stream: I/O stream to write to. |
| |
| Returns: |
| A str containing everything read from the in_stream. |
| """ |
| buffer = "" |
| read_size = 1024 if sys.version_info < (3, 7) else -1 |
| chunk = in_stream.read1(read_size) |
| while chunk: |
| # Convert to str. |
| if not hasattr(chunk, "encode"): |
| chunk = chunk.decode("utf-8", "backslashreplace") |
| |
| buffer += chunk |
| out_stream.write(chunk) |
| out_stream.flush() |
| |
| chunk = in_stream.read1(read_size) |
| |
| return buffer |
| |
| @staticmethod |
| def _GetBasicEnv(): |
| """Return a basic env for running git under. |
| |
| This is guaranteed to be side-effect free. |
| """ |
| env = os.environ.copy() |
| for key in ( |
| REPO_TRACE, |
| GIT_DIR, |
| "GIT_ALTERNATE_OBJECT_DIRECTORIES", |
| "GIT_OBJECT_DIRECTORY", |
| "GIT_WORK_TREE", |
| "GIT_GRAFT_FILE", |
| "GIT_INDEX_FILE", |
| ): |
| env.pop(key, None) |
| return env |
| |
| def VerifyCommand(self): |
| if self.rc == 0: |
| return None |
| stdout = ( |
| "\n".join(self.stdout.split("\n")[:GIT_ERROR_STDOUT_LINES]) |
| if self.stdout |
| else None |
| ) |
| stderr = ( |
| "\n".join(self.stderr.split("\n")[:GIT_ERROR_STDERR_LINES]) |
| if self.stderr |
| else None |
| ) |
| project = self.project.name if self.project else None |
| raise GitCommandError( |
| project=project, |
| command_args=self.cmdv, |
| git_rc=self.rc, |
| git_stdout=stdout, |
| git_stderr=stderr, |
| ) |
| |
| def Wait(self): |
| if self.verify_command: |
| self.VerifyCommand() |
| return self.rc |
| |
| |
| class GitRequireError(RepoExitError): |
| """Error raised when git version is unavailable or invalid.""" |
| |
| def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE): |
| super().__init__(message, exit_code=exit_code) |
| |
| |
| class GitCommandError(GitError): |
| """ |
| Error raised from a failed git command. |
| Note that GitError can refer to any Git related error (e.g. branch not |
| specified for project.py 'UploadForReview'), while GitCommandError is |
| raised exclusively from non-zero exit codes returned from git commands. |
| """ |
| |
| # Tuples with error formats and suggestions for those errors. |
| _ERROR_TO_SUGGESTION = [ |
| ( |
| re.compile("couldn't find remote ref .*"), |
| "Check if the provided ref exists in the remote.", |
| ), |
| ( |
| re.compile("unable to access '.*': .*"), |
| ( |
| "Please make sure you have the correct access rights and the " |
| "repository exists." |
| ), |
| ), |
| ( |
| re.compile("'.*' does not appear to be a git repository"), |
| "Are you running this repo command outside of a repo workspace?", |
| ), |
| ( |
| re.compile("not a git repository"), |
| "Are you running this repo command outside of a repo workspace?", |
| ), |
| ] |
| |
| def __init__( |
| self, |
| message: str = DEFAULT_GIT_FAIL_MESSAGE, |
| git_rc: int = None, |
| git_stdout: str = None, |
| git_stderr: str = None, |
| **kwargs, |
| ): |
| super().__init__( |
| message, |
| **kwargs, |
| ) |
| self.git_rc = git_rc |
| self.git_stdout = git_stdout |
| self.git_stderr = git_stderr |
| |
| @property |
| @functools.lru_cache(maxsize=None) |
| def suggestion(self): |
| """Returns helpful next steps for the given stderr.""" |
| if not self.git_stderr: |
| return self.git_stderr |
| |
| for err, suggestion in self._ERROR_TO_SUGGESTION: |
| if err.search(self.git_stderr): |
| return suggestion |
| |
| return None |
| |
| def __str__(self): |
| args = "[]" if not self.command_args else " ".join(self.command_args) |
| error_type = type(self).__name__ |
| string = f"{error_type}: '{args}' on {self.project} failed" |
| |
| if self.message != DEFAULT_GIT_FAIL_MESSAGE: |
| string += f": {self.message}" |
| |
| if self.git_stdout: |
| string += f"\nstdout: {self.git_stdout}" |
| |
| if self.git_stderr: |
| string += f"\nstderr: {self.git_stderr}" |
| |
| if self.suggestion: |
| string += f"\nsuggestion: {self.suggestion}" |
| |
| return string |
| |
| |
| class GitPopenCommandError(GitError): |
| """ |
| Error raised when subprocess.Popen fails for a GitCommand |
| """ |