From 961b6b0be56cca745d3e01f68a3b7a4f2711594c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 11 Sep 2026 16:18:46 +0200 Subject: [PATCH 1/2] bugfix: Also skip gitcache executable named git Previously only symlinks have been skipped reliably. When gitcache was named git directly, it found itself resulting in infinite recursion. Now the logic to find the real git executable always skips itself. --- CHANGELOG.md | 4 ++ src/git_cache/config.py | 9 ++-- test/unittests/test_git_cache_config.py | 57 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3aff2..b9c974e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Bugfix: Fetch the LFS objects of a `git checkout` of a tag or a commit as well. Previously, only a checkout of a branch triggered an LFS fetch of the mirror and the LFS objects of a tag or commit were downloaded by the git-lfs filters of the checkout itself. +- Bugfix: Skip the gitcache executable itself when searching for the real git command. Previously, only a symlink + named `git` was skipped, so that renaming or hard-linking the gitcache executable to `git` resulted in an + infinite recursion. As the check now compares the candidates with the gitcache executable, a symlink pointing to + the real git command is no longer skipped either. ## v1.0.34 diff --git a/src/git_cache/config.py b/src/git_cache/config.py index ebc9d4a..9596221 100644 --- a/src/git_cache/config.py +++ b/src/git_cache/config.py @@ -20,6 +20,7 @@ import os import platform import re +import sys from typing import Any, Callable, Dict, List, Optional import pytimeparse @@ -92,9 +93,11 @@ def find_git() -> str: cmd = "git.exe" if on_windows else "git" for candidate in path.split(os.path.pathsep): candidate = os.path.join(candidate, cmd) - if os.path.exists(candidate) and os.access(candidate, os.X_OK): - if not os.path.islink(candidate): - return candidate + if not os.path.exists(candidate) or not os.access(candidate, os.X_OK): + continue + if os.path.samefile(candidate, sys.executable): + continue + return candidate LOG.warning("Can't find git command! Please specify manually in the config file!") return "/usr/bin/git" diff --git a/test/unittests/test_git_cache_config.py b/test/unittests/test_git_cache_config.py index c096a54..4672121 100644 --- a/test/unittests/test_git_cache_config.py +++ b/test/unittests/test_git_cache_config.py @@ -13,6 +13,9 @@ # ----------------------------------------------------------------------------- import importlib import os +import shutil +import sys +import tempfile from unittest import TestCase import mock @@ -157,6 +160,60 @@ def test_str_rep(self): self.assertEqual(str(config), expected_config_str) +class GitCacheFindGitTest(TestCase): + """Test the :func:`git_cache.config.find_git` function.""" + + def setUp(self): + """Set up the test case with a gitcache executable and a real git command.""" + self.tmp_dir = tempfile.mkdtemp() + self.gitcache_cmd = self._create_executable(self.tmp_dir, "gitcache") + + self.real_git_dir = os.path.join(self.tmp_dir, "real") + os.makedirs(self.real_git_dir) + self.real_git_cmd = self._create_executable(self.real_git_dir, "git") + + self.gitcache_dir = os.path.join(self.tmp_dir, "gitcache_bin") + os.makedirs(self.gitcache_dir) + + def tearDown(self): + """Tear down the test case.""" + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + @staticmethod + def _create_executable(directory, name): + """Create an executable file and return its path.""" + path = os.path.join(directory, name) + with open(path, "w", encoding="utf-8") as file_handle: + file_handle.write("#!/bin/sh\nexit 0\n") + os.chmod(path, 0o755) + return path + + def _find_git(self, executable): + """Call find_git() as the given executable with the gitcache dir in front of the real git dir.""" + path = os.path.pathsep.join([self.gitcache_dir, self.real_git_dir]) + with mock.patch.object(sys, "executable", executable), mockenv(PATH=path): + return git_cache.config.find_git() + + def test_gitcache_executable(self): + """git_cache.config.find_git: Find the real git command.""" + self.assertEqual(self._find_git(self.gitcache_cmd), self.real_git_cmd) + + def test_skip_git_symlink(self): + """git_cache.config.find_git: Skip a symlink to the gitcache executable.""" + os.symlink(self.gitcache_cmd, os.path.join(self.gitcache_dir, "git")) + self.assertEqual(self._find_git(self.gitcache_cmd), self.real_git_cmd) + + def test_skip_git_hardlink(self): + """git_cache.config.find_git: Skip a hard link to the gitcache executable.""" + os.link(self.gitcache_cmd, os.path.join(self.gitcache_dir, "git")) + self.assertEqual(self._find_git(self.gitcache_cmd), self.real_git_cmd) + + def test_skip_git_executable(self): + """git_cache.config.find_git: Skip the gitcache executable itself named git.""" + gitcache_as_git = self._create_executable(self.gitcache_dir, "git") + self.assertEqual(self._find_git(gitcache_as_git), self.real_git_cmd) + + # ----------------------------------------------------------------------------- # EOF # ----------------------------------------------------------------------------- From a3693cb20940a88f502300969b60af403dd82ff6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 11 Sep 2026 17:09:59 +0200 Subject: [PATCH 2/2] Fix tests for Windows --- test/unittests/test_git_cache_config.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/unittests/test_git_cache_config.py b/test/unittests/test_git_cache_config.py index 4672121..3f30783 100644 --- a/test/unittests/test_git_cache_config.py +++ b/test/unittests/test_git_cache_config.py @@ -13,6 +13,7 @@ # ----------------------------------------------------------------------------- import importlib import os +import platform import shutil import sys import tempfile @@ -163,14 +164,18 @@ def test_str_rep(self): class GitCacheFindGitTest(TestCase): """Test the :func:`git_cache.config.find_git` function.""" + ON_WINDOWS = platform.system().lower().startswith("win") + GIT_CMD = "git.exe" if ON_WINDOWS else "git" + GITCACHE_CMD = "gitcache.exe" if ON_WINDOWS else "gitcache" + def setUp(self): """Set up the test case with a gitcache executable and a real git command.""" self.tmp_dir = tempfile.mkdtemp() - self.gitcache_cmd = self._create_executable(self.tmp_dir, "gitcache") + self.gitcache_cmd = self._create_executable(self.tmp_dir, self.GITCACHE_CMD) self.real_git_dir = os.path.join(self.tmp_dir, "real") os.makedirs(self.real_git_dir) - self.real_git_cmd = self._create_executable(self.real_git_dir, "git") + self.real_git_cmd = self._create_executable(self.real_git_dir, self.GIT_CMD) self.gitcache_dir = os.path.join(self.tmp_dir, "gitcache_bin") os.makedirs(self.gitcache_dir) @@ -200,17 +205,20 @@ def test_gitcache_executable(self): def test_skip_git_symlink(self): """git_cache.config.find_git: Skip a symlink to the gitcache executable.""" - os.symlink(self.gitcache_cmd, os.path.join(self.gitcache_dir, "git")) + try: + os.symlink(self.gitcache_cmd, os.path.join(self.gitcache_dir, self.GIT_CMD)) + except OSError: + self.skipTest("This system does not allow the creation of symlinks.") self.assertEqual(self._find_git(self.gitcache_cmd), self.real_git_cmd) def test_skip_git_hardlink(self): """git_cache.config.find_git: Skip a hard link to the gitcache executable.""" - os.link(self.gitcache_cmd, os.path.join(self.gitcache_dir, "git")) + os.link(self.gitcache_cmd, os.path.join(self.gitcache_dir, self.GIT_CMD)) self.assertEqual(self._find_git(self.gitcache_cmd), self.real_git_cmd) def test_skip_git_executable(self): """git_cache.config.find_git: Skip the gitcache executable itself named git.""" - gitcache_as_git = self._create_executable(self.gitcache_dir, "git") + gitcache_as_git = self._create_executable(self.gitcache_dir, self.GIT_CMD) self.assertEqual(self._find_git(gitcache_as_git), self.real_git_cmd)