Repository: gitpython-developers/GitPython
Pull request #640 (2017-07-09 20:12:54)

Adding setup for git executable
Added a convenience function called `refresh`. This functions accomplishes 2 things.

The first is that now the user can dynamically set the git path AFTER importing GitPython. In the following example git is neither on the user's $PATH nor is $GIT_PYTHON_GIT_EXECUTABLE defined:
```
>>> import git
WARNING: Bad git executable. The git executable must be specified in one of the following ways:
    (1) be included in your $PATH, or
    (2) be set via $GIT_PYTHON_GIT_EXECUTABLE, or
    (3) explicitly set via git.setup (or git.refresh).

All git commands will error until this is rectified.

>>> git.refresh("/usr/bin/git")
>>> git.Git().version()
'git version 2.11.0 (Apple Git-81)'
>>> 
```

The second is that this also means that the git executable can be dynamically refreshed at any time by simply calling `refresh`:
```
>>> import git
>>> git.Git.GIT_PYTHON_GIT_EXECUTABLE
'git'
>>> git.refresh("/usr/bin/git")
>>> git.Git.GIT_PYTHON_GIT_EXECUTABLE
'/usr/bin/git'
```


Patch:
diff --git a/AUTHORS b/AUTHORS
index f0c02e33..0d3bdacf 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -19,6 +19,7 @@ Contributors are:
 -Timothy B. Hartman <tbhartman _at_ gmail.com>
 -Konstantin Popov <konstantin.popov.89 _at_ yandex.ru>
 -Peter Jones <pjones _at_ redhat.com>
+-Ken Odegard <ken.odegard _at_ gmail.com>
 -Alexis Horgix Chotard
 
 Portions derived from other open source works and are clearly marked.
diff --git a/git/__init__.py b/git/__init__.py
index 75247dbf..74609e79 100644
--- a/git/__init__.py
+++ b/git/__init__.py
@@ -60,3 +60,24 @@ except GitError as exc:
 
 __all__ = [name for name, obj in locals().items()
            if not (name.startswith('_') or inspect.ismodule(obj))]
