mirror of
https://gerrit.googlesource.com/git-repo
synced 2024-12-21 07:16:21 +00:00
621de7ed12
git fetch operation may prompt user to enter username and password. This won't be visible to user when repo sync operation since stdout and stderr are redirected. If that happens, user may think repo is doing work and likely won't realize it's stuck on user's input. This patch disables prompt for clone and fetch operations, and repo will fail fast. R=gavinmak@google.com Bug: b/368644181 Change-Id: I2efa88ae66067587a00678eda155d861034b9127 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/438001 Reviewed-by: Nasser Grainawi <nasser.grainawi@linaro.org> Tested-by: Josip Sokcevic <sokcevic@google.com> Commit-Queue: Josip Sokcevic <sokcevic@google.com> Reviewed-by: Gavin Mak <gavinmak@google.com>
651 lines
20 KiB
Python
651 lines
20 KiB
Python
# 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"
|
|
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)
|
|
|
|
if command_name in ("fetch", "clone"):
|
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
# 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():
|
|
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
|
|
"""
|