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
 

Diff for: ‎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

Diff for: ‎test/test_git.py

+203-10
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
import os
1111
import os.path as osp
1212
from pathlib import Path
13+
import pickle
1314
import re
1415
import shutil
1516
import subprocess
1617
import sys
17-
from tempfile import TemporaryFile
18+
import tempfile
1819
from unittest import skipUnless
1920

2021
if sys.version_info >= (3, 8):
@@ -67,6 +68,32 @@ def _rollback_refresh():
6768
refresh()
6869

6970

71+
@contextlib.contextmanager
72+
def _fake_git(*version_info):
73+
fake_version = ".".join(map(str, version_info))
74+
fake_output = f"git version {fake_version} (fake)"
75+
76+
with tempfile.TemporaryDirectory() as tdir:
77+
if os.name == "nt":
78+
fake_git = Path(tdir, "fake-git.cmd")
79+
script = f"@echo {fake_output}\n"
80+
fake_git.write_text(script, encoding="utf-8")
81+
else:
82+
fake_git = Path(tdir, "fake-git")
83+
script = f"#!/bin/sh\necho '{fake_output}'\n"
84+
fake_git.write_text(script, encoding="utf-8")
85+
fake_git.chmod(0o755)
86+
87+
yield str(fake_git.absolute())
88+
89+
90+
def _rename_with_stem(path, new_stem):
91+
if sys.version_info >= (3, 9):
92+
path.rename(path.with_stem(new_stem))
93+
else:
94+
path.rename(path.with_name(new_stem + path.suffix))
95+
96+
7097
@ddt.ddt
7198
class TestGit(TestBase):
7299
@classmethod
@@ -260,13 +287,9 @@ def test_it_ignores_false_kwargs(self, git):
260287
self.assertTrue("pass_this_kwarg" not in git.call_args[1])
261288

262289
def test_it_raises_proper_exception_with_output_stream(self):
263-
tmp_file = TemporaryFile()
264-
self.assertRaises(
265-
GitCommandError,
266-
self.git.checkout,
267-
"non-existent-branch",
268-
output_stream=tmp_file,
269-
)
290+
with tempfile.TemporaryFile() as tmp_file:
291+
with self.assertRaises(GitCommandError):
292+
self.git.checkout("non-existent-branch", output_stream=tmp_file)
270293

271294
def test_it_accepts_environment_variables(self):
272295
filename = fixture_path("ls_tree_empty")
@@ -314,12 +337,38 @@ def test_persistent_cat_file_command(self):
314337
self.assertEqual(typename, typename_two)
315338
self.assertEqual(size, size_two)
316339

317-
def test_version(self):
340+
def test_version_info(self):
341+
"""The version_info attribute is a tuple of up to four ints."""
318342
v = self.git.version_info
319343
self.assertIsInstance(v, tuple)
344+
self.assertLessEqual(len(v), 4)
320345
for n in v:
321346
self.assertIsInstance(n, int)
322-
# END verify number types
347+
348+
def test_version_info_pickleable(self):
349+
"""The version_info attribute is usable on unpickled Git instances."""
350+
deserialized = pickle.loads(pickle.dumps(self.git))
351+
v = deserialized.version_info
352+
self.assertIsInstance(v, tuple)
353+
self.assertLessEqual(len(v), 4)
354+
for n in v:
355+
self.assertIsInstance(n, int)
356+
357+
@ddt.data(
358+
(("123", "456", "789"), (123, 456, 789)),
359+
(("12", "34", "56", "78"), (12, 34, 56, 78)),
360+
(("12", "34", "56", "78", "90"), (12, 34, 56, 78)),
361+
(("1", "2", "a", "3"), (1, 2)),
362+
(("1", "-2", "3"), (1,)),
363+
(("1", "2a", "3"), (1,)), # Subject to change.
364+
)
365+
def test_version_info_is_leading_numbers(self, case):
366+
fake_fields, expected_version_info = case
367+
with _rollback_refresh():
368+
with _fake_git(*fake_fields) as path:
369+
refresh(path)
370+
new_git = Git()
371+
self.assertEqual(new_git.version_info, expected_version_info)
323372

