diff options
| author | Jason Chang <jasonnc@google.com> | 2023-08-03 14:38:00 -0700 |
|---|---|---|
| committer | LUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2023-08-07 23:56:07 +0000 |
| commit | f9aacd4087b02948da9a7878da48ea186ab99d5a (patch) | |
| tree | b683190635cd6fcb7cf817837ad0c4259b53078f | |
| parent | b8a7b4a629c3435d77a3266a4e6dce51dc342bd9 (diff) | |
| download | git-repo-f9aacd4087b02948da9a7878da48ea186ab99d5a.tar.gz | |
Raise repo exit errors in place of sys.exit
Bug: b/293344017
Change-Id: I92d81c78eba8ff31b5252415f4c9a515a6c76411
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/381774
Tested-by: Jason Chang <jasonnc@google.com>
Reviewed-by: Joanna Wang <jojwang@google.com>
Commit-Queue: Jason Chang <jasonnc@google.com>
| -rw-r--r-- | command.py | 8 | ||||
| -rw-r--r-- | fetch.py | 9 | ||||
| -rw-r--r-- | git_command.py | 21 | ||||
| -rw-r--r-- | project.py | 22 | ||||
| -rw-r--r-- | subcmds/abandon.py | 24 | ||||
| -rw-r--r-- | subcmds/init.py | 12 | ||||
| -rw-r--r-- | subcmds/upload.py | 10 | ||||
| -rw-r--r-- | tests/test_error.py | 4 | ||||
| -rw-r--r-- | tests/test_git_command.py | 4 |
9 files changed, 80 insertions, 34 deletions
| @@ -16,11 +16,11 @@ import multiprocessing | |||
| 16 | import os | 16 | import os |
| 17 | import optparse | 17 | import optparse |
| 18 | import re | 18 | import re |
| 19 | import sys | ||
| 20 | 19 | ||
| 21 | from event_log import EventLog | 20 | from event_log import EventLog |
| 22 | from error import NoSuchProjectError | 21 | from error import NoSuchProjectError |
| 23 | from error import InvalidProjectGroupsError | 22 | from error import InvalidProjectGroupsError |
| 23 | from error import RepoExitError | ||
| 24 | import progress | 24 | import progress |
| 25 | 25 | ||
| 26 | 26 | ||
| @@ -42,6 +42,10 @@ WORKER_BATCH_SIZE = 32 | |||
| 42 | DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8) | 42 | DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8) |
| 43 | 43 | ||
| 44 | 44 | ||
| 45 | class UsageError(RepoExitError): | ||
| 46 | """Exception thrown with invalid command usage.""" | ||
| 47 | |||
| 48 | |||
| 45 | class Command(object): | 49 | class Command(object): |
| 46 | """Base class for any command line action in repo.""" | 50 | """Base class for any command line action in repo.""" |
| 47 | 51 | ||
| @@ -215,7 +219,7 @@ class Command(object): | |||
| 215 | def Usage(self): | 219 | def Usage(self): |
| 216 | """Display usage and terminate.""" | 220 | """Display usage and terminate.""" |
| 217 | self.OptionParser.print_usage() | 221 | self.OptionParser.print_usage() |
| 218 | sys.exit(1) | 222 | raise UsageError() |
| 219 | 223 | ||
| 220 | def CommonValidateOptions(self, opt, args): | 224 | def CommonValidateOptions(self, opt, args): |
| 221 | """Validate common options.""" | 225 | """Validate common options.""" |
| @@ -18,6 +18,11 @@ import subprocess | |||
| 18 | import sys | 18 | import sys |
| 19 | from urllib.parse import urlparse | 19 | from urllib.parse import urlparse |
| 20 | from urllib.request import urlopen | 20 | from urllib.request import urlopen |
| 21 | from error import RepoExitError | ||
| 22 | |||
| 23 | |||
| 24 | class FetchFileError(RepoExitError): | ||
| 25 | """Exit error when fetch_file fails.""" | ||
| 21 | 26 | ||
| 22 | 27 | ||
| 23 | def fetch_file(url, verbose=False): | 28 | def fetch_file(url, verbose=False): |
| @@ -29,6 +34,7 @@ def fetch_file(url, verbose=False): | |||
| 29 | scheme = urlparse(url).scheme | 34 | scheme = urlparse(url).scheme |
| 30 | if scheme == "gs": | 35 | if scheme == "gs": |
| 31 | cmd = ["gsutil", "cat", url] | 36 | cmd = ["gsutil", "cat", url] |
| 37 | errors = [] | ||
| 32 | try: | 38 | try: |
| 33 | result = subprocess.run( | 39 | result = subprocess.run( |
| 34 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True | 40 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True |
| @@ -41,9 +47,10 @@ def fetch_file(url, verbose=False): | |||
| 41 | ) | 47 | ) |
| 42 | return result.stdout | 48 | return result.stdout |
| 43 | except subprocess.CalledProcessError as e: | 49 | except subprocess.CalledProcessError as e: |
| 50 | errors.append(e) | ||
| 44 | print( | 51 | print( |
| 45 | 'fatal: error running "gsutil": %s' % e.stderr, file=sys.stderr | 52 | 'fatal: error running "gsutil": %s' % e.stderr, file=sys.stderr |
| 46 | ) | 53 | ) |
| 47 | sys.exit(1) | 54 | raise FetchFileError(aggregate_errors=errors) |
| 48 | with urlopen(url) as f: | 55 | with urlopen(url) as f: |
| 49 | return f.read() | 56 | return f.read() |
diff --git a/git_command.py b/git_command.py index 588a64fd..36fcfe7c 100644 --- a/git_command.py +++ b/git_command.py | |||
| @@ -19,6 +19,7 @@ import subprocess | |||
| 19 | from typing import Any, Optional | 19 | from typing import Any, Optional |
| 20 | 20 | ||
| 21 | from error import GitError | 21 | from error import GitError |
| 22 | from error import RepoExitError | ||
| 22 | from git_refs import HEAD | 23 | from git_refs import HEAD |
| 23 | import platform_utils | 24 | import platform_utils |
| 24 | from repo_trace import REPO_TRACE, IsTrace, Trace | 25 | from repo_trace import REPO_TRACE, IsTrace, Trace |
| @@ -44,6 +45,7 @@ DEFAULT_GIT_FAIL_MESSAGE = "git command failure" | |||
| 44 | # Common line length limit | 45 | # Common line length limit |
| 45 | GIT_ERROR_STDOUT_LINES = 1 | 46 | GIT_ERROR_STDOUT_LINES = 1 |
| 46 | GIT_ERROR_STDERR_LINES = 1 | 47 | GIT_ERROR_STDERR_LINES = 1 |
| 48 | INVALID_GIT_EXIT_CODE = 126 | ||
| 47 | 49 | ||
| 48 | 50 | ||
| 49 | class _GitCall(object): | 51 | class _GitCall(object): |
| @@ -51,8 +53,9 @@ class _GitCall(object): | |||
| 51 | def version_tuple(self): | 53 | def version_tuple(self): |
| 52 | ret = Wrapper().ParseGitVersion() | 54 | ret = Wrapper().ParseGitVersion() |
| 53 | if ret is None: | 55 | if ret is None: |
| 54 | print("fatal: unable to detect git version", file=sys.stderr) | 56 | msg = "fatal: unable to detect git version" |
| 55 | sys.exit(1) | 57 | print(msg, file=sys.stderr) |
| 58 | raise GitRequireError(msg) | ||
| 56 | return ret | 59 | return ret |
| 57 | 60 | ||
| 58 | def __getattr__(self, name): | 61 | def __getattr__(self, name): |
| @@ -167,10 +170,9 @@ def git_require(min_version, fail=False, msg=""): | |||
| 167 | need = ".".join(map(str, min_version)) | 170 | need = ".".join(map(str, min_version)) |
| 168 | if msg: | 171 | if msg: |
| 169 | msg = " for " + msg | 172 | msg = " for " + msg |
| 170 | print( | 173 | error_msg = "fatal: git %s or later required%s" % (need, msg) |
| 171 | "fatal: git %s or later required%s" % (need, msg), file=sys.stderr | 174 | print(error_msg, file=sys.stderr) |
| 172 | ) | 175 | raise GitRequireError(error_msg) |
| 173 | sys.exit(1) | ||
| 174 | return False | 176 | return False |
| 175 | 177 | ||
| 176 | 178 | ||
| @@ -403,6 +405,13 @@ class GitCommand(object): | |||
| 403 | ) | 405 | ) |
| 404 | 406 | ||
| 405 | 407 | ||
| 408 | class GitRequireError(RepoExitError): | ||
| 409 | """Error raised when git version is unavailable or invalid.""" | ||
| 410 | |||
| 411 | def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE): | ||
| 412 | super().__init__(message, exit_code=exit_code) | ||
| 413 | |||
| 414 | |||
| 406 | class GitCommandError(GitError): | 415 | class GitCommandError(GitError): |
| 407 | """ | 416 | """ |
| 408 | Error raised from a failed git command. | 417 | Error raised from a failed git command. |
| @@ -2003,8 +2003,8 @@ class Project(object): | |||
| 2003 | name: The name of the branch to abandon. | 2003 | name: The name of the branch to abandon. |
| 2004 | 2004 | ||
| 2005 | Returns: | 2005 | Returns: |
| 2006 | True if the abandon succeeded; False if it didn't; None if the | 2006 | True if the abandon succeeded; Raises GitCommandError if it didn't; |
| 2007 | branch didn't exist. | 2007 | None if the branch didn't exist. |
| 2008 | """ | 2008 | """ |
| 2009 | rev = R_HEADS + name | 2009 | rev = R_HEADS + name |
| 2010 | all_refs = self.bare_ref.all | 2010 | all_refs = self.bare_ref.all |
| @@ -2025,16 +2025,14 @@ class Project(object): | |||
| 2025 | ) | 2025 | ) |
| 2026 | else: | 2026 | else: |
| 2027 | self._Checkout(revid, quiet=True) | 2027 | self._Checkout(revid, quiet=True) |
| 2028 | 2028 | GitCommand( | |
| 2029 | return ( | 2029 | self, |
| 2030 | GitCommand( | 2030 | ["branch", "-D", name], |
| 2031 | self, | 2031 | capture_stdout=True, |
| 2032 | ["branch", "-D", name], | 2032 | capture_stderr=True, |
| 2033 | capture_stdout=True, | 2033 | verify_command=True, |
| 2034 | capture_stderr=True, | 2034 | ).Wait() |
| 2035 | ).Wait() | 2035 | return True |
| 2036 | == 0 | ||
| 2037 | ) | ||
| 2038 | 2036 | ||
| 2039 | def PruneHeads(self): | 2037 | def PruneHeads(self): |
| 2040 | """Prune any topic branches already merged into upstream.""" | 2038 | """Prune any topic branches already merged into upstream.""" |
diff --git a/subcmds/abandon.py b/subcmds/abandon.py index ded287f6..896b348f 100644 --- a/subcmds/abandon.py +++ b/subcmds/abandon.py | |||
| @@ -20,6 +20,11 @@ import sys | |||
| 20 | from command import Command, DEFAULT_LOCAL_JOBS | 20 | from command import Command, DEFAULT_LOCAL_JOBS |
| 21 | from git_command import git | 21 | from git_command import git |
| 22 | from progress import Progress | 22 | from progress import Progress |
| 23 | from error import RepoError, RepoExitError | ||
| 24 | |||
| 25 | |||
| 26 | class AbandonError(RepoExitError): | ||
| 27 | """Exit error when abandon command fails.""" | ||
| 23 | 28 | ||
| 24 | 29 | ||
| 25 | class Abandon(Command): | 30 | class Abandon(Command): |
| @@ -68,28 +73,37 @@ It is equivalent to "git branch -D <branchname>". | |||
| 68 | branches = nb | 73 | branches = nb |
| 69 | 74 | ||
| 70 | ret = {} | 75 | ret = {} |
| 76 | errors = [] | ||
| 71 | for name in branches: | 77 | for name in branches: |
| 72 | status = project.AbandonBranch(name) | 78 | status = None |
| 79 | try: | ||
| 80 | status = project.AbandonBranch(name) | ||
| 81 | except RepoError as e: | ||
| 82 | status = False | ||
| 83 | errors.append(e) | ||
| 73 | if status is not None: | 84 | if status is not None: |
| 74 | ret[name] = status | 85 | ret[name] = status |
| 75 | return (ret, project) | 86 | |
| 87 | return (ret, project, errors) | ||
| 76 | 88 | ||
| 77 | def Execute(self, opt, args): | 89 | def Execute(self, opt, args): |
| 78 | nb = args[0].split() | 90 | nb = args[0].split() |
| 79 | err = defaultdict(list) | 91 | err = defaultdict(list) |
| 80 | success = defaultdict(list) | 92 | success = defaultdict(list) |
| 93 | aggregate_errors = [] | ||
| 81 | all_projects = self.GetProjects( | 94 | all_projects = self.GetProjects( |
| 82 | args[1:], all_manifests=not opt.this_manifest_only | 95 | args[1:], all_manifests=not opt.this_manifest_only |
| 83 | ) | 96 | ) |
| 84 | _RelPath = lambda p: p.RelPath(local=opt.this_manifest_only) | 97 | _RelPath = lambda p: p.RelPath(local=opt.this_manifest_only) |
| 85 | 98 | ||
| 86 | def _ProcessResults(_pool, pm, states): | 99 | def _ProcessResults(_pool, pm, states): |
| 87 | for results, project in states: | 100 | for results, project, errors in states: |
| 88 | for branch, status in results.items(): | 101 | for branch, status in results.items(): |
| 89 | if status: | 102 | if status: |
| 90 | success[branch].append(project) | 103 | success[branch].append(project) |
| 91 | else: | 104 | else: |
| 92 | err[branch].append(project) | 105 | err[branch].append(project) |
| 106 | aggregate_errors.extend(errors) | ||
| 93 | pm.update(msg="") | 107 | pm.update(msg="") |
| 94 | 108 | ||
| 95 | self.ExecuteInParallel( | 109 | self.ExecuteInParallel( |
| @@ -116,13 +130,13 @@ It is equivalent to "git branch -D <branchname>". | |||
| 116 | " " * len(err_msg) + " | %s" % _RelPath(proj), | 130 | " " * len(err_msg) + " | %s" % _RelPath(proj), |
| 117 | file=sys.stderr, | 131 | file=sys.stderr, |
| 118 | ) | 132 | ) |
| 119 | sys.exit(1) | 133 | raise AbandonError(aggregate_errors=aggregate_errors) |
| 120 | elif not success: | 134 | elif not success: |
| 121 | print( | 135 | print( |
| 122 | "error: no project has local branch(es) : %s" % nb, | 136 | "error: no project has local branch(es) : %s" % nb, |
| 123 | file=sys.stderr, | 137 | file=sys.stderr, |
| 124 | ) | 138 | ) |
| 125 | sys.exit(1) | 139 | raise AbandonError(aggregate_errors=aggregate_errors) |
| 126 | else: | 140 | else: |
| 127 | # Everything below here is displaying status. | 141 | # Everything below here is displaying status. |
| 128 | if opt.quiet: | 142 | if opt.quiet: |
diff --git a/subcmds/init.py b/subcmds/init.py index 6d7fd857..868d339e 100644 --- a/subcmds/init.py +++ b/subcmds/init.py | |||
| @@ -19,6 +19,8 @@ from color import Coloring | |||
| 19 | from command import InteractiveCommand, MirrorSafeCommand | 19 | from command import InteractiveCommand, MirrorSafeCommand |
| 20 | from git_command import git_require, MIN_GIT_VERSION_SOFT, MIN_GIT_VERSION_HARD | 20 | from git_command import git_require, MIN_GIT_VERSION_SOFT, MIN_GIT_VERSION_HARD |
| 21 | from wrapper import Wrapper | 21 | from wrapper import Wrapper |
| 22 | from error import UpdateManifestError | ||
| 23 | from error import RepoUnhandledExceptionError | ||
| 22 | 24 | ||
| 23 | _REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW") | 25 | _REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW") |
| 24 | 26 | ||
| @@ -156,7 +158,10 @@ to update the working directory files. | |||
| 156 | git_event_log=self.git_event_log, | 158 | git_event_log=self.git_event_log, |
| 157 | manifest_name=opt.manifest_name, | 159 | manifest_name=opt.manifest_name, |
| 158 | ): | 160 | ): |
| 159 | sys.exit(1) | 161 | manifest_name = opt.manifest_name |
| 162 | raise UpdateManifestError( | ||
| 163 | f"Unable to sync manifest {manifest_name}" | ||
| 164 | ) | ||
| 160 | 165 | ||
| 161 | def _Prompt(self, prompt, value): | 166 | def _Prompt(self, prompt, value): |
| 162 | print("%-10s [%s]: " % (prompt, value), end="", flush=True) | 167 | print("%-10s [%s]: " % (prompt, value), end="", flush=True) |
| @@ -346,14 +351,15 @@ to update the working directory files. | |||
| 346 | repo_verify=opt.repo_verify, | 351 | repo_verify=opt.repo_verify, |
| 347 | quiet=opt.quiet, | 352 | quiet=opt.quiet, |
| 348 | ) | 353 | ) |
| 349 | except wrapper.CloneFailure: | 354 | except wrapper.CloneFailure as e: |
| 350 | err_msg = "fatal: double check your --repo-rev setting." | 355 | err_msg = "fatal: double check your --repo-rev setting." |
| 351 | print( | 356 | print( |
| 352 | err_msg, | 357 | err_msg, |
| 353 | file=sys.stderr, | 358 | file=sys.stderr, |
| 354 | ) | 359 | ) |
| 355 | self.git_event_log.ErrorEvent(err_msg) | 360 | self.git_event_log.ErrorEvent(err_msg) |
| 356 | sys.exit(1) | 361 | raise RepoUnhandledExceptionError(e) |
| 362 | |||
| 357 | branch = rp.GetBranch("default") | 363 | branch = rp.GetBranch("default") |
| 358 | branch.merge = remote_ref | 364 | branch.merge = remote_ref |
| 359 | rp.work_git.reset("--hard", rev) | 365 | rp.work_git.reset("--hard", rev) |
diff --git a/subcmds/upload.py b/subcmds/upload.py index 8d949bef..d0c028b9 100644 --- a/subcmds/upload.py +++ b/subcmds/upload.py | |||
| @@ -21,7 +21,7 @@ from typing import List | |||
| 21 | 21 | ||
| 22 | from command import DEFAULT_LOCAL_JOBS, InteractiveCommand | 22 | from command import DEFAULT_LOCAL_JOBS, InteractiveCommand |
| 23 | from editor import Editor | 23 | from editor import Editor |
| 24 | from error import UploadError | 24 | from error import UploadError, RepoExitError |
| 25 | from git_command import GitCommand | 25 | from git_command import GitCommand |
| 26 | from git_refs import R_HEADS | 26 | from git_refs import R_HEADS |
| 27 | from hooks import RepoHook | 27 | from hooks import RepoHook |
| @@ -31,6 +31,10 @@ from project import ReviewableBranch | |||
| 31 | _DEFAULT_UNUSUAL_COMMIT_THRESHOLD = 5 | 31 | _DEFAULT_UNUSUAL_COMMIT_THRESHOLD = 5 |
| 32 | 32 | ||
| 33 | 33 | ||
| 34 | class UploadExitError(RepoExitError): | ||
| 35 | """Indicates that there is an upload command error requiring a sys exit.""" | ||
| 36 | |||
| 37 | |||
| 34 | def _VerifyPendingCommits(branches: List[ReviewableBranch]) -> bool: | 38 | def _VerifyPendingCommits(branches: List[ReviewableBranch]) -> bool: |
| 35 | """Perform basic safety checks on the given set of branches. | 39 | """Perform basic safety checks on the given set of branches. |
| 36 | 40 | ||
| @@ -86,7 +90,7 @@ def _VerifyPendingCommits(branches: List[ReviewableBranch]) -> bool: | |||
| 86 | def _die(fmt, *args): | 90 | def _die(fmt, *args): |
| 87 | msg = fmt % args | 91 | msg = fmt % args |
| 88 | print("error: %s" % msg, file=sys.stderr) | 92 | print("error: %s" % msg, file=sys.stderr) |
| 89 | sys.exit(1) | 93 | raise UploadExitError(msg) |
| 90 | 94 | ||
| 91 | 95 | ||
| 92 | def _SplitEmails(values): | 96 | def _SplitEmails(values): |
| @@ -697,7 +701,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/ | |||
| 697 | ) | 701 | ) |
| 698 | 702 | ||
| 699 | if have_errors: | 703 | if have_errors: |
| 700 | sys.exit(1) | 704 | raise branch.error |
| 701 | 705 | ||
| 702 | def _GetMergeBranch(self, project, local_branch=None): | 706 | def _GetMergeBranch(self, project, local_branch=None): |
| 703 | if local_branch is None: | 707 | if local_branch is None: |
diff --git a/tests/test_error.py b/tests/test_error.py index 2733ab8c..2b28f5c2 100644 --- a/tests/test_error.py +++ b/tests/test_error.py | |||
| @@ -21,12 +21,16 @@ import unittest | |||
| 21 | import error | 21 | import error |
| 22 | import project | 22 | import project |
| 23 | import git_command | 23 | import git_command |
| 24 | import fetch | ||
| 25 | import command | ||
| 24 | from subcmds import all_modules | 26 | from subcmds import all_modules |
| 25 | 27 | ||
| 26 | imports = all_modules + [ | 28 | imports = all_modules + [ |
| 27 | error, | 29 | error, |
| 28 | project, | 30 | project, |
| 29 | git_command, | 31 | git_command, |
| 32 | fetch, | ||
| 33 | command, | ||
| 30 | ] | 34 | ] |
| 31 | 35 | ||
| 32 | 36 | ||
diff --git a/tests/test_git_command.py b/tests/test_git_command.py index 1e8beabc..3dd31b29 100644 --- a/tests/test_git_command.py +++ b/tests/test_git_command.py | |||
| @@ -204,12 +204,12 @@ class GitRequireTests(unittest.TestCase): | |||
| 204 | 204 | ||
| 205 | def test_older_fatal(self): | 205 | def test_older_fatal(self): |
| 206 | """Test fatal require calls with old versions.""" | 206 | """Test fatal require calls with old versions.""" |
| 207 | with self.assertRaises(SystemExit) as e: | 207 | with self.assertRaises(git_command.GitRequireError) as e: |
| 208 | git_command.git_require((2,), fail=True) | 208 | git_command.git_require((2,), fail=True) |
| 209 | self.assertNotEqual(0, e.code) | 209 | self.assertNotEqual(0, e.code) |
| 210 | 210 | ||
| 211 | def test_older_fatal_msg(self): | 211 | def test_older_fatal_msg(self): |
| 212 | """Test fatal require calls with old versions and message.""" | 212 | """Test fatal require calls with old versions and message.""" |
| 213 | with self.assertRaises(SystemExit) as e: | 213 | with self.assertRaises(git_command.GitRequireError) as e: |
| 214 | git_command.git_require((2,), fail=True, msg="so sad") | 214 | git_command.git_require((2,), fail=True, msg="so sad") |
| 215 | self.assertNotEqual(0, e.code) | 215 | self.assertNotEqual(0, e.code) |
