summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAravind Vasudevan <aravindvasudev@google.com>2023-10-06 00:40:25 +0000
committerLUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2023-10-06 18:21:45 +0000
commit2844a5f3cc81ffe2b749e574bdeb61809deab5b9 (patch)
treef57cc7f5e9420b47ed4c8506e4d2c3097ac49af5
parent47944bbe2ea69009c0da78573f6536ad2c77f026 (diff)
downloadgit-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>
-rw-r--r--git_command.py166
-rw-r--r--project.py2
-rw-r--r--tests/test_git_command.py133
3 files changed, 270 insertions, 31 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 @@
15import functools 15import functools
16import json 16import json
17import os 17import os
18import re
18import subprocess 19import subprocess
19import sys 20import sys
20from typing import Any, Optional 21from typing import Any, Optional
@@ -24,6 +25,7 @@ from error import RepoExitError
24from git_refs import HEAD 25from git_refs import HEAD
25from git_trace2_event_log_base import BaseEventLog 26from git_trace2_event_log_base import BaseEventLog
26import platform_utils 27import platform_utils
28from repo_logging import RepoLogger
27from repo_trace import IsTrace 29from repo_trace import IsTrace
28from repo_trace import REPO_TRACE 30from repo_trace import REPO_TRACE
29from repo_trace import Trace 31from repo_trace import Trace
@@ -50,9 +52,11 @@ DEFAULT_GIT_FAIL_MESSAGE = "git command failure"
50ERROR_EVENT_LOGGING_PREFIX = "RepoGitCommandError" 52ERROR_EVENT_LOGGING_PREFIX = "RepoGitCommandError"
51# Common line length limit 53# Common line length limit
52GIT_ERROR_STDOUT_LINES = 1 54GIT_ERROR_STDOUT_LINES = 1
53GIT_ERROR_STDERR_LINES = 1 55GIT_ERROR_STDERR_LINES = 10
54INVALID_GIT_EXIT_CODE = 126 56INVALID_GIT_EXIT_CODE = 126
55 57
58logger = RepoLogger(__file__)
59
56 60
57class _GitCall(object): 61class _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
548class GitPopenCommandError(GitError): 654class GitPopenCommandError(GitError):
diff --git a/project.py b/project.py
index 3acecd64..642c123f 100644
--- a/project.py
+++ b/project.py
@@ -1135,7 +1135,7 @@ class Project(object):
1135 url = branch.remote.ReviewUrl(self.UserEmail, validate_certs) 1135 url = branch.remote.ReviewUrl(self.UserEmail, validate_certs)
1136 if url is None: 1136 if url is None:
1137 raise UploadError("review not configured", project=self.name) 1137 raise UploadError("review not configured", project=self.name)
1138 cmd = ["push"] 1138 cmd = ["push", "--progress"]
1139 if dryrun: 1139 if dryrun:
1140 cmd.append("-n") 1140 cmd.append("-n")
1141 1141
diff --git a/tests/test_git_command.py b/tests/test_git_command.py
index c803d280..881cccb8 100644
--- a/tests/test_git_command.py
+++ b/tests/test_git_command.py
@@ -14,6 +14,7 @@
14 14
15"""Unittests for the git_command.py module.""" 15"""Unittests for the git_command.py module."""
16 16
17import io
17import os 18import os
18import re 19import re
19import subprocess 20import subprocess
@@ -74,6 +75,10 @@ class GitCommandWaitTest(unittest.TestCase):
74 class MockPopen(object): 75 class MockPopen(object):
75 rc = 0 76 rc = 0
76 77
78 def __init__(self):
79 self.stdout = io.BufferedReader(io.BytesIO())
80 self.stderr = io.BufferedReader(io.BytesIO())
81
77 def communicate( 82 def communicate(
78 self, input: str = None, timeout: float = None 83 self, input: str = None, timeout: float = None
79 ) -> [str, str]: 84 ) -> [str, str]:
@@ -117,6 +122,115 @@ class GitCommandWaitTest(unittest.TestCase):
117 self.assertEqual(1, r.Wait()) 122 self.assertEqual(1, r.Wait())
118 123
119 124
125class GitCommandStreamLogsTest(unittest.TestCase):
126 """Tests the GitCommand class stderr log streaming cases."""
127
128 def setUp(self):
129 self.mock_process = mock.MagicMock()
130 self.mock_process.communicate.return_value = (None, None)
131 self.mock_process.wait.return_value = 0
132
133 self.mock_popen = mock.MagicMock()
134 self.mock_popen.return_value = self.mock_process
135 mock.patch("subprocess.Popen", self.mock_popen).start()
136
137 def tearDown(self):
138 mock.patch.stopall()
139
140 def test_does_not_stream_logs_when_input_is_set(self):
141 git_command.GitCommand(None, ["status"], input="foo")
142
143 self.mock_popen.assert_called_once_with(
144 ["git", "status"],
145 cwd=None,
146 env=mock.ANY,
147 encoding="utf-8",
148 errors="backslashreplace",
149 stdin=subprocess.PIPE,
150 stdout=None,
151 stderr=None,
152 )
153 self.mock_process.communicate.assert_called_once_with(input="foo")
154 self.mock_process.stderr.read1.assert_not_called()
155
156 def test_does_not_stream_logs_when_stdout_is_set(self):
157 git_command.GitCommand(None, ["status"], capture_stdout=True)
158
159 self.mock_popen.assert_called_once_with(
160 ["git", "status"],
161 cwd=None,
162 env=mock.ANY,
163 encoding="utf-8",
164 errors="backslashreplace",
165 stdin=None,
166 stdout=subprocess.PIPE,
167 stderr=None,
168 )
169 self.mock_process.communicate.assert_called_once_with(input=None)
170 self.mock_process.stderr.read1.assert_not_called()
171
172 def test_does_not_stream_logs_when_stderr_is_set(self):
173 git_command.GitCommand(None, ["status"], capture_stderr=True)
174
175 self.mock_popen.assert_called_once_with(
176 ["git", "status"],
177 cwd=None,
178 env=mock.ANY,
179 encoding="utf-8",
180 errors="backslashreplace",
181 stdin=None,
182 stdout=None,
183 stderr=subprocess.PIPE,
184 )
185 self.mock_process.communicate.assert_called_once_with(input=None)
186 self.mock_process.stderr.read1.assert_not_called()
187
188 def test_does_not_stream_logs_when_merge_output_is_set(self):
189 git_command.GitCommand(None, ["status"], merge_output=True)
190
191 self.mock_popen.assert_called_once_with(
192 ["git", "status"],
193 cwd=None,
194 env=mock.ANY,
195 encoding="utf-8",
196 errors="backslashreplace",
197 stdin=None,
198 stdout=None,
199 stderr=subprocess.STDOUT,
200 )
201 self.mock_process.communicate.assert_called_once_with(input=None)
202 self.mock_process.stderr.read1.assert_not_called()
203
204 @mock.patch("sys.stderr")
205 def test_streams_stderr_when_no_stream_is_set(self, mock_stderr):
206 logs = "\n".join(
207 [
208 "Enumerating objects: 5, done.",
209 "Counting objects: 100% (5/5), done.",
210 "Writing objects: 100% (3/3), 330 bytes | 330 KiB/s, done.",
211 "remote: Processing changes: refs: 1, new: 1, done ",
212 "remote: SUCCESS",
213 ]
214 )
215 self.mock_process.stderr = io.BufferedReader(
216 io.BytesIO(bytes(logs, "utf-8"))
217 )
218
219 cmd = git_command.GitCommand(None, ["push"])
220
221 self.mock_popen.assert_called_once_with(
222 ["git", "push"],
223 cwd=None,
224 env=mock.ANY,
225 stdin=None,
226 stdout=None,
227 stderr=subprocess.PIPE,
228 )
229 self.mock_process.communicate.assert_not_called()
230 mock_stderr.write.assert_called_once_with(logs)
231 self.assertEqual(cmd.stderr, logs)
232
233
120class GitCallUnitTest(unittest.TestCase): 234class GitCallUnitTest(unittest.TestCase):
121 """Tests the _GitCall class (via git_command.git).""" 235 """Tests the _GitCall class (via git_command.git)."""
122 236
@@ -214,3 +328,22 @@ class GitRequireTests(unittest.TestCase):
214 with self.assertRaises(git_command.GitRequireError) as e: 328 with self.assertRaises(git_command.GitRequireError) as e:
215 git_command.git_require((2,), fail=True, msg="so sad") 329 git_command.git_require((2,), fail=True, msg="so sad")
216 self.assertNotEqual(0, e.code) 330 self.assertNotEqual(0, e.code)
331
332
333class GitCommandErrorTest(unittest.TestCase):
334 """Test for the GitCommandError class."""
335
336 def test_augument_stderr(self):
337 self.assertEqual(
338 git_command.GitCommandError(
339 git_stderr="couldn't find remote ref refs/heads/foo"
340 ).suggestion,
341 "Check if the provided ref exists in the remote.",
342 )
343
344 self.assertEqual(
345 git_command.GitCommandError(
346 git_stderr="'foobar' does not appear to be a git repository"
347 ).suggestion,
348 "Are you running this repo command outside of a repo workspace?",
349 )