324373
def test_git_exc_name_is_git(self):
325374
self.assertEqual(self.git.git_exec_name, "git")
@@ -487,6 +536,150 @@ def test_refresh_with_good_relative_git_path_arg(self):
487536
refresh(basename)
488537
self.assertEqual(self.git.GIT_PYTHON_GIT_EXECUTABLE, absolute_path)
489538

539+
def test_version_info_is_cached(self):
540+
fake_version_info = (123, 456, 789)
541+
with _rollback_refresh():
542+
with _fake_git(*fake_version_info) as path:
543+
new_git = Git() # Not cached yet.
544+
refresh(path)
545+
self.assertEqual(new_git.version_info, fake_version_info)
546+
os.remove(path) # Arrange that a second subprocess call would fail.
547+
self.assertEqual(new_git.version_info, fake_version_info)
548+
549+
def test_version_info_cache_is_per_instance(self):
550+
with _rollback_refresh():
551+
with _fake_git(123, 456, 789) as path:
552+
git1 = Git()
553+
git2 = Git()
554+
refresh(path)
555+
git1.version_info
556+
os.remove(path) # Arrange that the second subprocess call will fail.
557+
with self.assertRaises(GitCommandNotFound):
558+
git2.version_info
559+
git1.version_info
560+
561+
def test_version_info_cache_is_not_pickled(self):
562+
with _rollback_refresh():
563+
with _fake_git(123, 456, 789) as path:
564+
git1 = Git()
565+
refresh(path)
566+
git1.version_info
567+
git2 = pickle.loads(pickle.dumps(git1))
568+
os.remove(path) # Arrange that the second subprocess call will fail.
569+
with self.assertRaises(GitCommandNotFound):
570+
git2.version_info
571+
git1.version_info
572+
573+
def test_successful_refresh_with_arg_invalidates_cached_version_info(self):
574+
with _rollback_refresh():
575+
with _fake_git(11, 111, 1) as path1:
576+
with _fake_git(22, 222, 2) as path2:
577+
new_git = Git()
578+
refresh(path1)
579+
new_git.version_info
580+
refresh(path2)
581+
self.assertEqual(new_git.version_info, (22, 222, 2))
582+
583+
def test_failed_refresh_with_arg_does_not_invalidate_cached_version_info(self):
584+
with _rollback_refresh():
585+
with _fake_git(11, 111, 1) as path1:
586+
with _fake_git(22, 222, 2) as path2:
587+
new_git = Git()
588+
refresh(path1)
589+
new_git.version_info
590+
os.remove(path1) # Arrange that a repeat call for path1 would fail.
591+
os.remove(path2) # Arrange that the new call for path2 will fail.
592+
with self.assertRaises(GitCommandNotFound):
593+
refresh(path2)
594+
self.assertEqual(new_git.version_info, (11, 111, 1))
595+
596+
def test_successful_refresh_with_same_arg_invalidates_cached_version_info(self):
597+
"""Changing git at the same path and refreshing affects version_info."""
598+
with _rollback_refresh():
599+
with _fake_git(11, 111, 1) as path1:
600+
with _fake_git(22, 222, 2) as path2:
601+
new_git = Git()
602+
refresh(path1)
603+
new_git.version_info
604+
shutil.copy(path2, path1)
605+
refresh(path1) # The fake git at path1 has a different version now.
606+
self.assertEqual(new_git.version_info, (22, 222, 2))
607+
608+
def test_successful_refresh_with_env_invalidates_cached_version_info(self):
609+
with contextlib.ExitStack() as stack:
610+
stack.enter_context(_rollback_refresh())
611+
path1 = stack.enter_context(_fake_git(11, 111, 1))
612+
path2 = stack.enter_context(_fake_git(22, 222, 2))
613+
with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path1}):
614+
new_git = Git()
615+
refresh()
616+
new_git.version_info
617+
with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path2}):
618+
refresh()
619+
self.assertEqual(new_git.version_info, (22, 222, 2))
620+
621+
def test_failed_refresh_with_env_does_not_invalidate_cached_version_info(self):
622+
with contextlib.ExitStack() as stack:
623+
stack.enter_context(_rollback_refresh())
624+
path1 = stack.enter_context(_fake_git(11, 111, 1))
625+
path2 = stack.enter_context(_fake_git(22, 222, 2))
626+
with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path1}):
627+
new_git = Git()
628+
refresh()
629+
new_git.version_info
630+
os.remove(path1) # Arrange that a repeat call for path1 would fail.
631+
os.remove(path2) # Arrange that the new call for path2 will fail.
632+
with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path2}):
633+
with self.assertRaises(GitCommandNotFound):
634+
refresh(path2)
635+
self.assertEqual(new_git.version_info, (11, 111, 1))
636+
637+
def test_successful_refresh_with_same_env_invalidates_cached_version_info(self):
638+
"""Changing git at the same path/command and refreshing affects version_info."""
639+
with contextlib.ExitStack() as stack:
640+
stack.enter_context(_rollback_refresh())
641+
path1 = stack.enter_context(_fake_git(11, 111, 1))
642+
path2 = stack.enter_context(_fake_git(22, 222, 2))
643+
with mock.patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": path1}):
644+
new_git = Git()
645+
refresh()
646+
new_git.version_info
647+
shutil.copy(path2, path1)
648+
refresh() # The fake git at path1 has a different version now.
649+
self.assertEqual(new_git.version_info, (22, 222, 2))
650+
651+
def test_successful_default_refresh_invalidates_cached_version_info(self):
652+
"""Refreshing updates version after a filesystem change adds a git command."""
653+
# The key assertion here is the last. The others mainly verify the test itself.
654+
with contextlib.ExitStack() as stack:
655+
stack.enter_context(_rollback_refresh())
656+
657+
path1 = Path(stack.enter_context(_fake_git(11, 111, 1)))
658+
path2 = Path(stack.enter_context(_fake_git(22, 222, 2)))
659+
660+
new_path_var = f"{path1.parent}{os.pathsep}{path2.parent}"
661+
stack.enter_context(mock.patch.dict(os.environ, {"PATH": new_path_var}))
662+
stack.enter_context(_patch_out_env("GIT_PYTHON_GIT_EXECUTABLE"))
663+
664+
if os.name == "nt":
665+
# On Windows, use a shell so "git" finds "git.cmd". (In the infrequent
666+
# case that this effect is desired in production code, it should not be
667+
# done with this technique. USE_SHELL=True is less secure and reliable,
668+
# as unintended shell expansions can occur, and is deprecated. Instead,
669+
# use a custom command, by setting the GIT_PYTHON_GIT_EXECUTABLE
670+
# environment variable to git.cmd or by passing git.cmd's full path to
671+
# git.refresh. Or wrap the script with a .exe shim.
672+
stack.enter_context(mock.patch.object(Git, "USE_SHELL", True))
673+
674+
new_git = Git()
675+
_rename_with_stem(path2, "git") # "Install" git, "late" in the PATH.
676+
refresh()
677+
self.assertEqual(new_git.version_info, (22, 222, 2), 'before "downgrade"')
678+
_rename_with_stem(path1, "git") # "Install" another, higher priority.
679+
self.assertEqual(new_git.version_info, (22, 222, 2), "stale version")
680+
refresh()
681+
self.assertEqual(new_git.version_info, (11, 111, 1), "fresh version")
682+
490683
def test_options_are_passed_to_git(self):
491684
# This works because any command after git --version is ignored.
492685
git_version = self.git(version=True).NoOp()

Diff for: ‎test/test_index.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ def test_index_file_diffing(self, rw_repo):
528528
index.checkout(test_file)
529529
except CheckoutError as e:
530530
# Detailed exceptions are only possible in older git versions.
531-
if rw_repo.git._version_info < (2, 29):
531+
if rw_repo.git.version_info < (2, 29):
532532
self.assertEqual(len(e.failed_files), 1)
533533
self.assertEqual(e.failed_files[0], osp.basename(test_file))
534534
self.assertEqual(len(e.failed_files), len(e.failed_reasons))

0 commit comments

Comments
 (0)
Please sign in to comment.