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>
diff --git a/git_command.py b/git_command.py
index c7245ad..588a64f 100644
--- a/git_command.py
+++ b/git_command.py
@@ -40,6 +40,10 @@
 
 LAST_GITDIR = None
 LAST_CWD = None
+DEFAULT_GIT_FAIL_MESSAGE = "git command failure"
+# Common line length limit
+GIT_ERROR_STDOUT_LINES = 1
+GIT_ERROR_STDERR_LINES = 1
 
 
 class _GitCall(object):
@@ -237,6 +241,7 @@
         cwd=None,
         gitdir=None,
         objdir=None,
+        verify_command=False,
     ):
         if project:
             if not cwd:
@@ -244,6 +249,10 @@
             if not gitdir:
                 gitdir = project.gitdir
 
+        self.project = project
+        self.cmdv = cmdv
+        self.verify_command = verify_command
+
         # Git on Windows wants its paths only using / for reliability.
         if platform_utils.isWindows():
             if objdir:
@@ -332,7 +341,11 @@
                     stderr=stderr,
                 )
             except Exception as e:
-                raise GitError("%s: %s" % (command[1], e))
+                raise GitCommandError(
+                    message="%s: %s" % (command[1], e),
+                    project=project.name if project else None,
+                    command_args=cmdv,
+                )
 
             if ssh_proxy:
                 ssh_proxy.add_client(p)
@@ -366,4 +379,61 @@
         return env
 
     def Wait(self):
-        return self.rc
+        if not self.verify_command or self.rc == 0:
+            return self.rc
+
+        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,
+        )
+
+
+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.
+    """
+
+    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
+
+    def __str__(self):
+        args = "[]" if not self.command_args else " ".join(self.command_args)
+        error_type = type(self).__name__
+        return f"""{error_type}: {self.message}
+    Project: {self.project}
+    Args: {args}
+    Stdout:
+{self.git_stdout}
+    Stderr:
+{self.git_stderr}"""