diff options
author | Aravind Vasudevan <aravindvasudev@google.com> | 2023-10-06 00:40:25 +0000 |
---|---|---|
committer | LUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2023-10-06 18:21:45 +0000 |
commit | 2844a5f3cc81ffe2b749e574bdeb61809deab5b9 (patch) | |
tree | f57cc7f5e9420b47ed4c8506e4d2c3097ac49af5 /git_command.py | |
parent | 47944bbe2ea69009c0da78573f6536ad2c77f026 (diff) | |
download | git-repo-2844a5f3cc81ffe2b749e574bdeb61809deab5b9.tar.gz |
git_command: Augment underlying git errors with suggestions
This change appends suggestions to the underlying git error to make the
error slightly more actionable.
DD: go/improve-repo-error-reporting & go/tee-repo-stderr
Bug: b/292704435
Change-Id: I2bf8bea5fca42c6a9acd2fadc70f58f22456e027
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/387774
Commit-Queue: Aravind Vasudevan <aravindvasudev@google.com>
Reviewed-by: Jason Chang <jasonnc@google.com>
Tested-by: Aravind Vasudevan <aravindvasudev@google.com>
Reviewed-by: Aravind Vasudevan <aravindvasudev@google.com>
Diffstat (limited to 'git_command.py')
-rw-r--r-- | git_command.py | 166 |
1 files changed, 136 insertions, 30 deletions
diff --git a/git_command.py b/git_command.py index fe1e48d6..ef6e321c 100644 --- a/git_command.py +++ b/git_command.py | |||
@@ -15,6 +15,7 @@ | |||
15 | import functools | 15 | import functools |
16 | import json | 16 | import json |
17 | import os | 17 | import os |
18 | import re | ||
18 | import subprocess | 19 | import subprocess |
19 | import sys | 20 | import sys |
20 | from typing import Any, Optional | 21 | from typing import Any, Optional |
@@ -24,6 +25,7 @@ from error import RepoExitError | |||
24 | from git_refs import HEAD | 25 | from git_refs import HEAD |
25 | from git_trace2_event_log_base import BaseEventLog | 26 | from git_trace2_event_log_base import BaseEventLog |
26 | import platform_utils | 27 | import platform_utils |
28 | from repo_logging import RepoLogger | ||
27 | from repo_trace import IsTrace | 29 | from repo_trace import IsTrace |
28 | from repo_trace import REPO_TRACE | 30 | from repo_trace import REPO_TRACE |
29 | from repo_trace import Trace | 31 | from repo_trace import Trace |
@@ -50,9 +52,11 @@ DEFAULT_GIT_FAIL_MESSAGE = "git command failure" | |||
50 | ERROR_EVENT_LOGGING_PREFIX = "RepoGitCommandError" | 52 | ERROR_EVENT_LOGGING_PREFIX = "RepoGitCommandError" |
51 | # Common line length limit | 53 | # Common line length limit |
52 | GIT_ERROR_STDOUT_LINES = 1 | 54 | GIT_ERROR_STDOUT_LINES = 1 |
53 | GIT_ERROR_STDERR_LINES = 1 | 55 | GIT_ERROR_STDERR_LINES = 10 |
54 | INVALID_GIT_EXIT_CODE = 126 | 56 | INVALID_GIT_EXIT_CODE = 126 |
55 | 57 | ||
58 | logger = RepoLogger(__file__) | ||
59 | |||
56 | 60 | ||
57 | class _GitCall(object): | 61 | class _GitCall(object): |
58 | @functools.lru_cache(maxsize=None) | 62 | @functools.lru_cache(maxsize=None) |
@@ -60,7 +64,7 @@ class _GitCall(object): | |||
60 | ret = Wrapper().ParseGitVersion() | 64 | ret = Wrapper().ParseGitVersion() |
61 | if ret is None: | 65 | if ret is None: |
62 | msg = "fatal: unable to detect git version" | 66 | msg = "fatal: unable to detect git version" |
63 | print(msg, file=sys.stderr) | 67 | logger.error(msg) |
64 | raise GitRequireError(msg) | 68 | raise GitRequireError(msg) |
65 | return ret | 69 | return ret |
66 | 70 | ||
@@ -135,10 +139,11 @@ def GetEventTargetPath(): | |||
135 | # `git config --get` is documented to produce an exit status of `1` | 139 | # `git config --get` is documented to produce an exit status of `1` |
136 | # if the requested variable is not present in the configuration. | 140 | # if the requested variable is not present in the configuration. |
137 | # Report any other return value as an error. | 141 | # Report any other return value as an error. |
138 | print( | 142 | logger.error( |
139 | "repo: error: 'git config --get' call failed with return code: " | 143 | "repo: error: 'git config --get' call failed with return code: " |
140 | "%r, stderr: %r" % (retval, p.stderr), | 144 | "%r, stderr: %r", |
141 | file=sys.stderr, | 145 | retval, |
146 | p.stderr, | ||
142 | ) | 147 | ) |
143 | return path | 148 | return path |
144 | 149 | ||
@@ -212,7 +217,7 @@ def git_require(min_version, fail=False, msg=""): | |||
212 | if msg: | 217 | if msg: |
213 | msg = " for " + msg | 218 | msg = " for " + msg |
214 | error_msg = "fatal: git %s or later required%s" % (need, msg) | 219 | error_msg = "fatal: git %s or later required%s" % (need, msg) |
215 | print(error_msg, file=sys.stderr) | 220 | logger.error(error_msg) |
216 | raise GitRequireError(error_msg) | 221 | raise GitRequireError(error_msg) |
217 | return False | 222 | return False |
218 | 223 | ||
@@ -297,6 +302,7 @@ class GitCommand(object): | |||
297 | self.project = project | 302 | self.project = project |
298 | self.cmdv = cmdv | 303 | self.cmdv = cmdv |
299 | self.verify_command = verify_command | 304 | self.verify_command = verify_command |
305 | self.stdout, self.stderr = None, None | ||
300 | 306 | ||
301 | # Git on Windows wants its paths only using / for reliability. | 307 | # Git on Windows wants its paths only using / for reliability. |
302 | if platform_utils.isWindows(): | 308 | if platform_utils.isWindows(): |
@@ -326,14 +332,6 @@ class GitCommand(object): | |||
326 | command.append("--progress") | 332 | command.append("--progress") |
327 | command.extend(cmdv[1:]) | 333 | command.extend(cmdv[1:]) |
328 | 334 | ||
329 | stdin = subprocess.PIPE if input else None | ||
330 | stdout = subprocess.PIPE if capture_stdout else None | ||
331 | stderr = ( | ||
332 | subprocess.STDOUT | ||
333 | if merge_output | ||
334 | else (subprocess.PIPE if capture_stderr else None) | ||
335 | ) | ||
336 | |||
337 | event_log = ( | 335 | event_log = ( |
338 | BaseEventLog(env=env, add_init_count=True) | 336 | BaseEventLog(env=env, add_init_count=True) |
339 | if add_event_log | 337 | if add_event_log |
@@ -344,9 +342,9 @@ class GitCommand(object): | |||
344 | self._RunCommand( | 342 | self._RunCommand( |
345 | command, | 343 | command, |
346 | env, | 344 | env, |
347 | stdin=stdin, | 345 | capture_stdout=capture_stdout, |
348 | stdout=stdout, | 346 | capture_stderr=capture_stderr, |
349 | stderr=stderr, | 347 | merge_output=merge_output, |
350 | ssh_proxy=ssh_proxy, | 348 | ssh_proxy=ssh_proxy, |
351 | cwd=cwd, | 349 | cwd=cwd, |
352 | input=input, | 350 | input=input, |
@@ -377,13 +375,46 @@ class GitCommand(object): | |||
377 | self, | 375 | self, |
378 | command, | 376 | command, |
379 | env, | 377 | env, |
380 | stdin=None, | 378 | capture_stdout=False, |
381 | stdout=None, | 379 | capture_stderr=False, |
382 | stderr=None, | 380 | merge_output=False, |
383 | ssh_proxy=None, | 381 | ssh_proxy=None, |
384 | cwd=None, | 382 | cwd=None, |
385 | input=None, | 383 | input=None, |
386 | ): | 384 | ): |
385 | # Set subprocess.PIPE for streams that need to be captured. | ||
386 | stdin = subprocess.PIPE if input else None | ||
387 | stdout = subprocess.PIPE if capture_stdout else None | ||
388 | stderr = ( | ||
389 | subprocess.STDOUT | ||
390 | if merge_output | ||
391 | else (subprocess.PIPE if capture_stderr else None) | ||
392 | ) | ||
393 | |||
394 | # tee_stderr acts like a tee command for stderr, in that, it captures | ||
395 | # stderr from the subprocess and streams it back to sys.stderr, while | ||
396 | # keeping a copy in-memory. | ||
397 | # This allows us to store stderr logs from the subprocess into | ||
398 | # GitCommandError. | ||
399 | # Certain git operations, such as `git push`, writes diagnostic logs, | ||
400 | # such as, progress bar for pushing, into stderr. To ensure we don't | ||
401 | # break git's UX, we need to write to sys.stderr as we read from the | ||
402 | # subprocess. Setting encoding or errors makes subprocess return | ||
403 | # io.TextIOWrapper, which is line buffered. To avoid line-buffering | ||
404 | # while tee-ing stderr, we unset these kwargs. See GitCommand._Tee | ||
405 | # for tee-ing between the streams. | ||
406 | # We tee stderr iff the caller doesn't want to capture any stream to | ||
407 | # not disrupt the existing flow. | ||
408 | # See go/tee-repo-stderr for more context. | ||
409 | tee_stderr = False | ||
410 | kwargs = {"encoding": "utf-8", "errors": "backslashreplace"} | ||
411 | if not (stdin or stdout or stderr): | ||
412 | tee_stderr = True | ||
413 | # stderr will be written back to sys.stderr even though it is | ||
414 | # piped here. | ||
415 | stderr = subprocess.PIPE | ||
416 | kwargs = {} | ||
417 | |||
387 | dbg = "" | 418 | dbg = "" |
388 | if IsTrace(): | 419 | if IsTrace(): |
389 | global LAST_CWD | 420 | global LAST_CWD |
@@ -430,11 +461,10 @@ class GitCommand(object): | |||
430 | command, | 461 | command, |
431 | cwd=cwd, | 462 | cwd=cwd, |
432 | env=env, | 463 | env=env, |
433 | encoding="utf-8", | ||
434 | errors="backslashreplace", | ||
435 | stdin=stdin, | 464 | stdin=stdin, |
436 | stdout=stdout, | 465 | stdout=stdout, |
437 | stderr=stderr, | 466 | stderr=stderr, |
467 | **kwargs, | ||
438 | ) | 468 | ) |
439 | except Exception as e: | 469 | except Exception as e: |
440 | raise GitPopenCommandError( | 470 | raise GitPopenCommandError( |
@@ -449,13 +479,45 @@ class GitCommand(object): | |||
449 | self.process = p | 479 | self.process = p |
450 | 480 | ||
451 | try: | 481 | try: |
452 | self.stdout, self.stderr = p.communicate(input=input) | 482 | if tee_stderr: |
483 | # tee_stderr streams stderr to sys.stderr while capturing | ||
484 | # a copy within self.stderr. tee_stderr is only enabled | ||
485 | # when the caller wants to pipe no stream. | ||
486 | self.stderr = self._Tee(p.stderr, sys.stderr) | ||
487 | else: | ||
488 | self.stdout, self.stderr = p.communicate(input=input) | ||
453 | finally: | 489 | finally: |
454 | if ssh_proxy: | 490 | if ssh_proxy: |
455 | ssh_proxy.remove_client(p) | 491 | ssh_proxy.remove_client(p) |
456 | self.rc = p.wait() | 492 | self.rc = p.wait() |
457 | 493 | ||
458 | @staticmethod | 494 | @staticmethod |
495 | def _Tee(in_stream, out_stream): | ||
496 | """Writes text from in_stream to out_stream while recording in buffer. | ||
497 | |||
498 | Args: | ||
499 | in_stream: I/O stream to be read from. | ||
500 | out_stream: I/O stream to write to. | ||
501 | |||
502 | Returns: | ||
503 | A str containing everything read from the in_stream. | ||
504 | """ | ||
505 | buffer = "" | ||
506 | chunk = in_stream.read1() | ||
507 | while chunk: | ||
508 | # Convert to str. | ||
509 | if not hasattr(chunk, "encode"): | ||
510 | chunk = chunk.decode("utf-8", "backslashreplace") | ||
511 | |||
512 | buffer += chunk | ||
513 | out_stream.write(chunk) | ||
514 | out_stream.flush() | ||
515 | |||
516 | chunk = in_stream.read1() | ||
517 | |||
518 | return buffer | ||
519 | |||
520 | @staticmethod | ||
459 | def _GetBasicEnv(): | 521 | def _GetBasicEnv(): |
460 | """Return a basic env for running git under. | 522 | """Return a basic env for running git under. |
461 | 523 | ||
@@ -517,6 +579,29 @@ class GitCommandError(GitError): | |||
517 | raised exclusively from non-zero exit codes returned from git commands. | 579 | raised exclusively from non-zero exit codes returned from git commands. |
518 | """ | 580 | """ |
519 | 581 | ||
582 | # Tuples with error formats and suggestions for those errors. | ||
583 | _ERROR_TO_SUGGESTION = [ | ||
584 | ( | ||
585 | re.compile("couldn't find remote ref .*"), | ||
586 | "Check if the provided ref exists in the remote.", | ||
587 | ), | ||
588 | ( | ||
589 | re.compile("unable to access '.*': .*"), | ||
590 | ( | ||
591 | "Please make sure you have the correct access rights and the " | ||
592 | "repository exists." | ||
593 | ), | ||
594 | ), | ||
595 | ( | ||
596 | re.compile("'.*' does not appear to be a git repository"), | ||
597 | "Are you running this repo command outside of a repo workspace?", | ||
598 | ), | ||
599 | ( | ||
600 | re.compile("not a git repository"), | ||
601 | "Are you running this repo command outside of a repo workspace?", | ||
602 | ), | ||
603 | ] | ||
604 | |||
520 | def __init__( | 605 | def __init__( |
521 | self, | 606 | self, |
522 | message: str = DEFAULT_GIT_FAIL_MESSAGE, | 607 | message: str = DEFAULT_GIT_FAIL_MESSAGE, |
@@ -533,16 +618,37 @@ class GitCommandError(GitError): | |||
533 | self.git_stdout = git_stdout | 618 | self.git_stdout = git_stdout |
534 | self.git_stderr = git_stderr | 619 | self.git_stderr = git_stderr |
535 | 620 | ||
621 | @property | ||
622 | @functools.lru_cache | ||
623 | def suggestion(self): | ||
624 | """Returns helpful next steps for the given stderr.""" | ||
625 | if not self.git_stderr: | ||
626 | return self.git_stderr | ||
627 | |||
628 | for err, suggestion in self._ERROR_TO_SUGGESTION: | ||
629 | if err.search(self.git_stderr): | ||
630 | return suggestion | ||
631 | |||
632 | return None | ||
633 | |||
536 | def __str__(self): | 634 | def __str__(self): |
537 | args = "[]" if not self.command_args else " ".join(self.command_args) | 635 | args = "[]" if not self.command_args else " ".join(self.command_args) |
538 | error_type = type(self).__name__ | 636 | error_type = type(self).__name__ |
539 | return f"""{error_type}: {self.message} | 637 | string = f"{error_type}: '{args}' on {self.project} failed" |
540 | Project: {self.project} | 638 | |
541 | Args: {args} | 639 | if self.message != DEFAULT_GIT_FAIL_MESSAGE: |
542 | Stdout: | 640 | string += f": {self.message}" |
543 | {self.git_stdout} | 641 | |
544 | Stderr: | 642 | if self.git_stdout: |
545 | {self.git_stderr}""" | 643 | string += f"\nstdout: {self.git_stdout}" |
644 | |||
645 | if self.git_stderr: | ||
646 | string += f"\nstderr: {self.git_stderr}" | ||
647 | |||
648 | if self.suggestion: | ||
649 | string += f"\nsuggestion: {self.suggestion}" | ||
650 | |||
651 | return string | ||
546 | 652 | ||
547 | 653 | ||
548 | class GitPopenCommandError(GitError): | 654 | class GitPopenCommandError(GitError): |