+
+
+#{ Initialize git executable path
+GIT_OK = None
+
+def refresh(path=None):
+    """Convenience method for setting the git executable path."""
+    global GIT_OK
+    GIT_OK = False
+
+    if not Git.refresh(path=path):
+        return
+    if not FetchInfo.refresh():
+        return
+
+    GIT_OK = True
+#} END initialize git executable path
+
+#################
+refresh()
+#################
diff --git a/git/cmd.py b/git/cmd.py
index a2fcf50d..312f933a 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -17,6 +17,7 @@ from subprocess import (
 import subprocess
 import sys
 import threading
+from textwrap import dedent
 
 from git.compat import (
     string_types,
@@ -182,16 +183,141 @@ class Git(LazyMixin):
     # Enables debugging of GitPython's git commands
     GIT_PYTHON_TRACE = os.environ.get("GIT_PYTHON_TRACE", False)
 
-    # Provide the full path to the git executable. Otherwise it assumes git is in the path
-    _git_exec_env_var = "GIT_PYTHON_GIT_EXECUTABLE"
-    GIT_PYTHON_GIT_EXECUTABLE = os.environ.get(_git_exec_env_var, git_exec_name)
-
     # If True, a shell will be used when executing git commands.
     # This should only be desirable on Windows, see https://github.com/gitpython-developers/GitPython/pull/126
     # and check `git/test_repo.py:TestRepo.test_untracked_files()` TC for an example where it is required.
     # Override this value using `Git.USE_SHELL = True`
     USE_SHELL = False
 
+    # Provide the full path to the git executable. Otherwise it assumes git is in the path
+    _git_exec_env_var = "GIT_PYTHON_GIT_EXECUTABLE"
+    _refresh_env_var = "GIT_PYTHON_REFRESH"
+    GIT_PYTHON_GIT_EXECUTABLE = None
+    # note that the git executable is actually found during the refresh step in
+    # the top level __init__
+
+    @classmethod
+    def refresh(cls, path=None):
+        """This gets called by the refresh function (see the top level
+        __init__).
+        """
+        # discern which path to refresh with
+        if path is not None:
+            new_git = os.path.expanduser(path)
+            new_git = os.path.abspath(new_git)
+        else:
+            new_git = os.environ.get(cls._git_exec_env_var, cls.git_exec_name)
+
+        # keep track of the old and new git executable path
+        old_git = cls.GIT_PYTHON_GIT_EXECUTABLE
+        cls.GIT_PYTHON_GIT_EXECUTABLE = new_git
+
+        # test if the new git executable path is valid
+
+        if sys.version_info < (3,):
+            # - a GitCommandNotFound error is spawned by ourselves
+            # - a OSError is spawned if the git executable provided
+            #   cannot be executed for whatever reason
+            exceptions = (GitCommandNotFound, OSError)
+        else:
+            # - a GitCommandNotFound error is spawned by ourselves
+            # - a PermissionError is spawned if the git executable provided
+            #   cannot be executed for whatever reason
+            exceptions = (GitCommandNotFound, PermissionError)
+
+        has_git = False
+        try:
+            cls().version()
+            has_git = True
+        except exceptions:
+            pass
+
+        # warn or raise exception if test failed
+        if not has_git:
+            err = dedent("""\
+                Bad git executable.
+                The git executable must be specified in one of the following ways:
+                    - be included in your $PATH
+                    - be set via $%s
+                    - explicitly set via git.refresh()
+                """) % cls._git_exec_env_var
+
+            # revert to whatever the old_git was
+            cls.GIT_PYTHON_GIT_EXECUTABLE = old_git
+
+            if old_git is None:
+                # on the first refresh (when GIT_PYTHON_GIT_EXECUTABLE is
+                # None) we only are quiet, warn, or error depending on the
+                # GIT_PYTHON_REFRESH value
+
+                # determine what the user wants to happen during the initial
+                # refresh we expect GIT_PYTHON_REFRESH to either be unset or
+                # be one of the following values:
+                #   0|q|quiet|s|silence
+                #   1|w|warn|warning
+                #   2|r|raise|e|error
+
+                mode = os.environ.get(cls._refresh_env_var, "raise").lower()
+
+                quiet = ["quiet", "q", "silence", "s", "none", "n", "0"]
+                warn = ["warn", "w", "warning", "1"]
+                error = ["error", "e", "raise", "r", "2"]
+
+                if mode in quiet:
+                    pass
+                elif mode in warn or mode in error:
+                    err = dedent("""\
+                        %s
+                        All git commands will error until this is rectified.
+
+                        This initial warning can be silenced or aggravated in the future by setting the
+                        $%s environment variable. Use one of the following values:
+                            - %s: for no warning or exception
+                            - %s: for a printed warning
+                            - %s: for a raised exception
+
+                        Example:
+                            export %s=%s
+                        """) % (
+                        err,
+                        cls._refresh_env_var,
+                        "|".join(quiet),
+                        "|".join(warn),
+                        "|".join(error),
+                        cls._refresh_env_var,
+                        quiet[0])
+
+                    if mode in warn:
+                        print("WARNING: %s" % err)
+                    else:
+                        raise ImportError(err)
+                else:
+                    err = dedent("""\
+                        %s environment variable has been set but it has been set with an invalid value.
+
+                        Use only the following values:
+                            - %s: for no warning or exception
+                            - %s: for a printed warning
+                            - %s: for a raised exception
+                        """) % (
+                        cls._refresh_env_var,
+                        "|".join(quiet),
+                        "|".join(warn),
+                        "|".join(error))
+                    raise ImportError(err)
+
+                # we get here if this was the init refresh and the refresh mode
+                # was not error, go ahead and set the GIT_PYTHON_GIT_EXECUTABLE
+                # such that we discern the difference between a first import
+                # and a second import
+                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)
+
+        return has_git
+
     @classmethod
     def is_cygwin(cls):
         return is_cygwin_git(cls.GIT_PYTHON_GIT_EXECUTABLE)
@@ -828,13 +954,13 @@ class Git(LazyMixin):
             - "command options" to be converted by :meth:`transform_kwargs()`;
             - the `'insert_kwargs_after'` key which its value must match one of ``*args``,
               and any cmd-options will be appended after the matched arg.
-        
+
         Examples::
-        
+
             git.rev_list('master', max_count=10, header=True)
-        
+
         turns into::
-        
+
            git rev-list max-count 10 --header master
 
         :return: Same as ``execute``"""
diff --git a/git/remote.py b/git/remote.py
index 81719f4b..7261be81 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -211,17 +211,38 @@ class FetchInfo(object):
 
     _re_fetch_result = re.compile(r'^\s*(.) (\[?[\w\s\.$@]+\]?)\s+(.+) -> ([^\s]+)(    \(.*\)?$)?')
 
-    _flag_map = {'!': ERROR,
-                 '+': FORCED_UPDATE,
-                 '*': 0,
-                 '=': HEAD_UPTODATE,
-                 ' ': FAST_FORWARD}
+    _flag_map = {
+        '!': ERROR,
+        '+': FORCED_UPDATE,
+        '*': 0,
+        '=': HEAD_UPTODATE,
+        ' ': FAST_FORWARD,
+        '-': TAG_UPDATE,
+    }
 
-    v = Git().version_info[:2]
-    if v >= (2, 10):
-        _flag_map['t'] = TAG_UPDATE
-    else:
-        _flag_map['-'] = TAG_UPDATE
+    @classmethod
+    def refresh(cls):
+        """This gets called by the refresh function (see the top level
+        __init__).
+        """
+        # clear the old values in _flag_map
+        try:
+            del cls._flag_map["t"]
+        except KeyError:
+            pass
+
+        try:
+            del cls._flag_map["-"]
+        except KeyError:
+            pass
+
+        # set the value given the git version
+        if Git().version_info[:2] >= (2, 10):
+            cls._flag_map["t"] = cls.TAG_UPDATE
+        else:
+            cls._flag_map["-"] = cls.TAG_UPDATE
+
+        return True
 
     def __init__(self, ref, flags, note='', old_commit=None, remote_ref_path=None):
         """


Test patch:
diff --git a/git/test/test_git.py b/git/test/test_git.py
index 3c8b6f82..00577e33 100644
--- a/git/test/test_git.py
+++ b/git/test/test_git.py
@@ -10,6 +10,7 @@ import sys
 
 from git import (
     Git,
+    refresh,
     GitCommandError,
     GitCommandNotFound,
     Repo,
@@ -156,6 +157,14 @@ class TestGit(TestBase):
             type(self.git).GIT_PYTHON_GIT_EXECUTABLE = prev_cmd
         # END undo adjustment
 
+    def test_refresh(self):
+        # test a bad git path refresh
+        self.assertRaises(GitCommandNotFound, refresh, "yada")
+
+        # test a good path refresh
+        path = os.popen("which git").read().strip()
+        refresh(path)
+
     def test_options_are_passed_to_git(self):
         # This work because any command after git --version is ignored
         git_version = self.git(version=True).NoOp()