| # 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 | |
| import error | |
| from git_trace2_event_log_base import BaseEventLog | |
| import platform_utils | |
| import repo_logging | |
| import repo_trace | |
| from wrapper import Wrapper | |
| GIT = "git" | |
| 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 = repo_logging.RepoLogger(__file__) | |
| class _GitCall: | |
| @functools.lru_cache(maxsize=None) # noqa: B019 | |
| 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 IsValidBranchName(name: str) -> bool: | |
| """Return whether |name| is valid where Git expects a branch name.""" | |
| p = GitCommand( | |
| None, | |
| ["check-ref-format", "--branch", name], | |
| capture_stdout=True, | |
| capture_stderr=True, | |
| add_event_log=False, | |
| log_as_error=False, | |
| ) | |
| return p.Wait() == 0 | |
| 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" | |
| 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_stdout_bytes: bool = 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 | |
| if capture_stdout_bytes: | |
| if merge_output: | |
| raise ValueError( | |
| "capture_stdout_bytes cannot be combined with merge_output" | |
| ) | |
| capture_stdout = True | |
| # 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) | |
| if command_name in ("fetch", "clone"): | |
| env["GIT_TERMINAL_PROMPT"] = "0" | |
| if command_name in ("fetch", "clone", "push"): | |
| # Need to use the --progress flag for fetch/clone/push so output | |
| # will be displayed as by default git only does progress output if | |
| # stderr is a TTY. | |
| if sys.stderr.isatty(): | |
| if ( | |
| "--progress" not in cmdv | |
| and "--no-progress" not in cmdv | |
| and "--quiet" not in cmdv | |
| and "-q" 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_stdout_bytes=capture_stdout_bytes, | |
| 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_stdout_bytes: bool = 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 capture_stdout_bytes: | |
| kwargs = {} | |
| if isinstance(input, str): | |
| input = input.encode("utf-8", "surrogateescape") | |
| 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 repo_trace.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 repo_trace.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) | |
| if capture_stdout_bytes and isinstance(self.stderr, bytes): | |
| self.stderr = self.stderr.decode( | |
| "utf-8", "backslashreplace" | |
| ).replace("\r\n", "\n") | |
| 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.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) -> None: | |
| if self.rc == 0: | |
| return None | |
| raw_stdout = self.stdout | |
| if isinstance(raw_stdout, bytes): | |
| first_records = re.split( | |
| rb"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES | |
| )[:GIT_ERROR_STDOUT_LINES] | |
| stdout = ( | |
| "\n".join( | |
| r.decode("utf-8", "backslashreplace") for r in first_records | |
| ) | |
| if raw_stdout | |
| else None | |
| ) | |
| elif raw_stdout: | |
| first_records = re.split( | |
| r"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES | |
| )[:GIT_ERROR_STDOUT_LINES] | |
| stdout = "\n".join(first_records) | |
| else: | |
| stdout = None | |
| raw_stderr = self.stderr | |
| if isinstance(raw_stderr, bytes): | |
| raw_stderr = raw_stderr.decode("utf-8", "backslashreplace") | |
| stderr = ( | |
| "\n".join(raw_stderr.split("\n")[:GIT_ERROR_STDERR_LINES]) | |
| if raw_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(error.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(error.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) # noqa: B019 | |
| 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(error.GitError): | |
| """ | |
| Error raised when subprocess.Popen fails for a GitCommand | |
| """ |