Skip to content

Commit eba6fce

Browse files
authoredFeb 23, 2024
Merge pull request #1838 from EliahKagan/refresh-version
Fix version_info cache invalidation, typing, parsing, and serialization
2 parents afa5754 + 629fd87 commit eba6fce

File tree

3 files changed

+244
-33
lines changed

3 files changed

+244
-33
lines changed
 

‎git/cmd.py

+40-22
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
import contextlib
1010
import io
11+
import itertools
1112
import logging
1213
import os
1314
import signal
@@ -25,7 +26,6 @@
2526
UnsafeProtocolError,
2627
)
2728
from 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

@@ -359,6 +365,8 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
359365
the top level ``__init__``.
360366
"""
361367

368+
_refresh_token = object() # Since None would match an initial _version_info_token.
369+
362370
@classmethod
363371
def refresh(cls, path: Union[None, PathLike] = None) -> bool:
364372
"""This gets called by the refresh function (see the top level __init__)."""
@@ -371,7 +379,9 @@ def refresh(cls, path: Union[None, PathLike] = None) -> bool:
371379

372380
# Keep track of the old and new git executable path.
373381
old_git = cls.GIT_PYTHON_GIT_EXECUTABLE
382+
old_refresh_token = cls._refresh_token
374383
cls.GIT_PYTHON_GIT_EXECUTABLE = new_git
384+
cls._refresh_token = object()
375385

376386
# Test if the new git executable path is valid. A GitCommandNotFound error is
377387
# spawned by us. A PermissionError is spawned if the git executable cannot be
@@ -400,6 +410,7 @@ def refresh(cls, path: Union[None, PathLike] = None) -> bool:
400410

401411
# Revert to whatever the old_git was.
402412
cls.GIT_PYTHON_GIT_EXECUTABLE = old_git
413+
cls._refresh_token = old_refresh_token
403414

404415
if old_git is None:
405416
# On the first refresh (when GIT_PYTHON_GIT_EXECUTABLE is None) we only
@@ -783,6 +794,10 @@ def __init__(self, working_dir: Union[None, PathLike] = None):
783794
# Extra environment variables to pass to git commands
784795
self._environment: Dict[str, str] = {}
785796

797+
# Cached version slots
798+
self._version_info: Union[Tuple[int, ...], None] = None
799+
self._version_info_token: object = None
800+
786801
# Cached command slots
787802
self.cat_file_header: Union[None, TBD] = None
788803
self.cat_file_all: Union[None, TBD] = None
@@ -795,8 +810,8 @@ def __getattr__(self, name: str) -> Any:
795810
Callable object that will execute call :meth:`_call_process` with
796811
your arguments.
797812
"""
798-
if name[0] == "_":
799-
return LazyMixin.__getattr__(self, name)
813+
if name.startswith("_"):
814+
return super().__getattribute__(name)
800815
return lambda *args, **kwargs: self._call_process(name, *args, **kwargs)
801816

802817
def set_persistent_git_options(self, **kwargs: Any) -> None:
@@ -811,33 +826,36 @@ def set_persistent_git_options(self, **kwargs: Any) -> None:
811826

812827
self._persistent_git_options = self.transform_kwargs(split_single_char_options=True, **kwargs)
813828

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-
828829
@property
829830
def working_dir(self) -> Union[None, PathLike]:
830831
""":return: Git directory we are working on"""
831832
return self._working_dir
832833

833834
@property
834-
def version_info(self) -> Tuple[int, int, int, int]:
835+
def version_info(self) -> Tuple[int, ...]:
835836
"""
836-
:return: tuple(int, int, int, int) tuple with integers representing the major, minor
837-
and additional version numbers as parsed from git version.
837+
:return: tuple with integers representing the major, minor and additional
838+
version numbers as parsed from git version. Up to four fields are used.
838839
839840
This value is generated on demand and is cached.
840841
"""
842+
# Refreshing is global, but version_info caching is per-instance.
843+
refresh_token = self._refresh_token # Copy token in case of concurrent refresh.
844+
845+
# Use the cached version if obtained after the most recent refresh.
846+
if self._version_info_token is refresh_token:
847+
assert self._version_info is not None, "Bug: corrupted token-check state"
848+
return self._version_info
849+
850+
# Run "git version" and parse it.
851+
process_version = self._call_process("version")
852+
version_string = process_version.split(" ")[2]
853+
version_fields = version_string.split(".")[:4]
854+
leading_numeric_fields = itertools.takewhile(str.isdigit, version_fields)
855+
self._version_info = tuple(map(int, leading_numeric_fields))
856+
857+
# This value will be considered valid until the next refresh.
858+
self._version_info_token = refresh_token
841859
return self._version_info
842860

843861
@overload

0 commit comments

Comments
 (0)
Failed to load comments.