summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJason Chang <jasonnc@google.com>2023-07-26 13:23:40 -0700
committerLUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2023-07-31 21:31:36 +0000
commita6413f5d88f12466b3daa833668d0f59fc65ece4 (patch)
tree25410555a8941c500fbd55a974476ace04198dca
parent8c35d948cfa76ec685ad36fb1cb3a0fcc749f428 (diff)
downloadgit-repo-a6413f5d88f12466b3daa833668d0f59fc65ece4.tar.gz
Update errors to extend BaseRepoError
In order to better analyze and track repo errors, repo command failures need to be tied to specific errors in repo source code. Additionally a new GitCommandError was added to differentiate between general git related errors to failed git commands. Git commands that opt into verification will raise a GitCommandError if the command failed. The first step in this process is a general error refactoring Bug: b/293344017 Change-Id: I46944b1825ce892757c8dd3f7e2fab7e460760c0 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/380994 Commit-Queue: Jason Chang <jasonnc@google.com> Reviewed-by: Aravind Vasudevan <aravindvasudev@google.com> Tested-by: Jason Chang <jasonnc@google.com> Reviewed-by: Joanna Wang <jojwang@google.com>
-rw-r--r--error.py106
-rw-r--r--git_command.py74
-rw-r--r--subcmds/__init__.py2
-rw-r--r--tests/test_error.py18
-rw-r--r--tests/test_git_command.py51
5 files changed, 218 insertions, 33 deletions
diff --git a/error.py b/error.py
index 3cf34d54..ed4a90b7 100644
--- a/error.py
+++ b/error.py
@@ -12,8 +12,51 @@
12# See the License for the specific language governing permissions and 12# See the License for the specific language governing permissions and
13# limitations under the License. 13# limitations under the License.
14 14
15from typing import List
15 16
16class ManifestParseError(Exception): 17
18class BaseRepoError(Exception):
19 """All repo specific exceptions derive from BaseRepoError."""
20
21
22class RepoError(BaseRepoError):
23 """Exceptions thrown inside repo that can be handled."""
24
25 def __init__(self, *args, project: str = None) -> None:
26 super().__init__(*args)
27 self.project = project
28
29
30class RepoExitError(BaseRepoError):
31 """Exception thrown that result in termination of repo program.
32 - Should only be handled in main.py
33 """
34
35 def __init__(
36 self,
37 *args,
38 exit_code: int = 1,
39 aggregate_errors: List[Exception] = None,
40 **kwargs,
41 ) -> None:
42 super().__init__(*args, **kwargs)
43 self.exit_code = exit_code
44 self.aggregate_errors = aggregate_errors
45
46
47class RepoUnhandledExceptionError(RepoExitError):
48 """Exception that maintains error as reason for program exit."""
49
50 def __init__(
51 self,
52 error: BaseException,
53 **kwargs,
54 ) -> None:
55 super().__init__(error, **kwargs)
56 self.error = error
57
58
59class ManifestParseError(RepoExitError):
17 """Failed to parse the manifest file.""" 60 """Failed to parse the manifest file."""
18 61
19 62
@@ -25,11 +68,11 @@ class ManifestInvalidPathError(ManifestParseError):
25 """A path used in <copyfile> or <linkfile> is incorrect.""" 68 """A path used in <copyfile> or <linkfile> is incorrect."""
26 69
27 70
28class NoManifestException(Exception): 71class NoManifestException(RepoExitError):
29 """The required manifest does not exist.""" 72 """The required manifest does not exist."""
30 73
31 def __init__(self, path, reason): 74 def __init__(self, path, reason, **kwargs):
32 super().__init__(path, reason) 75 super().__init__(path, reason, **kwargs)
33 self.path = path 76 self.path = path
34 self.reason = reason 77 self.reason = reason
35 78
@@ -37,55 +80,64 @@ class NoManifestException(Exception):
37 return self.reason 80 return self.reason
38 81
39 82
40class EditorError(Exception): 83class EditorError(RepoError):
41 """Unspecified error from the user's text editor.""" 84 """Unspecified error from the user's text editor."""
42 85
43 def __init__(self, reason): 86 def __init__(self, reason, **kwargs):
44 super().__init__(reason) 87 super().__init__(reason, **kwargs)
45 self.reason = reason 88 self.reason = reason
46 89
47 def __str__(self): 90 def __str__(self):
48 return self.reason 91 return self.reason
49 92
50 93
51class GitError(Exception): 94class GitError(RepoError):
52 """Unspecified internal error from git.""" 95 """Unspecified git related error."""
53 96
54 def __init__(self, command): 97 def __init__(self, message, command_args=None, **kwargs):
55 super().__init__(command) 98 super().__init__(message, **kwargs)
56 self.command = command 99 self.message = message
100 self.command_args = command_args
57 101
58 def __str__(self): 102 def __str__(self):
59 return self.command 103 return self.message
60 104
61 105
62class UploadError(Exception): 106class UploadError(RepoError):
63 """A bundle upload to Gerrit did not succeed.""" 107 """A bundle upload to Gerrit did not succeed."""
64 108
65 def __init__(self, reason): 109 def __init__(self, reason, **kwargs):
66 super().__init__(reason) 110 super().__init__(reason, **kwargs)
67 self.reason = reason 111 self.reason = reason
68 112
69 def __str__(self): 113 def __str__(self):
70 return self.reason 114 return self.reason
71 115
72 116
73class DownloadError(Exception): 117class DownloadError(RepoExitError):
74 """Cannot download a repository.""" 118 """Cannot download a repository."""
75 119
76 def __init__(self, reason): 120 def __init__(self, reason, **kwargs):
77 super().__init__(reason) 121 super().__init__(reason, **kwargs)
78 self.reason = reason 122 self.reason = reason
79 123
80 def __str__(self): 124 def __str__(self):
81 return self.reason 125 return self.reason
82 126
83 127
84class NoSuchProjectError(Exception): 128class SyncError(RepoExitError):
129 """Cannot sync repo."""
130
131
132class UpdateManifestError(RepoExitError):
133 """Cannot update manifest."""
134
135
136class NoSuchProjectError(RepoExitError):
85 """A specified project does not exist in the work tree.""" 137 """A specified project does not exist in the work tree."""
86 138
87 def __init__(self, name=None): 139 def __init__(self, name=None, **kwargs):
88 super().__init__(name) 140 super().__init__(**kwargs)
89 self.name = name 141 self.name = name
90 142
91 def __str__(self): 143 def __str__(self):
@@ -94,11 +146,11 @@ class NoSuchProjectError(Exception):
94 return self.name 146 return self.name
95 147
96 148
97class InvalidProjectGroupsError(Exception): 149class InvalidProjectGroupsError(RepoExitError):
98 """A specified project is not suitable for the specified groups""" 150 """A specified project is not suitable for the specified groups"""
99 151
100 def __init__(self, name=None): 152 def __init__(self, name=None, **kwargs):
101 super().__init__(name) 153 super().__init__(**kwargs)
102 self.name = name 154 self.name = name
103 155
104 def __str__(self): 156 def __str__(self):
@@ -107,7 +159,7 @@ class InvalidProjectGroupsError(Exception):
107 return self.name 159 return self.name
108 160
109 161
110class RepoChangedException(Exception): 162class RepoChangedException(BaseRepoError):
111 """Thrown if 'repo sync' results in repo updating its internal 163 """Thrown if 'repo sync' results in repo updating its internal
112 repo or manifest repositories. In this special case we must 164 repo or manifest repositories. In this special case we must
113 use exec to re-execute repo with the new code and manifest. 165 use exec to re-execute repo with the new code and manifest.
@@ -118,7 +170,7 @@ class RepoChangedException(Exception):
118 self.extra_args = extra_args or [] 170 self.extra_args = extra_args or []
119 171
120 172
121class HookError(Exception): 173class HookError(RepoError):
122 """Thrown if a 'repo-hook' could not be run. 174 """Thrown if a 'repo-hook' could not be run.
123 175
124 The common case is that the file wasn't present when we tried to run it. 176 The common case is that the file wasn't present when we tried to run it.
diff --git a/git_command.py b/git_command.py
index c7245ade..588a64fd 100644
--- a/git_command.py
+++ b/git_command.py
@@ -40,6 +40,10 @@ GIT_DIR = "GIT_DIR"
40 40
41LAST_GITDIR = None 41LAST_GITDIR = None
42LAST_CWD = None 42LAST_CWD = None
43DEFAULT_GIT_FAIL_MESSAGE = "git command failure"
44# Common line length limit
45GIT_ERROR_STDOUT_LINES = 1
46GIT_ERROR_STDERR_LINES = 1
43 47
44 48
45class _GitCall(object): 49class _GitCall(object):
@@ -237,6 +241,7 @@ class GitCommand(object):
237 cwd=None, 241 cwd=None,
238 gitdir=None, 242 gitdir=None,
239 objdir=None, 243 objdir=None,
244 verify_command=False,
240 ): 245 ):
241 if project: 246 if project:
242 if not cwd: 247 if not cwd:
@@ -244,6 +249,10 @@ class GitCommand(object):
244 if not gitdir: 249 if not gitdir:
245 gitdir = project.gitdir 250 gitdir = project.gitdir
246 251
252 self.project = project
253 self.cmdv = cmdv
254 self.verify_command = verify_command
255
247 # Git on Windows wants its paths only using / for reliability. 256 # Git on Windows wants its paths only using / for reliability.
248 if platform_utils.isWindows(): 257 if platform_utils.isWindows():
249 if objdir: 258 if objdir:
@@ -332,7 +341,11 @@ class GitCommand(object):
332 stderr=stderr, 341 stderr=stderr,
333 ) 342 )
334 except Exception as e: 343 except Exception as e:
335 raise GitError("%s: %s" % (command[1], e)) 344 raise GitCommandError(
345 message="%s: %s" % (command[1], e),
346 project=project.name if project else None,
347 command_args=cmdv,
348 )
336 349
337 if ssh_proxy: 350 if ssh_proxy:
338 ssh_proxy.add_client(p) 351 ssh_proxy.add_client(p)
@@ -366,4 +379,61 @@ class GitCommand(object):
366 return env 379 return env
367 380
368 def Wait(self): 381 def Wait(self):
369 return self.rc 382 if not self.verify_command or self.rc == 0:
383 return self.rc
384
385 stdout = (
386 "\n".join(self.stdout.split("\n")[:GIT_ERROR_STDOUT_LINES])
387 if self.stdout
388 else None
389 )
390
391 stderr = (
392 "\n".join(self.stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
393 if self.stderr
394 else None
395 )
396 project = self.project.name if self.project else None
397 raise GitCommandError(
398 project=project,
399 command_args=self.cmdv,
400 git_rc=self.rc,
401 git_stdout=stdout,
402 git_stderr=stderr,
403 )
404
405
406class GitCommandError(GitError):
407 """
408 Error raised from a failed git command.
409 Note that GitError can refer to any Git related error (e.g. branch not
410 specified for project.py 'UploadForReview'), while GitCommandError is
411 raised exclusively from non-zero exit codes returned from git commands.
412 """
413
414 def __init__(
415 self,
416 message: str = DEFAULT_GIT_FAIL_MESSAGE,
417 git_rc: int = None,
418 git_stdout: str = None,
419 git_stderr: str = None,
420 **kwargs,
421 ):
422 super().__init__(
423 message,
424 **kwargs,
425 )
426 self.git_rc = git_rc
427 self.git_stdout = git_stdout
428 self.git_stderr = git_stderr
429
430 def __str__(self):
431 args = "[]" if not self.command_args else " ".join(self.command_args)
432 error_type = type(self).__name__
433 return f"""{error_type}: {self.message}
434 Project: {self.project}
435 Args: {args}
436 Stdout:
437{self.git_stdout}
438 Stderr:
439{self.git_stderr}"""
diff --git a/subcmds/__init__.py b/subcmds/__init__.py
index 4e41afc0..0754f708 100644
--- a/subcmds/__init__.py
+++ b/subcmds/__init__.py
@@ -16,6 +16,7 @@ import os
16 16
17# A mapping of the subcommand name to the class that implements it. 17# A mapping of the subcommand name to the class that implements it.
18all_commands = {} 18all_commands = {}
19all_modules = []
19 20
20my_dir = os.path.dirname(__file__) 21my_dir = os.path.dirname(__file__)
21for py in os.listdir(my_dir): 22for py in os.listdir(my_dir):
@@ -42,6 +43,7 @@ for py in os.listdir(my_dir):
42 name = name.replace("_", "-") 43 name = name.replace("_", "-")
43 cmd.NAME = name 44 cmd.NAME = name
44 all_commands[name] = cmd 45 all_commands[name] = cmd
46 all_modules.append(mod)
45 47
46# Add 'branch' as an alias for 'branches'. 48# Add 'branch' as an alias for 'branches'.
47all_commands["branch"] = all_commands["branches"] 49all_commands["branch"] = all_commands["branches"]
diff --git a/tests/test_error.py b/tests/test_error.py
index 784e2d57..2733ab8c 100644
--- a/tests/test_error.py
+++ b/tests/test_error.py
@@ -19,6 +19,15 @@ import pickle
19import unittest 19import unittest
20 20
21import error 21import error
22import project
23import git_command
24from subcmds import all_modules
25
26imports = all_modules + [
27 error,
28 project,
29 git_command,
30]
22 31
23 32
24class PickleTests(unittest.TestCase): 33class PickleTests(unittest.TestCase):
@@ -26,10 +35,11 @@ class PickleTests(unittest.TestCase):
26 35
27 def getExceptions(self): 36 def getExceptions(self):
28 """Return all our custom exceptions.""" 37 """Return all our custom exceptions."""
29 for name in dir(error): 38 for entry in imports:
30 cls = getattr(error, name) 39 for name in dir(entry):
31 if isinstance(cls, type) and issubclass(cls, Exception): 40 cls = getattr(entry, name)
32 yield cls 41 if isinstance(cls, type) and issubclass(cls, Exception):
42 yield cls
33 43
34 def testExceptionLookup(self): 44 def testExceptionLookup(self):
35 """Make sure our introspection logic works.""" 45 """Make sure our introspection logic works."""
diff --git a/tests/test_git_command.py b/tests/test_git_command.py
index c4c3a4c5..1e8beabc 100644
--- a/tests/test_git_command.py
+++ b/tests/test_git_command.py
@@ -16,6 +16,7 @@
16 16
17import re 17import re
18import os 18import os
19import subprocess
19import unittest 20import unittest
20 21
21try: 22try:
@@ -65,6 +66,56 @@ class GitCommandTest(unittest.TestCase):
65 ) 66 )
66 67
67 68
69class GitCommandWaitTest(unittest.TestCase):
70 """Tests the GitCommand class .Wait()"""
71
72 def setUp(self):
73 class MockPopen(object):
74 rc = 0
75
76 def communicate(
77 self, input: str = None, timeout: float = None
78 ) -> [str, str]:
79 """Mock communicate fn."""
80 return ["", ""]
81
82 def wait(self, timeout=None):
83 return self.rc
84
85 self.popen = popen = MockPopen()
86
87 def popen_mock(*args, **kwargs):
88 return popen
89
90 def realpath_mock(val):
91 return val
92
93 mock.patch.object(subprocess, "Popen", side_effect=popen_mock).start()
94
95 mock.patch.object(
96 os.path, "realpath", side_effect=realpath_mock
97 ).start()
98
99 def tearDown(self):
100 mock.patch.stopall()
101
102 def test_raises_when_verify_non_zero_result(self):
103 self.popen.rc = 1
104 r = git_command.GitCommand(None, ["status"], verify_command=True)
105 with self.assertRaises(git_command.GitCommandError):
106 r.Wait()
107
108 def test_returns_when_no_verify_non_zero_result(self):
109 self.popen.rc = 1
110 r = git_command.GitCommand(None, ["status"], verify_command=False)
111 self.assertEqual(1, r.Wait())
112
113 def test_default_returns_non_zero_result(self):
114 self.popen.rc = 1
115 r = git_command.GitCommand(None, ["status"])
116 self.assertEqual(1, r.Wait())
117
118
68class GitCallUnitTest(unittest.TestCase): 119class GitCallUnitTest(unittest.TestCase):
69 """Tests the _GitCall class (via git_command.git).""" 120 """Tests the _GitCall class (via git_command.git)."""
70 121