88import re
99import contextlib
1010import io
11+ import itertools
1112import logging
1213import os
1314import signal
2526 UnsafeProtocolError ,
2627)
2728from git .util import (
28- LazyMixin ,
2929 cygpath ,
3030 expand_path ,
3131 is_cygwin_git ,
@@ -287,7 +287,7 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc
287287## -- End Utilities -- @}
288288
289289
290- class Git ( LazyMixin ) :
290+ class Git :
291291 """The Git class manages communication with the Git binary.
292292
293293 It provides a convenient interface to calling the Git binary, such as in::
@@ -307,12 +307,18 @@ class Git(LazyMixin):
307307 "cat_file_all" ,
308308 "cat_file_header" ,
309309 "_version_info" ,
310+ "_version_info_token" ,
310311 "_git_options" ,
311312 "_persistent_git_options" ,
312313 "_environment" ,
313314 )
314315
315- _excluded_ = ("cat_file_all" , "cat_file_header" , "_version_info" )
316+ _excluded_ = (
317+ "cat_file_all" ,
318+ "cat_file_header" ,
319+ "_version_info" ,
320+ "_version_info_token" ,
321+ )
316322
317323 re_unsafe_protocol = re .compile (r"(.+)::.+" )
318324
@@ -344,6 +350,7 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
344350 for, which is not possible under most circumstances.
345351
346352 See:
353+
347354 - :meth:`Git.execute` (on the ``shell`` parameter).
348355 - https://github.com/gitpython-developers/GitPython/commit/0d9390866f9ce42870d3116094cd49e0019a970a
349356 - https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
@@ -355,13 +362,50 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
355362 GIT_PYTHON_GIT_EXECUTABLE = None
356363 """Provide the full path to the git executable. Otherwise it assumes git is in the path.
357364
358- Note that the git executable is actually found during the refresh step in
359- the top level ``__init__``.
365+ :note: The git executable is actually found during the refresh step in
366+ the top level :mod:`__init__`. It can also be changed by explicitly calling
367+ :func:`git.refresh`.
360368 """
361369
370+ _refresh_token = object () # Since None would match an initial _version_info_token.
371+
362372 @classmethod
363373 def refresh (cls , path : Union [None , PathLike ] = None ) -> bool :
364- """This gets called by the refresh function (see the top level __init__)."""
374+ """This gets called by the refresh function (see the top level __init__).
375+
376+ :param path: Optional path to the git executable. If not absolute, it is
377+ resolved immediately, relative to the current directory. (See note below.)
378+
379+ :note: The top-level :func:`git.refresh` should be preferred because it calls
380+ this method and may also update other state accordingly.
381+
382+ :note: There are three different ways to specify what command refreshing causes
383+ to be uses for git:
384+
385+ 1. Pass no *path* argument and do not set the ``GIT_PYTHON_GIT_EXECUTABLE``
386+ environment variable. The command name ``git`` is used. It is looked up
387+ in a path search by the system, in each command run (roughly similar to
388+ how git is found when running ``git`` commands manually). This is usually
389+ the desired behavior.
390+
391+ 2. Pass no *path* argument but set the ``GIT_PYTHON_GIT_EXECUTABLE``
392+ environment variable. The command given as the value of that variable is
393+ used. This may be a simple command or an arbitrary path. It is looked up
394+ in each command run. Setting ``GIT_PYTHON_GIT_EXECUTABLE`` to ``git`` has
395+ the same effect as not setting it.
396+
397+ 3. Pass a *path* argument. This path, if not absolute, it immediately
398+ resolved, relative to the current directory. This resolution occurs at
399+ the time of the refresh, and when git commands are run, they are run with
400+ that previously resolved path. If a *path* argument is passed, the
401+ ``GIT_PYTHON_GIT_EXECUTABLE`` environment variable is not consulted.
402+
403+ :note: Refreshing always sets the :attr:`Git.GIT_PYTHON_GIT_EXECUTABLE` class
404+ attribute, which can be read on the :class:`Git` class or any of its
405+ instances to check what command is used to run git. This attribute should
406+ not be confused with the related ``GIT_PYTHON_GIT_EXECUTABLE`` environment
407+ variable. The class attribute is set no matter how refreshing is performed.
408+ """
365409 # Discern which path to refresh with.
366410 if path is not None :
367411 new_git = os .path .expanduser (path )
@@ -371,7 +415,9 @@ def refresh(cls, path: Union[None, PathLike] = None) -> bool:
371415
372416 # Keep track of the old and new git executable path.
373417 old_git = cls .GIT_PYTHON_GIT_EXECUTABLE
418+ old_refresh_token = cls ._refresh_token
374419 cls .GIT_PYTHON_GIT_EXECUTABLE = new_git
420+ cls ._refresh_token = object ()
375421
376422 # Test if the new git executable path is valid. A GitCommandNotFound error is
377423 # spawned by us. A PermissionError is spawned if the git executable cannot be
@@ -392,14 +438,15 @@ def refresh(cls, path: Union[None, PathLike] = None) -> bool:
392438 The git executable must be specified in one of the following ways:
393439 - be included in your $PATH
394440 - be set via $%s
395- - explicitly set via git.refresh()
441+ - explicitly set via git.refresh("/full/path/to/git" )
396442 """
397443 )
398444 % cls ._git_exec_env_var
399445 )
400446
401447 # Revert to whatever the old_git was.
402448 cls .GIT_PYTHON_GIT_EXECUTABLE = old_git
449+ cls ._refresh_token = old_refresh_token
403450
404451 if old_git is None :
405452 # On the first refresh (when GIT_PYTHON_GIT_EXECUTABLE is None) we only
@@ -783,6 +830,10 @@ def __init__(self, working_dir: Union[None, PathLike] = None):
783830 # Extra environment variables to pass to git commands
784831 self ._environment : Dict [str , str ] = {}
785832
833+ # Cached version slots
834+ self ._version_info : Union [Tuple [int , ...], None ] = None
835+ self ._version_info_token : object = None
836+
786837 # Cached command slots
787838 self .cat_file_header : Union [None , TBD ] = None
788839 self .cat_file_all : Union [None , TBD ] = None
@@ -795,8 +846,8 @@ def __getattr__(self, name: str) -> Any:
795846 Callable object that will execute call :meth:`_call_process` with
796847 your arguments.
797848 """
798- if name [ 0 ] == "_" :
799- return LazyMixin . __getattr__ ( self , name )
849+ if name . startswith ( "_" ) :
850+ return super (). __getattribute__ ( name )
800851 return lambda * args , ** kwargs : self ._call_process (name , * args , ** kwargs )
801852
802853 def set_persistent_git_options (self , ** kwargs : Any ) -> None :
@@ -811,33 +862,36 @@ def set_persistent_git_options(self, **kwargs: Any) -> None:
811862
812863 self ._persistent_git_options = self .transform_kwargs (split_single_char_options = True , ** kwargs )
813864
814- def _set_cache_ (self , attr : str ) -> None :
815- if attr == "_version_info" :
816- # We only use the first 4 numbers, as everything else could be strings in fact (on Windows).
817- process_version = self ._call_process ("version" ) # Should be as default *args and **kwargs used.
818- version_numbers = process_version .split (" " )[2 ]
819-
820- self ._version_info = cast (
821- Tuple [int , int , int , int ],
822- tuple (int (n ) for n in version_numbers .split ("." )[:4 ] if n .isdigit ()),
823- )
824- else :
825- super ()._set_cache_ (attr )
826- # END handle version info
827-
828865 @property
829866 def working_dir (self ) -> Union [None , PathLike ]:
830867 """:return: Git directory we are working on"""
831868 return self ._working_dir
832869
833870 @property
834- def version_info (self ) -> Tuple [int , int , int , int ]:
871+ def version_info (self ) -> Tuple [int , ... ]:
835872 """
836- :return: tuple(int, int, int, int) tuple with integers representing the major, minor
837- and additional version numbers as parsed from git version.
873+ :return: tuple with integers representing the major, minor and additional
874+ version numbers as parsed from git version. Up to four fields are used .
838875
839876 This value is generated on demand and is cached.
840877 """
878+ # Refreshing is global, but version_info caching is per-instance.
879+ refresh_token = self ._refresh_token # Copy token in case of concurrent refresh.
880+
881+ # Use the cached version if obtained after the most recent refresh.
882+ if self ._version_info_token is refresh_token :
883+ assert self ._version_info is not None , "Bug: corrupted token-check state"
884+ return self ._version_info
885+
886+ # Run "git version" and parse it.
887+ process_version = self ._call_process ("version" )
888+ version_string = process_version .split (" " )[2 ]
889+ version_fields = version_string .split ("." )[:4 ]
890+ leading_numeric_fields = itertools .takewhile (str .isdigit , version_fields )
891+ self ._version_info = tuple (map (int , leading_numeric_fields ))
892+
893+ # This value will be considered valid until the next refresh.
894+ self ._version_info_token = refresh_token
841895 return self ._version_info
842896
843897 @overload
0 commit comments