Repository: gitpython-developers/GitPython

Issue:
Git.refresh GitCommandNotFound indicates "git" even for other commands
### The bug

Even when the Git command that `Git.refresh` runs and checks is not `git`, its command line is reported as `git` in the `GitCommandNotFound` exception, when such an exception is raised:

https://github.com/gitpython-developers/GitPython/blob/d28c20b1dbf18dc60a2b1859e818801bd8d742f2/git/cmd.py#L483-L485

### Steps to reproduce

The effect happens when the exception is raised due to a subsequent call to `Git.refresh`, since that is when `Git.refresh` raises `GitCommandNotFound` rather than `ImportError`. The `command` attribute is directly part of the traceback message, presented as `cmdline`. This can be produced by running a shell one-liner:

```text
$ GIT_PYTHON_GIT_EXECUTABLE=git-2.43 GIT_PYTHON_REFRESH=quiet python -c 'import git; git.refresh()'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/home/ek/repos-wsl/GitPython/git/__init__.py", line 127, in refresh
    if not Git.refresh(path=path):
           ^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ek/repos-wsl/GitPython/git/cmd.py", line 485, in refresh
    raise GitCommandNotFound("git", err)
git.exc.GitCommandNotFound: Cmd('git') not found due to: 'Bad git executable.
The git executable must be specified in one of the following ways:
    - be included in your $PATH
    - be set via $GIT_PYTHON_GIT_EXECUTABLE
    - explicitly set via git.refresh()
'
  cmdline: git
```

Although often a custom Git command may be a full path whose last component is exactly `git` (or sometimes `git.exe` on Windows), the above shows a realistic scenario in which even that may not be the case. I do not have a command named `git-2.43` installed, so it is correct that I get an error, but the error should not state the name of the command as `git`.

### Verification

Verbose debugging confirms that the command given in `GIT_PYTHON_GIT_EXECUTABLE` really is running, and that the only problem is that the hard-coded command name `git` is printed instead:

```text
$ GIT_PYTHON_GIT_EXECUTABLE=git-2.43 GIT_PYTHON_REFRESH=quiet GIT_PYTHON_TRACE=full python -c 'import logging; logging.basicConfig(level=logging.DEBUG); import git; git.refresh()'
DEBUG:git.cmd:Popen(['git-2.43', 'version'], cwd=/home/ek/repos-wsl/GitPython, stdin=None, shell=False, universal_newlines=False)
DEBUG:git.cmd:Popen(['git-2.43', 'version'], cwd=/home/ek/repos-wsl/GitPython, stdin=None, shell=False, universal_newlines=False)
Traceback (most recent call last):
  ...
  cmdline: git
```

