From a02682b5655c18d4155014050906ed6b033d6dbc Mon Sep 17 00:00:00 2001 From: Cyrus Date: Mon, 27 Jul 2026 22:43:47 +0800 Subject: [PATCH 1/3] test: add a shared symlink capability guard Whether a symlink can be created isn't decided by the platform alone: on Windows it needs Developer Mode or SeCreateSymbolicLinkPrivilege. --- test/lib/helper.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/lib/helper.py b/test/lib/helper.py index deddced2b..58923eaef 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -19,6 +19,8 @@ "GIT_REPO", "GIT_DAEMON_PORT", "xfail_if_raises", + "symlinks_supported", + "requires_symlinks", ] import contextlib @@ -29,6 +31,7 @@ import logging import os import os.path as osp +from stat import S_ISLNK, ST_MODE import subprocess import sys import tempfile @@ -491,6 +494,28 @@ def _executable(self, basename): raise RuntimeError(f"no regular file or symlink {path!r}") +def symlinks_supported() -> bool: + """Check whether this process can actually create a symlink. + + On Windows the platform alone doesn't decide it: creating a symlink needs either + Developer Mode or SeCreateSymbolicLinkPrivilege, and an unprivileged process gets + OSError (WinError 1314) instead. + """ + with tempfile.TemporaryDirectory(prefix="gitpython-symlink-check-") as temp_dir: + link_path = osp.join(temp_dir, "link") + try: + os.symlink("missing-target", link_path) + except (NotImplementedError, OSError): + return False + return S_ISLNK(os.lstat(link_path)[ST_MODE]) + + +requires_symlinks = pytest.mark.skipif( + not symlinks_supported(), + reason="symlinks are unavailable, or need privileges this process doesn't have", +) + + @contextlib.contextmanager def xfail_if_raises( condition: bool, From 9ef6b2331d2afb3ad78542ecbd17aead36212ad1 Mon Sep 17 00:00:00 2001 From: Cyrus Date: Mon, 27 Jul 2026 22:43:47 +0800 Subject: [PATCH 2/3] test: skip tests that need symlink privileges These called os.symlink unguarded, so on a Windows account without the privilege they errored with WinError 1314 instead of being skipped. --- test/test_installation.py | 3 ++- test/test_repo.py | 3 ++- test/test_util.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/test_installation.py b/test/test_installation.py index 656ef9787..e8956d5cf 100644 --- a/test/test_installation.py +++ b/test/test_installation.py @@ -6,10 +6,11 @@ import os import subprocess -from test.lib import TestBase, VirtualEnvironment, with_rw_directory +from test.lib import TestBase, VirtualEnvironment, requires_symlinks, with_rw_directory class TestInstallation(TestBase): + @requires_symlinks @with_rw_directory def test_installation(self, rw_dir): venv, run = self._set_up_venv(rw_dir) diff --git a/test/test_repo.py b/test/test_repo.py index 7c7f1dd34..4cf120db7 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -43,7 +43,7 @@ from git.repo.fun import touch from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree -from test.lib import TestBase, fixture, with_rw_directory, with_rw_repo, PathLikeMock +from test.lib import TestBase, fixture, requires_symlinks, with_rw_directory, with_rw_repo, PathLikeMock def iter_flatten(lol): @@ -1351,6 +1351,7 @@ def test_ignored_items_reported(self): ["included_file.txt", "ignored_file.txt", "included_dir/file.txt", "ignored_dir/file.txt"] ) == ["ignored_file.txt", "ignored_dir/file.txt"] + @requires_symlinks def test_ignored_raises_error_w_symlink(self): with tempfile.TemporaryDirectory() as tdir: tmp_dir = pathlib.Path(tdir) diff --git a/test/test_util.py b/test/test_util.py index 4bd1eebca..ce8a6c7f3 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -40,7 +40,7 @@ rmtree, ) -from test.lib import TestBase, with_rw_repo +from test.lib import TestBase, requires_symlinks, with_rw_repo @pytest.fixture @@ -113,6 +113,7 @@ def test_deletes_dir_with_readonly_files(self, tmp_path): sys.platform == "cygwin", reason="Cygwin can't set the permissions that make the test meaningful.", ) + @requires_symlinks def test_avoids_changing_permissions_outside_tree(self, tmp_path, request): # Automatically works on Windows, but on Unix requires either special handling # or refraining from attempting to fix PermissionError by making chmod calls. From 57a2232702d2345f52cf9b576f650b115b6c9c62 Mon Sep 17 00:00:00 2001 From: Cyrus Date: Mon, 27 Jul 2026 22:43:47 +0800 Subject: [PATCH 3/3] test: use the shared guard instead of local copies test_index had a private probe and test_refs repeated the same check inline. --- test/test_index.py | 17 ++--------------- test/test_refs.py | 8 +++----- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/test/test_index.py b/test/test_index.py index 3ad5a457f..cc496c64c 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -40,7 +40,7 @@ from git.util import Actor, cwd, hex_to_bin, rmtree from test.lib import TestBase, VirtualEnvironment, fixture, fixture_path, with_rw_directory, with_rw_repo, PathLikeMock -from test.lib.helper import xfail_if_raises +from test.lib.helper import symlinks_supported, xfail_if_raises HOOKS_SHEBANG = "#!/usr/bin/env sh\n" @@ -175,19 +175,6 @@ def _decode(stdout): _win_bash_status = WinBashStatus.check() -def _windows_supports_symlinks(): - if sys.platform != "win32": - return False - - with tempfile.TemporaryDirectory(prefix="gitpython-symlink-check-") as temp_dir: - link_path = osp.join(temp_dir, "link") - try: - os.symlink("missing-target", link_path) - except (NotImplementedError, OSError): - return False - return S_ISLNK(os.lstat(link_path)[ST_MODE]) - - def _make_hook(git_dir, name, content, make_exec=True): """A helper to create a hook""" hp = hook_path(name, git_dir) @@ -613,7 +600,7 @@ def _count_existing(self, repo, files): @with_rw_repo("0.1.6") def test_index_mutation(self, rw_repo): with xfail_if_raises( - sys.platform == "win32" and (Git().config("core.symlinks") == "true" or _windows_supports_symlinks()), + sys.platform == "win32" and (Git().config("core.symlinks") == "true" or symlinks_supported()), raises=(FileNotFoundError, GitCommandError), reason="Assumes symlinks are not created on Windows and opens a symlink to a nonexistent target.", ): diff --git a/test/test_refs.py b/test/test_refs.py index 6481b54a8..6a38a9f71 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -28,7 +28,7 @@ import git.refs as refs from git.util import Actor -from test.lib import TestBase, with_rw_repo, PathLikeMock +from test.lib import TestBase, requires_symlinks, with_rw_repo, PathLikeMock class TestRefs(TestBase): @@ -735,6 +735,7 @@ def test_symbolic_reference_log_append_rejects_path_traversal(self): ) assert not outside_path.exists() + @requires_symlinks def test_symbolic_reference_set_reference_rejects_symlink_escape(self): with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) @@ -746,10 +747,7 @@ def test_symbolic_reference_set_reference_rejects_symlink_escape(self): refs_heads_dir = Path(repo.common_dir) / "refs" / "heads" refs_heads_dir.mkdir(parents=True, exist_ok=True) symlink_path = refs_heads_dir / "link_out" - try: - symlink_path.symlink_to(outside_dir, target_is_directory=True) - except (OSError, NotImplementedError) as ex: - self.skipTest("symlinks unavailable on this platform: %s" % ex) + symlink_path.symlink_to(outside_dir, target_is_directory=True) if osp.realpath(symlink_path / "escaped") == osp.abspath(symlink_path / "escaped"): self.skipTest("realpath does not resolve directory symlinks on this platform")