Capturing the `subprocess.Popen` [audit event](https://docs.python.org/3/library/audit_events.html) verifies that no other logging bug in GitPython is confusing the analysis:

```text
$ GIT_PYTHON_GIT_EXECUTABLE=git-2.43 GIT_PYTHON_REFRESH=quiet python -c 'import sys, git; sys.addaudithook(lambda event, args: event == "subprocess.Popen" and print(args[:2])); git.refresh()'
('git-2.43', ['git-2.43', 'version'])
Traceback (most recent call last):
  ...
  cmdline: git
```

(For both the above runs, I replaced most of the traceback with `...`.)

### Why this is a bug

Since it's possible, however unlikely, that someone has come to rely on the exception object's `command` attribute having the exact value `"git"` in this situation, it should *arguably* only be changed if it really is a bug for it to hold that value.

I believe it is a bug, for three reasons:

#### 1. It is presented as the command line

When users see this in the `GitCommandNotFound` exception message, I believe they will assume it is the command that was run:

```text
  cmdline: git
```

#### 2. It differs from any other `GitCommandNotFound`

In other situations that raise `GitCommandNotFound`, it comes from `Git.execute`, and the exception is constructed with the actual command line as `command` (except that redactions are performed, such as for passwords):

https://github.com/gitpython-developers/GitPython/blob/d28c20b1dbf18dc60a2b1859e818801bd8d742f2/git/cmd.py#L1065-L1079

#### 3. The superclass documents the `command` argument

`GitCommandNotFound` inherits its `command` attribute from its `CommandError` superclass. Although `CommandError` does not document the `command` attribute directly, it does document the `command` argument from which the value of that attribute is derived:

https://github.com/gitpython-developers/GitPython/blob/d28c20b1dbf18dc60a2b1859e818801bd8d742f2/git/exc.py#L83-L88

This does not really guarantee the specific meaning of the `command` *attribute*. (Likskov substitutability is usually a goal for objects that have already been constructed/initialized; subclass `__new__` and `__init__` are not expected to treat arguments the same ways as the superclass.) But it is a further reason to consider the current behavior in `Git.refresh` unexpected.

### Possible fixes

This could be fixed by having `Git.refresh` construct the `GitCommandNotFoundError` with the same command line that its `cls().version()` call causes `Git.execute` to use, which is given by `GIT_PYTHON_GIT_EXECUTABLE`.

Another approach, though, would be to keep a reference to the `GitCommandNotFound` exception that occurred occurred due to running `cls().version()`:

https://github.com/gitpython-developers/GitPython/blob/d28c20b1dbf18dc60a2b1859e818801bd8d742f2/git/cmd.py#L381-L385

Then that same exception object, with its original information, could be reraised instead of constructing and raising a new `GitCommandNotFound` object.

If this is to be done, then some refactoring may be in order, and a decision about whether that `GitCommandNotFound` object should be used as the `cause` of an `ImportError` in the case that is raised should probably also be made:

https://github.com/gitpython-developers/GitPython/blob/d28c20b1dbf18dc60a2b1859e818801bd8d742f2/git/cmd.py#L476

Thus, in addition to the other changes, that statement could be changed to the `raise ... from ...` form.

There may be reasons to avoid this approach, in whole or in part, that I am not aware of.

Discussion:

Patch:
diff --git a/git/cmd.py b/git/cmd.py
index 24ba71b5..b3bd48b8 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -478,12 +478,12 @@ class Git(LazyMixin):
                 # We get here if this was the initial refresh and the refresh mode was
                 # not error. Go ahead and set the GIT_PYTHON_GIT_EXECUTABLE such that we
                 # discern the difference between the first refresh at import time
-                # and subsequent calls to refresh().
+                # and subsequent calls to git.refresh or this refresh method.
                 cls.GIT_PYTHON_GIT_EXECUTABLE = cls.git_exec_name
             else:
                 # After the first refresh (when GIT_PYTHON_GIT_EXECUTABLE is no longer
                 # None) we raise an exception.
-                raise GitCommandNotFound("git", err)
+                raise GitCommandNotFound(new_git, err)
 
         return has_git
 
diff --git a/git/remote.py b/git/remote.py
index 98a421b3..7ebe566b 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -5,30 +5,24 @@
 
 """Module implementing a remote object allowing easy access to git remotes."""
 
+import contextlib
 import logging
 import re
 
-from git.cmd import handle_process_output, Git
+from git.cmd import Git, handle_process_output
 from git.compat import defenc, force_text
+from git.config import GitConfigParser, SectionConstraint, cp
 from git.exc import GitCommandError
+from git.refs import Head, Reference, RemoteReference, SymbolicReference, TagReference
 from git.util import (
-    LazyMixin,
-    IterableObj,
+    CallableRemoteProgress,
     IterableList,
+    IterableObj,
+    LazyMixin,
     RemoteProgress,
-    CallableRemoteProgress,
-)
-from git.util import (
     join_path,
 )
 
-from git.config import (
-    GitConfigParser,
-    SectionConstraint,
-    cp,
-)
-from git.refs import Head, Reference, RemoteReference, SymbolicReference, TagReference
-
 # typing-------------------------------------------------------
 
 from typing import (
@@ -345,18 +339,13 @@ class FetchInfo(IterableObj):
     @classmethod
     def refresh(cls) -> Literal[True]:
         """This gets called by the refresh function (see the top level __init__)."""
-        # clear the old values in _flag_map
-        try:
+        # Clear the old values in _flag_map.
+        with contextlib.suppress(KeyError):
             del cls._flag_map["t"]
-        except KeyError:
-            pass
-
-        try:
+        with contextlib.suppress(KeyError):
             del cls._flag_map["-"]
-        except KeyError:
-            pass
 
-        # set the value given the git version
+        # Set the value given the git version.
         if Git().version_info[:2] >= (2, 10):
             cls._flag_map["t"] = cls.TAG_UPDATE
         else:


Test patch:
diff --git a/test/test_git.py b/test/test_git.py
index 3b9abc71..a9af0b04 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -44,6 +44,14 @@ def _patch_out_env(name):
             os.environ[name] = old_value
 
 
+@contextlib.contextmanager
+def _rollback_refresh():
+    try:
+        yield Git.GIT_PYTHON_GIT_EXECUTABLE  # Provide the old value for convenience.
+    finally:
+        refresh()
+
+
 @ddt.ddt
 class TestGit(TestBase):
     @classmethod
@@ -306,14 +314,43 @@ class TestGit(TestBase):
         ):
             self.assertRaises(GitCommandNotFound, self.git.version)
 
-    def test_refresh(self):
-        # Test a bad git path refresh.
-        self.assertRaises(GitCommandNotFound, refresh, "yada")
-
-        # Test a good path refresh.
-        which_cmd = "where" if os.name == "nt" else "command -v"
-        path = os.popen("{0} git".format(which_cmd)).read().strip().split("\n")[0]
-        refresh(path)
+    def test_refresh_bad_absolute_git_path(self):
+        """Bad absolute path arg is reported and not set."""
+        absolute_path = str(Path("yada").absolute())
+        expected_pattern = rf"\n[ \t]*cmdline: {re.escape(absolute_path)}\Z"
+
+        with _rollback_refresh() as old_git_executable:
+            with self.assertRaisesRegex(GitCommandNotFound, expected_pattern):
+                refresh(absolute_path)
+            self.assertEqual(self.git.GIT_PYTHON_GIT_EXECUTABLE, old_git_executable)
+
+    def test_refresh_bad_relative_git_path(self):
+        """Bad relative path arg is resolved to absolute path and reported, not set."""
+        absolute_path = str(Path("yada").absolute())
+        expected_pattern = rf"\n[ \t]*cmdline: {re.escape(absolute_path)}\Z"
+
+        with _rollback_refresh() as old_git_executable:
+            with self.assertRaisesRegex(GitCommandNotFound, expected_pattern):
+                refresh("yada")
+            self.assertEqual(self.git.GIT_PYTHON_GIT_EXECUTABLE, old_git_executable)
+
+    def test_refresh_good_absolute_git_path(self):
+        """Good absolute path arg is set."""
+        absolute_path = shutil.which("git")
+
+        with _rollback_refresh():
+            refresh(absolute_path)
+            self.assertEqual(self.git.GIT_PYTHON_GIT_EXECUTABLE, absolute_path)
+
+    def test_refresh_good_relative_git_path(self):
+        """Good relative path arg is resolved to absolute path and set."""
+        absolute_path = shutil.which("git")
+        dirname, basename = osp.split(absolute_path)
+
+        with cwd(dirname):
+            with _rollback_refresh():
+                refresh(basename)
+                self.assertEqual(self.git.GIT_PYTHON_GIT_EXECUTABLE, absolute_path)
 
     def test_options_are_passed_to_git(self):
         # This works because any command after git --version is ignored.