From f824fde4058b5b9366f00c4511ac86b586f37082 Mon Sep 17 00:00:00 2001 From: "GPT 5.6" Date: Sun, 26 Jul 2026 09:52:28 +0200 Subject: [PATCH 01/22] test: allow Python startup before timeout The macOS Python 3.8 Actions job intermittently killed the helper process before it could start and flush its expected output. The 100 ms deadline tested interpreter startup speed rather than GitPython timeout behavior. Keep the timeout regression coverage while allowing one second for process startup. Validation: five focused runs with Python 3.8; Ruff check and format. Co-authored-by: Sebastian Thiel --- test/test_git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_git.py b/test/test_git.py index 7256e9bb5..96df3c8f0 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -302,7 +302,7 @@ def test_it_honors_kill_after_timeout_with_output_stream(self): status, _, stderr = self.git.execute( command, output_stream=output_stream, - kill_after_timeout=0.1, + kill_after_timeout=1, with_exceptions=False, with_extended_output=True, ) From 7131bcc27f2d96851eb6d667dc1207ab6ece5c61 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 30 Jul 2026 10:45:23 +0000 Subject: [PATCH 02/22] Resolve Windows hook Bash through PATH (#2198) Extensionless commit hooks on Windows were launched through a bare bash.exe. Windows CreateProcess searches system directories before PATH for bare executable names, so the WSL launcher in System32 could preempt Git for Windows Bash and make every commit fail when no WSL distribution was installed. A regression test mocks PATH resolution and demonstrates that the hook command previously remained bare instead of using the resolved executable. Resolve bash.exe with shutil.which before spawning, while retaining the bare-name fallback when PATH has no Bash so existing WSL-based behavior remains available. This matches Git for Windows v2.55.0.windows.3, whose git var GIT_SHELL_PATH reports the absolute PATH-selected sh.exe. It also follows gix-command's principle of resolving a Git-associated Windows shell before falling back to a bare executable name. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/index/fun.py | 21 ++++++++++++++++++++- test/test_index.py | 8 ++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index 5d52486f9..7bd2e4bf6 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -79,6 +79,21 @@ def _has_file_extension(path: str) -> str: return osp.splitext(path)[1] +def _which_from_path(command: str) -> Union[str, None]: + """Resolve an executable 'command' from PATH without considering the current directory.""" + cwd = osp.normcase(osp.abspath(os.curdir)) + for directory in os.get_exec_path(): + if not directory: + continue + directory = osp.abspath(directory) + if osp.normcase(directory) == cwd: + continue + candidate = osp.join(directory, command) + if osp.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: """Run the commit hook of the given name. Silently ignore hooks that do not exist. @@ -112,7 +127,11 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: # an absolute path in this form, although a relative path is preferable # because it also works with the Windows Subsystem for Linux wrapper. bash_hp = hp - cmd = ["bash.exe", Path(bash_hp).as_posix()] + # Resolve through PATH before spawning. On Windows, CreateProcess searches + # the current and system directories before PATH for a bare executable name, + # which can otherwise select an impostor or the WSL launcher instead of Git + # for Windows' Bash. + cmd = [_which_from_path("bash.exe") or "bash.exe", Path(bash_hp).as_posix()] process = safer_popen( cmd + list(args), diff --git a/test/test_index.py b/test/test_index.py index 3ad5a457f..d902bf58d 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1128,16 +1128,20 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir): repo = Repo.init(root / "repo") hooks_dir = root / "hooks" _make_hook(root, "fake-hook", "exit 0") + bash = root / "git" / "bin" / "bash.exe" with repo.config_writer() as writer: writer.set_value("core", "hooksPath", str(hooks_dir)) - with mock.patch("git.index.fun.sys.platform", "win32"): + with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch( + "git.index.fun._which_from_path", return_value=str(bash) + ) as which: with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"): popen.return_value.returncode = 0 run_commit_hook("fake-hook", repo.index) + which.assert_called_once_with("bash.exe") command = popen.call_args[0][0] - self.assertEqual(command, ["bash.exe", "../hooks/fake-hook"]) + self.assertEqual(command, [str(bash), "../hooks/fake-hook"]) @ddt.data((False,), (True,)) @with_rw_directory From fbde2da1f45ed5eb058ebe255544b32d6949bc6c Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 30 Jul 2026 18:15:48 +0200 Subject: [PATCH 03/22] Revert "Resolve Windows hook Bash through PATH" --- git/index/fun.py | 21 +-------------------- test/test_index.py | 8 ++------ 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index 7bd2e4bf6..5d52486f9 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -79,21 +79,6 @@ def _has_file_extension(path: str) -> str: return osp.splitext(path)[1] -def _which_from_path(command: str) -> Union[str, None]: - """Resolve an executable 'command' from PATH without considering the current directory.""" - cwd = osp.normcase(osp.abspath(os.curdir)) - for directory in os.get_exec_path(): - if not directory: - continue - directory = osp.abspath(directory) - if osp.normcase(directory) == cwd: - continue - candidate = osp.join(directory, command) - if osp.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate - return None - - def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: """Run the commit hook of the given name. Silently ignore hooks that do not exist. @@ -127,11 +112,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: # an absolute path in this form, although a relative path is preferable # because it also works with the Windows Subsystem for Linux wrapper. bash_hp = hp - # Resolve through PATH before spawning. On Windows, CreateProcess searches - # the current and system directories before PATH for a bare executable name, - # which can otherwise select an impostor or the WSL launcher instead of Git - # for Windows' Bash. - cmd = [_which_from_path("bash.exe") or "bash.exe", Path(bash_hp).as_posix()] + cmd = ["bash.exe", Path(bash_hp).as_posix()] process = safer_popen( cmd + list(args), diff --git a/test/test_index.py b/test/test_index.py index d902bf58d..3ad5a457f 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1128,20 +1128,16 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir): repo = Repo.init(root / "repo") hooks_dir = root / "hooks" _make_hook(root, "fake-hook", "exit 0") - bash = root / "git" / "bin" / "bash.exe" with repo.config_writer() as writer: writer.set_value("core", "hooksPath", str(hooks_dir)) - with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch( - "git.index.fun._which_from_path", return_value=str(bash) - ) as which: + with mock.patch("git.index.fun.sys.platform", "win32"): with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"): popen.return_value.returncode = 0 run_commit_hook("fake-hook", repo.index) - which.assert_called_once_with("bash.exe") command = popen.call_args[0][0] - self.assertEqual(command, [str(bash), "../hooks/fake-hook"]) + self.assertEqual(command, ["bash.exe", "../hooks/fake-hook"]) @ddt.data((False,), (True,)) @with_rw_directory From 96cbede92eb8ba0661774874766c2e9a382cb1b8 Mon Sep 17 00:00:00 2001 From: raisulchowdhury <34920788+raisulchowdhury@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:09:17 -0700 Subject: [PATCH 04/22] test: focus pytest summary on unexpected failures Use pytest's failure and error summary modes so expected-failure details do not obscure unexpected failures while retaining aggregate counts. Assisted-by: OpenAI Codex Signed-off-by: raisulchowdhury <34920788+raisulchowdhury@users.noreply.github.com> --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 117f044d6..2a073a24b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] -addopts = "--cov=git --cov-report=term -ra" +addopts = "--cov=git --cov-report=term -rfE" filterwarnings = "ignore::DeprecationWarning" python_files = "test_*.py" tmp_path_retention_policy = "failed" @@ -13,7 +13,7 @@ testpaths = "test" # Space separated list of paths from root e.g test tests doc # --cov-report term-missing # to terminal with line numbers # --cov-report html:path # html file at path # --maxfail # number of errors before giving up -# -rfE # default test summary: list fail and error +# -rfE # test summary: list failures and errors, omitting expected failures # -ra # test summary: list all non-passing (fail, error, skip, xfail, xpass) # --ignore-glob=**/gitdb/* # ignore glob paths # filterwarnings ignore::WarningType # ignores those warnings From 1d246f022fa2b67c1784925a1fd9d1a898b0f9ce Mon Sep 17 00:00:00 2001 From: raisulchowdhury <34920788+raisulchowdhury@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:42:39 -0700 Subject: [PATCH 05/22] fix: keep unexpected passes in pytest summary Include XPASS results while still omitting expected-failure detail, and document the exact report flags.\n\nAssisted-by: OpenAI Codex Signed-off-by: raisulchowdhury <34920788+raisulchowdhury@users.noreply.github.com> --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2a073a24b..b7c437bf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] -addopts = "--cov=git --cov-report=term -rfE" +addopts = "--cov=git --cov-report=term -rfEX" filterwarnings = "ignore::DeprecationWarning" python_files = "test_*.py" tmp_path_retention_policy = "failed" @@ -13,7 +13,7 @@ testpaths = "test" # Space separated list of paths from root e.g test tests doc # --cov-report term-missing # to terminal with line numbers # --cov-report html:path # html file at path # --maxfail # number of errors before giving up -# -rfE # test summary: list failures and errors, omitting expected failures +# -rfEX # test summary: list failures, errors, and unexpected passes; omit expected failures # -ra # test summary: list all non-passing (fail, error, skip, xfail, xpass) # --ignore-glob=**/gitdb/* # ignore glob paths # filterwarnings ignore::WarningType # ignores those warnings From e4b8e7d026ca6abb4cf604f8e77093432ce23c06 Mon Sep 17 00:00:00 2001 From: Byron Date: Sat, 1 Aug 2026 04:47:40 +0200 Subject: [PATCH 06/22] Validate submodule names before filesystem operations Submodule names read from .gitmodules can influence the separate Git directory path. Reject empty names, absolute or drive-qualified names, and parent components with either path separator. Validate before constructing module paths and before opening or mutating existing checkouts, so a repository initialized by an older vulnerable version cannot bypass the guard. Validate programmatic add and rename inputs before making changes as well. Advisory: GHSA-hmq2-w58f-27jc Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/objects/submodule/base.py | 23 +++++++++++- test/test_submodule.py | 69 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 97fc9e111..658259cd2 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -6,6 +6,7 @@ import gc from io import BytesIO import logging +import ntpath import os import os.path as osp import stat @@ -302,10 +303,21 @@ def _config_parser_constrained(self, read_only: bool) -> SectionConstraint: parser.set_submodule(self) return SectionConstraint(parser, sm_section(self.name)) + @classmethod + def _validated_name(cls, name: str) -> str: + if ( + not name + or name.startswith(("/", "\\")) + or ntpath.splitdrive(name)[0] + or ".." in name.replace("\\", "/").split("/") + ): + raise ValueError("Invalid submodule name %r" % name) + return name + @classmethod def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> PathLike: if cls._need_gitfile_submodules(parent_repo.git): - return osp.join(parent_repo.git_dir, "modules", name) + return osp.join(parent_repo.git_dir, "modules", cls._validated_name(name)) if parent_repo.working_tree_dir: return osp.join(parent_repo.working_tree_dir, path) raise NotADirectoryError() @@ -523,6 +535,7 @@ def add( raise InvalidGitRepositoryError("Cannot add submodules to bare repositories") # END handle bare repos + cls._validated_name(name) path = cls._to_relative_path(repo, path) # Ensure we never put backslashes into the URL, as might happen on Windows. @@ -771,6 +784,8 @@ def fetch_remotes(module_repo: "Repo") -> None: # END fetch new data try: + self._validated_name(self.name) + # ENSURE REPO IS PRESENT AND UP-TO-DATE ####################################### try: @@ -1020,6 +1035,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool = raise ValueError("You must specify to move at least the module or the configuration of the submodule") # END handle input + self._validated_name(self.name) module_checkout_path = self._to_relative_path(self.repo, module_path) # VERIFY DESTINATION @@ -1160,6 +1176,7 @@ def remove( raise ValueError("Need to specify to delete at least the module, or the configuration") # END handle parameters + self._validated_name(self.name) # Recursively remove children of this submodule. nc = 0 for csm in self.children(): @@ -1416,6 +1433,9 @@ def rename(self, new_name: str) -> "Submodule": if self.name == new_name: return self + self._validated_name(self.name) + self._validated_name(new_name) + # .git/config with self.repo.config_writer() as pw: # As we ourselves didn't write anything about submodules into the parent @@ -1463,6 +1483,7 @@ def module(self) -> "Repo": If a repository was not available. This could also mean that it was not yet initialized. """ + self._validated_name(self.name) module_checkout_abspath = self.abspath try: repo = git.Repo(module_checkout_abspath) diff --git a/test/test_submodule.py b/test/test_submodule.py index 23e2c0e63..217ee7a99 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -925,6 +925,75 @@ def test_update_submodule_with_relative_path(self, rwdir): cloned_repo.submodule_update(init=True, recursive=True) + @with_rw_directory + @_patch_git_config("protocol.file.allow", "always") + def test_update_rejects_parent_component_in_name(self, rwdir): + source = git.Repo.init(osp.join(rwdir, "source")) + source.git.commit(m="initial commit", allow_empty=True) + + parent = git.Repo.init(osp.join(rwdir, "parent")) + parent.git.submodule("add", source.working_tree_dir, "module") + parent.index.commit("add submodule") + modules_file = Path(parent.working_tree_dir) / ".gitmodules" + modules_file.write_text( + modules_file.read_text().replace('submodule "module"', 'submodule "../../../escaped/module"') + ) + parent.index.add([".gitmodules"]) + parent.index.commit("change submodule name") + + clone = git.Repo.clone_from(parent.working_tree_dir, osp.join(rwdir, "clone")) + with pytest.raises(ValueError, match="submodule name"): + clone.submodules[0].update(init=True) + assert not osp.exists(osp.join(rwdir, "escaped")) + + Path(rwdir, "escaped").mkdir() + git.Repo.clone_from( + source.working_tree_dir, + osp.join(clone.working_tree_dir, "module"), + separate_git_dir=osp.join(rwdir, "escaped", "module"), + ) + with pytest.raises(ValueError, match="submodule name"): + clone.submodules[0].update(init=True) + + invalid_names = ( + "", + "..", + "../module", + R"..\module", + "nested/../module", + R"nested\..\module", + "/module", + R"\module", + "C:module", + R"C:\module", + ) + for name in invalid_names: + with pytest.raises(ValueError, match="submodule name"): + Submodule._module_abspath(clone, "module", name) + + @with_rw_directory + @_patch_git_config("protocol.file.allow", "always") + def test_root_update_keeps_going_after_invalid_submodule_name(self, rwdir): + source = git.Repo.init(osp.join(rwdir, "source")) + source.git.commit(m="initial commit", allow_empty=True) + + parent = git.Repo.init(osp.join(rwdir, "parent")) + parent.git.submodule("add", source.working_tree_dir, "invalid") + parent.git.submodule("add", source.working_tree_dir, "valid") + modules_file = Path(parent.working_tree_dir) / ".gitmodules" + modules_file.write_text(modules_file.read_text().replace('submodule "invalid"', 'submodule "../invalid"')) + parent.index.add([".gitmodules"]) + parent.index.commit("add submodules") + + clone = git.Repo.clone_from(parent.working_tree_dir, osp.join(rwdir, "clone")) + assert [sm.name for sm in clone.submodules] == ["../invalid", "valid"] + + clone.submodule_update(keep_going=True) + + assert os.listdir(osp.join(clone.working_tree_dir, "invalid")) == [] + assert not clone.submodule("../invalid").module_exists() + assert clone.submodule("valid").module_exists() + @with_rw_directory @_patch_git_config("protocol.file.allow", "always") def test_list_only_valid_submodules(self, rwdir): From b324c831cbc02c82b99c18113b622d03539d23cd Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 4 Aug 2026 11:07:02 +0200 Subject: [PATCH 07/22] Initialize submodule repository before handled failures Direct recursive updates with keep_going could swallow an invalid-name validation error and then access mrepo before assignment. Reproduce that path and initialize mrepo before validation so recursion is safely skipped. Git baseline: submodule.c at 883a47ef6496c96a5d6132ed8c87fcd44ebf8d1a validates submodule paths before operating on them. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- .basedpyright/baseline.json | 8 -------- git/objects/submodule/base.py | 4 +--- test/test_submodule.py | 2 ++ 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 013b097ae..59a5145c3 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -491,14 +491,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 15, - "endColumn": 20, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 658259cd2..9b1eb0e02 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -747,9 +747,7 @@ def update( prefix = "DRY-RUN: " # END handle prefix - # To keep things plausible in dry-run mode. - if dry_run: - mrepo = None + mrepo = None # END init mrepo def fetch_remotes(module_repo: "Repo") -> None: diff --git a/test/test_submodule.py b/test/test_submodule.py index 217ee7a99..32b1c7af1 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -994,6 +994,8 @@ def test_root_update_keeps_going_after_invalid_submodule_name(self, rwdir): assert not clone.submodule("../invalid").module_exists() assert clone.submodule("valid").module_exists() + clone.submodule("../invalid").update(recursive=True, keep_going=True) + @with_rw_directory @_patch_git_config("protocol.file.allow", "always") def test_list_only_valid_submodules(self, rwdir): From 4299c990e1ca21896f9485277caf7bb0ae5b404c Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 4 Aug 2026 10:08:41 +0200 Subject: [PATCH 08/22] Validate submodule names on every module path branch _module_abspath only validated names when Git used separate gitfile submodule directories, so legacy Git behavior could accept the same invalid name. Validate before selecting the Git-version-dependent path branch and cover the legacy branch explicitly. Git baseline: submodule.c at 883a47ef6496c96a5d6132ed8c87fcd44ebf8d1a validates submodule paths before filesystem operations. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/objects/submodule/base.py | 3 ++- test/test_submodule.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 9b1eb0e02..56fd16669 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -316,8 +316,9 @@ def _validated_name(cls, name: str) -> str: @classmethod def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> PathLike: + name = cls._validated_name(name) if cls._need_gitfile_submodules(parent_repo.git): - return osp.join(parent_repo.git_dir, "modules", cls._validated_name(name)) + return osp.join(parent_repo.git_dir, "modules", name) if parent_repo.working_tree_dir: return osp.join(parent_repo.working_tree_dir, path) raise NotADirectoryError() diff --git a/test/test_submodule.py b/test/test_submodule.py index 32b1c7af1..8c8a53641 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -971,6 +971,10 @@ def test_update_rejects_parent_component_in_name(self, rwdir): with pytest.raises(ValueError, match="submodule name"): Submodule._module_abspath(clone, "module", name) + with mock.patch.object(Submodule, "_need_gitfile_submodules", return_value=False): + with pytest.raises(ValueError, match="submodule name"): + Submodule._module_abspath(clone, "module", "../module") + @with_rw_directory @_patch_git_config("protocol.file.allow", "always") def test_root_update_keeps_going_after_invalid_submodule_name(self, rwdir): From da58f39c19e918f88573db7b19201841a7399f6a Mon Sep 17 00:00:00 2001 From: Byron Date: Mon, 27 Jul 2026 07:01:51 +0000 Subject: [PATCH 09/22] fix Windows path handling for Python 3.13+ (#1955) #(2072) This commit also makes the test-suite independent of the presence of a local `master` branch. - make index and submodule path normalization distinguish drive-rooted, drive-relative, UNC, and device paths on Windows - confine normalized paths with `commonpath` instead of string-prefix checks - normalize `includeIf gitdir` patterns to Git-style separators - cover the other `isabs` consumers whose drive-rooted behavior correctly relies on Windows join semantics - remove the Windows 3.13-3.15 CI exclusions and add 3.13-3.15 to tox - make the pre-existing tests independent of a local `master` branch and linked-worktree-specific alternates For context, the classifiers mentioned in the issue #2072 were introduced by 904aa71c5 (`Declare support for Python 3.13-3.14`) and 7d5370dab (`Add support for Python 3.15`), both included in 3.1.56. This PR supplies the Windows path work that those metadata changes did not include. - paths absolute on the current drive - drive-rooted paths such as `\directory` and `/directory` - drive-relative paths such as `C:directory` (rejected where repository confinement is required) - other-drive, UNC, and device paths - traversal and adjacent-prefix escape attempts - native and Git-style separators in conditional includes - config includes, submodule gitfiles, linked worktree metadata, clone destinations, and Cygwin-path conversion - Python 3.12.13, 3.13.14, 3.14.6, 3.14.6t, 3.15.0b4, and 3.15.0b4t installed locally with uv - full test set passed for every interpreter in an independent clone without a local `master` branch; daemon-free suites ran in parallel and `test_remote.py` ran serially because its static local daemon endpoint cannot be shared across processes - Ruff check and format - mypy - basedpyright - all pre-commit hooks Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- .basedpyright/baseline.json | 16 ------- .github/workflows/pythonpackage.yml | 10 ---- git/config.py | 11 +++-- git/index/base.py | 17 ++++--- git/objects/submodule/base.py | 22 +++------ git/refs/symbolic.py | 2 +- git/repo/base.py | 3 +- git/util.py | 59 +++++++++++++++++++++++ test/test_commit.py | 10 ++-- test/test_config.py | 32 ++++++++++--- test/test_docs.py | 49 ++++++++++--------- test/test_index.py | 39 +++++++++++++-- test/test_refs.py | 4 +- test/test_repo.py | 73 ++++++++++++++++++++++++----- test/test_submodule.py | 57 +++++++++++++++++++++- test/test_util.py | 4 ++ tox.ini | 2 +- 17 files changed, 296 insertions(+), 114 deletions(-) diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 013b097ae..8dd108355 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -895,22 +895,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 22, - "endColumn": 37, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 22, - "endColumn": 37, - "lineCount": 1 - } - }, { "code": "reportReturnType", "range": { diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 88099f15c..7dfa2e8f2 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -25,16 +25,6 @@ jobs: python-version: "3.14t" - os-type: macos python-version: "3.15t" - - os-type: windows - python-version: "3.13" # FIXME: Fix and enable Python 3.13-3.15 on Windows (#1955). - - os-type: windows - python-version: "3.14" - - os-type: windows - python-version: "3.14t" - - os-type: windows - python-version: "3.15" - - os-type: windows - python-version: "3.15t" include: - os-ver: latest - os-type: ubuntu diff --git a/git/config.py b/git/config.py index 821710ea3..fe80ea68d 100644 --- a/git/config.py +++ b/git/config.py @@ -576,9 +576,11 @@ def _all_items(section: str) -> List[Tuple[str, str]]: value = match.group(2).strip() if keyword in ["gitdir", "gitdir/i"]: - value = osp.expanduser(value) + value = osp.expanduser(value).replace("\\", "/") + git_dir = os.fspath(self._repo.git_dir).replace("\\", "/") if self._repo.git_dir else None - if not any(value.startswith(s) for s in ["./", "/"]): + drive, _tail = osp.splitdrive(value) + if not drive and not any(value.startswith(s) for s in ["./", "/"]): value = "**/" + value if value.endswith("/"): value += "**" @@ -590,9 +592,8 @@ def _all_items(section: str) -> List[Tuple[str, str]]: lambda m: f"[{m.group().lower()!r}{m.group().upper()!r}]", value, ) - if self._repo.git_dir: - if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value): - paths += _all_items(section) + if git_dir and fnmatch.fnmatchcase(git_dir, value): + paths += _all_items(section) elif keyword == "onbranch": try: diff --git a/git/index/base.py b/git/index/base.py index e5b1e72f8..248a7f10a 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -34,6 +34,8 @@ LockedFD, join_path_native, file_contents_ro, + _is_path_rooted, + _to_relative_path, to_native_path_linux, unbare_repo, to_bin_sha, @@ -58,6 +60,7 @@ Any, BinaryIO, Callable, + cast, Dict, Generator, IO, @@ -655,16 +658,12 @@ def _to_relative_path(self, path: PathLike) -> PathLike: :raise ValueError: """ - if not osp.isabs(path): - return path if self.repo.bare: - raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(path).startswith(str(self.repo.working_tree_dir)): - raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir)) - result = os.path.relpath(path, self.repo.working_tree_dir) - if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): - result += os.sep - return result + drive, _tail = osp.splitdrive(os.fspath(path)) + if drive or _is_path_rooted(path): + raise InvalidGitRepositoryError("paths with a drive or root require a non-bare repository") + return path + return _to_relative_path(cast(PathLike, self.repo.working_tree_dir), path) def _preprocess_add_items( self, items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]] diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 658259cd2..152f7821d 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -29,6 +29,7 @@ from git.util import ( IterableList, RemoteProgress, + _to_relative_path, join_path_native, rmtree, to_native_path_linux, @@ -391,23 +392,14 @@ def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike: :raise ValueError: If path is not contained in the parent repository's working tree. """ - path = to_native_path_linux(path) + if parent_repo.working_tree_dir: + path = _to_relative_path(parent_repo.working_tree_dir, path) + else: + path = to_native_path_linux(path) if path.endswith("/"): path = path[:-1] - # END handle trailing slash - - if osp.isabs(path) and parent_repo.working_tree_dir: - working_tree_linux = to_native_path_linux(parent_repo.working_tree_dir) - if not path.startswith(working_tree_linux): - raise ValueError( - "Submodule checkout path '%s' needs to be within the parents repository at '%s'" - % (working_tree_linux, path) - ) - path = path[len(working_tree_linux.rstrip("/")) + 1 :] - if not path: - raise ValueError("Absolute submodule path '%s' didn't yield a valid relative path" % path) - # END verify converted relative path makes sense - # END convert to a relative path + if not path or path == ".": + raise ValueError("Submodule checkout path must not be the repository root") return path diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 020de5e13..824d0c46c 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -119,7 +119,7 @@ def _get_validated_path(base: PathLike, path: PathLike) -> str: common_path = os.path.commonpath([base_path, abs_path]) except ValueError as e: raise ValueError("Reference path %r escapes the repository" % path) from e - if os.path.normcase(common_path) != os.path.normcase(base_path): + if common_path != base_path: raise ValueError("Reference path %r escapes the repository" % path) return abs_path diff --git a/git/repo/base.py b/git/repo/base.py index 6594101f3..dfd361747 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -961,8 +961,7 @@ def _get_alternates(self) -> List[str]: :return: List of strings being pathnames of alternates """ - if self.git_dir: - alternates_path = osp.join(self.git_dir, "objects", "info", "alternates") + alternates_path = osp.join(self.common_dir, "objects", "info", "alternates") if osp.exists(alternates_path): with open(alternates_path, "rb") as f: diff --git a/git/util.py b/git/util.py index 6f002d98a..cacab6077 100644 --- a/git/util.py +++ b/git/util.py @@ -315,6 +315,65 @@ def join_path_native(a: PathLike, *p: PathLike) -> PathLike: return to_native_path(join_path(a, *p)) +def _is_path_rooted(path: PathLike) -> bool: + r"""Whether ``path`` has a root component after any drive. + + On Windows, ``\directory`` is rooted on the current drive without being + absolute, while ``C:\directory`` has both a drive and a root. In contrast, + ``directory`` and the drive-relative ``C:directory`` have no root. + On POSIX, which has no drive concept, this simply distinguishes absolute + paths such as ``/directory`` from relative paths such as ``directory``. + """ + _drive, tail = osp.splitdrive(os.fspath(path)) + separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep) + return tail.startswith(separators) + + +def _to_relative_path(root: PathLike, path: PathLike) -> str: + r"""Return a normalized Git-style path confined to ``root``. + + A Windows path such as ``\directory`` is rooted but not absolute. Resolve it + against the drive of ``root`` rather than treating it as relative to ``root``. + Drive-relative paths such as ``C:directory`` are rejected because their meaning + depends on process-global per-drive state. + + For example, with ``root`` set to ``C:\repo`` on Windows: + + * ``directory\file`` -> ``directory/file`` + * ``directory\`` -> ``directory/`` + * ``C:\repo\directory\file`` -> ``directory/file`` + * ``\repo\directory\file`` -> ``directory/file`` + * ``C:directory\file`` -> :exc:`ValueError` + * ``C:\other\file`` -> :exc:`ValueError` + + On POSIX, ``/repo/directory/file`` under ``/repo`` similarly becomes + ``directory/file``. A trailing separator is preserved as a Git-style ``/``. + """ + path_str = os.fspath(path) + if not path_str: + return path_str + + drive, _tail = osp.splitdrive(path_str) + rooted = _is_path_rooted(path_str) + if drive and not rooted: + raise ValueError("Drive-relative path %r is not supported" % path_str) + + root_abs = osp.abspath(os.fspath(root)) + path_abs = osp.abspath(osp.join(root_abs, path_str)) + try: + common_path = osp.commonpath([root_abs, path_abs]) + except ValueError as e: + raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) from e + if common_path != root_abs: + raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) + + relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs)) + separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep) + if path_str.endswith(separators) and relative_path != "." and not relative_path.endswith("/"): + relative_path += "/" + return relative_path + + def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool: """Make sure that the directory pointed to by path exists. diff --git a/test/test_commit.py b/test/test_commit.py index cb0427740..b9ceecf07 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -276,14 +276,14 @@ def test_iteration(self): assert ltd_commits and len(ltd_commits) < len(all_commits) # Show commits of multiple paths, resulting in a union of commits. - less_ltd_commits = list(Commit.iter_items(self.rorepo, "master", paths=("CHANGES", "AUTHORS"))) + less_ltd_commits = list(Commit.iter_items(self.rorepo, "HEAD", paths=("CHANGES", "AUTHORS"))) assert len(ltd_commits) < len(less_ltd_commits) class Child(Commit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - child_commits = list(Child.iter_items(self.rorepo, "master", paths=("CHANGES", "AUTHORS"))) + child_commits = list(Child.iter_items(self.rorepo, "HEAD", paths=("CHANGES", "AUTHORS"))) assert type(child_commits[0]) is Child def test_iter_items(self): @@ -536,7 +536,7 @@ def test_trailers(self): ), ] for msg in msgs: - commit = copy.copy(self.rorepo.commit("master")) + commit = copy.copy(self.rorepo.commit("HEAD")) commit.message = msg assert commit.trailers_list == [ (KEY_1, VALUE_1_1), @@ -559,13 +559,13 @@ def test_trailers(self): ] for msg in msgs: - commit = copy.copy(self.rorepo.commit("master")) + commit = copy.copy(self.rorepo.commit("HEAD")) commit.message = msg assert commit.trailers_list == [] assert commit.trailers_dict == {} # Check that only the last key value paragraph is evaluated. - commit = copy.copy(self.rorepo.commit("master")) + commit = copy.copy(self.rorepo.commit("HEAD")) commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1_1}\n\n{KEY_2}: {VALUE_2}\n" assert commit.trailers_list == [(KEY_2, VALUE_2)] assert commit.trailers_dict == {KEY_2: [VALUE_2]} diff --git a/test/test_config.py b/test/test_config.py index 361a51fa9..ff2e9b269 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -7,14 +7,13 @@ import io import os import os.path as osp -import sys from unittest import mock import pytest from git import GitConfigParser from git.config import _OMD, cp -from git.util import rmfile +from git.util import cwd, rmfile from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory @@ -374,6 +373,22 @@ def test_config_relative_path_include(self, rw_dir): with GitConfigParser(relative_config_path, read_only=True) as cr: assert cr.get_value("included", "value") == "included" + @pytest.mark.skipif(os.name != "nt", reason="Specifically for Windows drive-rooted paths.") + @with_rw_directory + def test_config_drive_rooted_path_include(self, rw_dir): + with cwd(rw_dir): + included_path = osp.join(rw_dir, "included") + with GitConfigParser(included_path, read_only=False) as cw: + cw.set_value("included", "value", "included") + + _drive, rooted_included_path = osp.splitdrive(included_path) + config_path = osp.join(rw_dir, "config") + with GitConfigParser(config_path, read_only=False) as cw: + cw.set_value("include", "path", rooted_included_path) + + with GitConfigParser(config_path, read_only=True) as cr: + assert cr.get_value("included", "value") == "included" + @with_rw_directory def test_multiple_include_paths_with_same_key(self, rw_dir): """Test that multiple 'path' entries under [include] are all respected. @@ -411,11 +426,6 @@ def test_multiple_include_paths_with_same_key(self, rw_dir): assert cr.get_value("user", "name") == "from-inc1" assert cr.get_value("core", "bar") == "from-inc2" - @pytest.mark.xfail( - sys.platform == "win32", - reason='Second config._has_includes() assertion fails (for "config is included if path is matching git_dir")', - raises=AssertionError, - ) @with_rw_directory def test_conditional_includes_from_git_dir(self, rw_dir): # Initiate repository path. @@ -443,6 +453,14 @@ def test_conditional_includes_from_git_dir(self, rw_dir): assert config._has_includes() assert config._included_paths() == [("path", path2)] + # Ensure that Git's forward-slash syntax matches native Windows paths. + with open(path1, "w") as stream: + stream.write(template.format("gitdir", git_dir.replace("\\", "/"), path2)) + + with GitConfigParser(path1, repo=repo) as config: + assert config._has_includes() + assert config._included_paths() == [("path", path2)] + # Ensure that config is ignored if case is incorrect. with open(path1, "w") as stream: stream.write(template.format("gitdir", git_dir.upper(), path2)) diff --git a/test/test_docs.py b/test/test_docs.py index c3cfec3e0..0810d954d 100644 --- a/test/test_docs.py +++ b/test/test_docs.py @@ -72,21 +72,20 @@ def test_init_repo_object(self, rw_dir): # heads, tags and references # heads are branches in git-speak # [8-test_init_repo_object] - self.assertEqual( - repo.head.ref, - repo.heads.master, # head is a sym-ref pointing to master. - "It's ok if TC not running from `master`.", - ) + active_branch = repo.active_branch + self.assertEqual(repo.head.ref, active_branch) # HEAD is a sym-ref pointing to the active branch. self.assertEqual(repo.tags["0.3.5"], repo.tag("refs/tags/0.3.5")) # You can access tags in various ways too. - self.assertEqual(repo.refs.master, repo.heads["master"]) # .refs provides all refs, i.e. heads... + self.assertEqual(repo.refs[active_branch.name], repo.heads[active_branch.name]) # .refs provides all refs... if "TRAVIS" not in os.environ: - self.assertEqual(repo.refs["origin/master"], repo.remotes.origin.refs.master) # ... remotes ... + remote_branch = next(ref for ref in repo.remotes.origin.refs if ref.remote_head != "HEAD") + self.assertEqual(repo.refs[remote_branch.name], remote_branch) # ...remotes... self.assertEqual(repo.refs["0.3.5"], repo.tags["0.3.5"]) # ... and tags. # ![8-test_init_repo_object] # Create a new head/branch. # [9-test_init_repo_object] + original_branch = cloned_repo.active_branch new_branch = cloned_repo.create_head("feature") # Create a new branch ... assert cloned_repo.active_branch != new_branch # which wasn't checked out yet ... self.assertEqual(new_branch.commit, cloned_repo.active_branch.commit) # pointing to the checked-out commit. @@ -146,10 +145,10 @@ def update(self, op_code, cur_count, max_count=None, message=""): assert origin.exists() for fetch_info in origin.fetch(progress=MyProgressPrinter()): print("Updated %s to %s" % (fetch_info.ref, fetch_info.commit)) - # Create a local branch at the latest fetched master. We specify the name - # statically, but you have all information to do it programmatically as well. - bare_master = bare_repo.create_head("master", origin.refs.master) - bare_repo.head.set_reference(bare_master) + # Create a local branch at one of the remote's branches. + remote_branch = next(ref for ref in origin.refs if ref.remote_head != "HEAD") + bare_branch = bare_repo.create_head(remote_branch.remote_head, remote_branch) + bare_repo.head.set_reference(bare_branch) assert not bare_repo.delete_remote(origin).exists() # push and pull behave very similarly. # ![12-test_init_repo_object] @@ -162,35 +161,39 @@ def update(self, op_code, cur_count, max_count=None, message=""): new_file_path = os.path.join(cloned_repo.working_tree_dir, "my-new-file") open(new_file_path, "wb").close() # Create new file in working tree. cloned_repo.index.add([new_file_path]) # Add it to the index. - # Commit the changes to deviate masters history. + # Commit the changes to deviate from the original branch's history. cloned_repo.index.commit("Added a new file in the past - for later merge") # Prepare a merge. - master = cloned_repo.heads.master # Right-hand side is ahead of us, in the future. - merge_base = cloned_repo.merge_base(new_branch, master) # Allows for a three-way merge. - cloned_repo.index.merge_tree(master, base=merge_base) # Write the merge result into index. + merge_base = cloned_repo.merge_base(new_branch, original_branch) # Allows for a three-way merge. + cloned_repo.index.merge_tree(original_branch, base=merge_base) # Write the merge result into index. cloned_repo.index.commit( "Merged past and now into future ;)", - parent_commits=(new_branch.commit, master.commit), + parent_commits=(new_branch.commit, original_branch.commit), ) - # Now new_branch is ahead of master, which probably should be checked out and reset softly. + # Now new_branch is ahead of the original branch, which probably should be checked out and reset softly. # Note that all these operations didn't touch the working tree, as we managed it ourselves. # This definitely requires you to know what you are doing! :) assert os.path.basename(new_file_path) in new_branch.commit.tree # New file is now in tree. - master.commit = new_branch.commit # Let master point to most recent commit. - cloned_repo.head.reference = master # We adjusted just the reference, not the working tree or index. + original_branch.commit = new_branch.commit # Let the original branch point to the most recent commit. + cloned_repo.head.reference = original_branch # We adjusted just the reference, not the working tree or index. # ![13-test_init_repo_object] # submodules # [14-test_init_repo_object] - # Create a new submodule and check it out on the spot, setup to track master - # branch of `bare_repo`. As our GitPython repository has submodules already that - # point to GitHub, make sure we don't interact with them. + # Create a new submodule and check it out on the spot, set up to track the + # default branch of `bare_repo`. As our GitPython repository has submodules + # already that point to GitHub, make sure we don't interact with them. for sm in cloned_repo.submodules: assert not sm.remove().exists() # after removal, the sm doesn't exist anymore - sm = cloned_repo.create_submodule("mysubrepo", "path/to/subrepo", url=bare_repo.git_dir, branch="master") + sm = cloned_repo.create_submodule( + "mysubrepo", + "path/to/subrepo", + url=bare_repo.git_dir, + branch=bare_branch.name, + ) # .gitmodules was written and added to the index, which is now being committed. cloned_repo.index.commit("Added submodule") diff --git a/test/test_index.py b/test/test_index.py index 3ad5a457f..bece92ad6 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1086,14 +1086,43 @@ class Mocked: path = os.path.join(repo_root, f"directory2{os.sep}") index = IndexFile(repo) - expected_path = f"directory2{os.sep}" + expected_path = "directory2/" actual_path = index._to_relative_path(path) self.assertEqual(expected_path, actual_path) - with mock.patch("git.index.base.os.path") as ospath_mock: - ospath_mock.relpath.return_value = f"directory2{os.sep}" - actual_path = index._to_relative_path(path) - self.assertEqual(expected_path, actual_path) + @pytest.mark.skipif(sys.platform != "win32", reason="Specifically for Windows.") + @with_rw_directory + def test__to_relative_path_windows_path_kinds(self, rw_dir): + repo_root = osp.join(rw_dir, "repo") + os.makedirs(osp.join(repo_root, "nested")) + + class Mocked: + bare = False + git_dir = osp.join(repo_root, ".git") + working_tree_dir = repo_root + + index = IndexFile(Mocked()) + inside_path = osp.join(repo_root, "nested", "file") + _drive, rooted_inside_path = osp.splitdrive(inside_path) + + self.assertEqual(index._to_relative_path(inside_path), "nested/file") + self.assertEqual(index._to_relative_path(rooted_inside_path), "nested/file") + self.assertEqual(index._to_relative_path(rooted_inside_path.replace("\\", "/")), "nested/file") + self.assertEqual(index._to_relative_path(PathLikeMock(inside_path)), "nested/file") + self.assertEqual(index._to_relative_path(inside_path.upper()), "NESTED/FILE") + self.assertRaises(ValueError, index._to_relative_path, osp.join(repo_root + "-other", "file")) + self.assertRaises(ValueError, index._to_relative_path, osp.join("..", "outside")) + self.assertRaises(ValueError, index._to_relative_path, f"{osp.splitdrive(repo_root)[0]}relative") + self.assertRaises(ValueError, index._to_relative_path, R"Z:\outside") + self.assertRaises(ValueError, index._to_relative_path, R"\\server\share\outside") + self.assertRaises(ValueError, index._to_relative_path, R"\\?\C:\outside") + + Mocked.bare = True + bare_index = IndexFile(Mocked()) + self.assertRaises(InvalidGitRepositoryError, bare_index._to_relative_path, rooted_inside_path) + self.assertRaises( + InvalidGitRepositoryError, bare_index._to_relative_path, f"{osp.splitdrive(repo_root)[0]}relative" + ) @pytest.mark.xfail( type(_win_bash_status) is WinBashStatus.Absent, diff --git a/test/test_refs.py b/test/test_refs.py index 6481b54a8..38e15cfa1 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -258,7 +258,7 @@ def test_orig_head(self): @with_rw_repo("0.1.6") def test_head_checkout_detached_head(self, rw_repo): - res = rw_repo.remotes.origin.refs.master.checkout() + res = rw_repo.remotes.origin.refs.HEAD.reference.checkout() assert isinstance(res, SymbolicReference) assert res.name == "HEAD" @@ -661,7 +661,7 @@ def test_dereference_recursive(self): assert SymbolicReference.dereference_recursive(self.rorepo, "HEAD") def test_reflog(self): - assert isinstance(self.rorepo.heads.master.log(), RefLog) + assert isinstance(self.rorepo.active_branch.log(), RefLog) def test_refs_outside_repo(self): # Create a file containing a valid reference outside the repository. Attempting diff --git a/test/test_repo.py b/test/test_repo.py index 7c7f1dd34..84336a39b 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -128,8 +128,9 @@ def test_heads_should_populate_head_data(self): self.assertIsInstance(head.commit, Commit) # END for each head - self.assertIsInstance(self.rorepo.heads.master, Head) - self.assertIsInstance(self.rorepo.heads["master"], Head) + active_branch = self.rorepo.active_branch + self.assertIsInstance(self.rorepo.heads[active_branch.name], Head) + self.assertEqual(self.rorepo.heads[active_branch.name], active_branch) def test_tree_from_revision(self): tree = self.rorepo.tree("0.1.6") @@ -233,6 +234,18 @@ def test_clone_from_keeps_env(self, rw_dir): self.assertEqual(environment, cloned.git.environment()) + @pytest.mark.skipif(os.name != "nt", reason="Specifically for Windows drive-rooted paths.") + @with_rw_directory + def test_clone_from_drive_rooted_destination(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + with cwd(rw_dir): + destination = osp.join(rw_dir, "clone") + _drive, rooted_destination = osp.splitdrive(destination) + + cloned = Repo.clone_from(original_repo.git_dir, rooted_destination) + + assert osp.samefile(cloned.working_tree_dir, destination) + @with_rw_directory def test_date_format(self, rw_dir): repo = Repo.init(osp.join(rw_dir, "repo")) @@ -326,13 +339,29 @@ def test_daemon_export(self): def test_alternates(self): cur_alternates = self.rorepo.alternates - # empty alternates - self.rorepo.alternates = [] - self.assertEqual(self.rorepo.alternates, []) + try: + # Empty alternates. + self.rorepo.alternates = [] + self.assertEqual(self.rorepo.alternates, []) + alts = ["other/location", "this/location"] + self.rorepo.alternates = alts + self.assertEqual(alts, self.rorepo.alternates) + finally: + self.rorepo.alternates = cur_alternates + + @with_rw_directory + def test_alternates_use_common_dir(self, rw_dir): + common_dir = osp.join(rw_dir, "common") + git_dir = osp.join(rw_dir, "worktrees", "linked") + os.makedirs(osp.join(common_dir, "objects", "info")) + os.makedirs(osp.join(git_dir, "objects", "info")) + repo = mock.Mock(common_dir=common_dir, git_dir=git_dir) + alts = ["other/location", "this/location"] - self.rorepo.alternates = alts - self.assertEqual(alts, self.rorepo.alternates) - self.rorepo.alternates = cur_alternates + Repo._set_alternates(repo, alts) + + self.assertEqual(Repo._get_alternates(repo), alts) + self.assertFalse(osp.exists(osp.join(git_dir, "objects", "info", "alternates"))) def test_repr(self): assert repr(self.rorepo).startswith("=4 -env_list = py{37,38,39,310,311,312}, ruff, format, mypy, html, misc +env_list = py{37,38,39,310,311,312,313,314,315}, ruff, format, mypy, html, misc [testenv] description = Run unit tests From 308d3716301edda367a4169fb1f512da99936a54 Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 28 Jul 2026 04:14:53 +0000 Subject: [PATCH 10/22] various fixes for windows paths - Preserve POSIX backslashes in include patterns - Accept UNC share roots as rooted paths - treat includeIf patterns correctly on Windows, expecting forward slashes - Recognize UNC roots on older Python versions Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/config.py | 6 ++++-- git/util.py | 9 ++++++--- test/test_config.py | 35 ++++++++++++++++++++++++++++------- test/test_index.py | 9 +++++++++ 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/git/config.py b/git/config.py index fe80ea68d..8b26be722 100644 --- a/git/config.py +++ b/git/config.py @@ -576,8 +576,10 @@ def _all_items(section: str) -> List[Tuple[str, str]]: value = match.group(2).strip() if keyword in ["gitdir", "gitdir/i"]: - value = osp.expanduser(value).replace("\\", "/") - git_dir = os.fspath(self._repo.git_dir).replace("\\", "/") if self._repo.git_dir else None + value = osp.expanduser(value) + git_dir = os.fspath(self._repo.git_dir) if self._repo.git_dir else None + if sys.platform == "win32": + git_dir = git_dir.replace("\\", "/") if git_dir else None drive, _tail = osp.splitdrive(value) if not drive and not any(value.startswith(s) for s in ["./", "/"]): diff --git a/git/util.py b/git/util.py index cacab6077..02f57c132 100644 --- a/git/util.py +++ b/git/util.py @@ -316,17 +316,20 @@ def join_path_native(a: PathLike, *p: PathLike) -> PathLike: def _is_path_rooted(path: PathLike) -> bool: - r"""Whether ``path`` has a root component after any drive. + r"""Whether ``path`` has a root, including one encoded in a UNC drive. On Windows, ``\directory`` is rooted on the current drive without being absolute, while ``C:\directory`` has both a drive and a root. In contrast, ``directory`` and the drive-relative ``C:directory`` have no root. + UNC paths are rooted: ``\\server\share`` stores the share in the drive + returned by :func:`os.path.splitdrive`, while ``\\server\share\directory`` + additionally has a rooted tail. On POSIX, which has no drive concept, this simply distinguishes absolute paths such as ``/directory`` from relative paths such as ``directory``. """ - _drive, tail = osp.splitdrive(os.fspath(path)) + drive, tail = osp.splitdrive(os.fspath(path)) separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep) - return tail.startswith(separators) + return tail.startswith(separators) or drive.startswith(separators) def _to_relative_path(root: PathLike, path: PathLike) -> str: diff --git a/test/test_config.py b/test/test_config.py index ff2e9b269..f5316296b 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -7,6 +7,7 @@ import io import os import os.path as osp +import sys from unittest import mock import pytest @@ -430,6 +431,7 @@ def test_multiple_include_paths_with_same_key(self, rw_dir): def test_conditional_includes_from_git_dir(self, rw_dir): # Initiate repository path. git_dir = osp.join(rw_dir, "target1", "repo1") + git_dir_pattern = git_dir.replace("\\", "/") os.makedirs(git_dir) # Initiate mocked repository. @@ -441,6 +443,7 @@ def test_conditional_includes_from_git_dir(self, rw_dir): template = '[includeIf "{}:{}"]\n path={}\n' with open(path1, "w") as stream: + # on Windows, this writes a backslash pattern. stream.write(template.format("gitdir", git_dir, path2)) # Ensure that config is ignored if no repo is set. @@ -448,14 +451,18 @@ def test_conditional_includes_from_git_dir(self, rw_dir): assert not config._has_includes() assert config._included_paths() == [] - # Ensure that config is included if path is matching git_dir. - with GitConfigParser(path1, repo=repo) as config: - assert config._has_includes() - assert config._included_paths() == [("path", path2)] + # Git uses forward slashes in gitdir patterns on every platform: + # backslashes escape the next pattern character rather than separate + # path components. On Windows, GitPython therefore normalizes git_dir + # to forward slashes but leaves this backslash pattern unchanged, so + # the two do not match and no path is included. + with GitConfigParser(path1, repo=repo, merge_includes=False) as config: + expected_paths = [] if sys.platform == "win32" else [("path", path2)] + assert config._included_paths() == expected_paths # Ensure that Git's forward-slash syntax matches native Windows paths. with open(path1, "w") as stream: - stream.write(template.format("gitdir", git_dir.replace("\\", "/"), path2)) + stream.write(template.format("gitdir", git_dir_pattern, path2)) with GitConfigParser(path1, repo=repo) as config: assert config._has_includes() @@ -463,7 +470,7 @@ def test_conditional_includes_from_git_dir(self, rw_dir): # Ensure that config is ignored if case is incorrect. with open(path1, "w") as stream: - stream.write(template.format("gitdir", git_dir.upper(), path2)) + stream.write(template.format("gitdir", git_dir_pattern.upper(), path2)) with GitConfigParser(path1, repo=repo) as config: assert not config._has_includes() @@ -471,7 +478,7 @@ def test_conditional_includes_from_git_dir(self, rw_dir): # Ensure that config is included if case is ignored. with open(path1, "w") as stream: - stream.write(template.format("gitdir/i", git_dir.upper(), path2)) + stream.write(template.format("gitdir/i", git_dir_pattern.upper(), path2)) with GitConfigParser(path1, repo=repo) as config: assert config._has_includes() @@ -501,6 +508,20 @@ def test_conditional_includes_from_git_dir(self, rw_dir): assert config._has_includes() assert config._included_paths() == [("path", path2)] + @with_rw_directory + def test_conditional_includes_do_not_treat_backslashes_as_separators(self, rw_dir): + git_dir = osp.join(rw_dir, "target", "repo") + repo = mock.Mock(git_dir=git_dir) + config_path = osp.join(rw_dir, "config") + included_path = osp.join(rw_dir, "included") + pattern = git_dir.replace("\\", "/").replace("/target/repo", R"/target\repo") + + with open(config_path, "w") as stream: + stream.write(f'[includeIf "gitdir:{pattern}"]\n path={included_path}\n') + + with GitConfigParser(config_path, repo=repo, merge_includes=False) as config: + assert config._included_paths() == [] + @with_rw_directory def test_conditional_includes_from_branch_name(self, rw_dir): # Initiate mocked branch. diff --git a/test/test_index.py b/test/test_index.py index bece92ad6..2d2e47d20 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1090,6 +1090,15 @@ class Mocked: actual_path = index._to_relative_path(path) self.assertEqual(expected_path, actual_path) + @pytest.mark.skipif(sys.platform != "win32", reason="Specifically for Windows.") + def test__to_relative_path_windows_unc_share_root(self): + for repo_root in [R"\\server\share", R"\\?\UNC\server\share"]: + with self.subTest(repo_root=repo_root): + repo = mock.Mock(bare=False, git_dir=repo_root, working_tree_dir=repo_root) + index = IndexFile(repo) + + self.assertEqual(index._to_relative_path(repo_root), ".") + @pytest.mark.skipif(sys.platform != "win32", reason="Specifically for Windows.") @with_rw_directory def test__to_relative_path_windows_path_kinds(self, rw_dir): From e0e5918cd7b144a15710420c3829c33eafa898bf Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 4 Aug 2026 11:20:59 +0200 Subject: [PATCH 11/22] Handle uninitialized submodule and commit streams RootModule.update could swallow setup failures with keep_going and then iterate sms before assignment. Start with an empty submodule list so the handled failure safely skips updates. Commit iteration could likewise read stream before assignment for processes without stdout or unsupported inputs. Raise explicit input errors instead. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/objects/commit.py | 7 +++++-- git/objects/submodule/root.py | 5 +++-- test/test_commit.py | 5 +++++ test/test_submodule.py | 3 +++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/git/objects/commit.py b/git/objects/commit.py index 3e435453d..45843eac2 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -575,11 +575,14 @@ def _iter_from_process_or_stream(cls, repo: "Repo", proc_or_stream: Union[Popen, if hasattr(proc_or_stream, "wait"): proc_or_stream = cast(Popen, proc_or_stream) - if proc_or_stream.stdout is not None: - stream = proc_or_stream.stdout + stream = proc_or_stream.stdout + if stream is None: + raise ValueError("Process has no stdout stream") elif hasattr(proc_or_stream, "readline"): proc_or_stream = cast(IO, proc_or_stream) # type: ignore[redundant-cast] stream = proc_or_stream + else: + raise TypeError("Expected a process or stream") readline = stream.readline while True: diff --git a/git/objects/submodule/root.py b/git/objects/submodule/root.py index d93193fa3..d068049c1 100644 --- a/git/objects/submodule/root.py +++ b/git/objects/submodule/root.py @@ -7,6 +7,7 @@ import git from git.exc import InvalidGitRepositoryError +from git.util import IterableList from .base import Submodule, UpdateProgress from .util import find_first_remote_branch @@ -19,7 +20,6 @@ if TYPE_CHECKING: from git.repo import Repo - from git.util import IterableList # ---------------------------------------------------------------------------- @@ -162,6 +162,7 @@ def update( # type: ignore[override] prefix = "DRY-RUN: " repo = self.repo + sms: "IterableList[Submodule]" = IterableList("name") try: # SETUP BASE COMMIT @@ -182,7 +183,7 @@ def update( # type: ignore[override] # END handle previous commit psms: "IterableList[Submodule]" = self.list_items(repo, parent_commit=previous_commit) - sms: "IterableList[Submodule]" = self.list_items(repo) + sms = self.list_items(repo) spsms = set(psms) ssms = set(sms) diff --git a/test/test_commit.py b/test/test_commit.py index b9ceecf07..431269b29 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -327,6 +327,11 @@ def test_rev_list_bisect_all(self): for sha1, commit in zip(expected_ids, commits): self.assertEqual(sha1, commit.hexsha) + def test_iter_from_invalid_process_or_stream(self): + for source, error in ((Mock(wait=Mock(), stdout=None), ValueError), (object(), TypeError)): + with self.assertRaises(error): + list(Commit._iter_from_process_or_stream(self.rorepo, source)) + @with_rw_directory def test_ambiguous_arg_iteration(self, rw_dir): rw_repo = Repo.init(osp.join(rw_dir, "test_ambiguous_arg")) diff --git a/test/test_submodule.py b/test/test_submodule.py index 0e7164641..287986059 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -511,6 +511,9 @@ def test_root_module(self, rwrepo): # Cannot set the parent commit as root module's path didn't exist. self.assertRaises(ValueError, rm.set_parent_commit, "HEAD") + with mock.patch.object(RootModule, "list_items", side_effect=ValueError("boom")): + rm.update(keep_going=True) + # TEST UPDATE ############# # Set up a commit that removes existing, adds new and modifies existing From afe3ac98d8b125d5d9ef02cdbb3e18b444fbb11c Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 4 Aug 2026 11:23:58 +0200 Subject: [PATCH 12/22] Remove possibly-unbound type suppressions Make established repository invariants explicit, initialize loop-only locals, and move assignments ahead of exception handling so basedpyright can follow the existing control flow. Regenerate the baseline to remove all 19 reportPossiblyUnboundVariable suppressions, including the two runtime fixes from the preceding commit. Validation: basedpyright --warnings; unbaselined possibly-unbound count 0; eight focused subsystem tests passed. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- .basedpyright/baseline.json | 138 ---------------------------------- git/index/base.py | 11 ++- git/objects/submodule/base.py | 2 + git/refs/log.py | 1 + git/repo/base.py | 18 +++-- test/test_repo.py | 6 ++ 6 files changed, 28 insertions(+), 148 deletions(-) diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 7066ccd48..14bf1024e 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -183,14 +183,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 52, - "endColumn": 59, - "lineCount": 1 - } - }, { "code": "reportSelfClsParameterName", "range": { @@ -207,14 +199,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 41, - "endColumn": 44, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -393,14 +377,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 19, - "endColumn": 25, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -467,30 +443,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 23, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 32, - "endColumn": 43, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 23, - "endColumn": 34, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -499,14 +451,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 28, - "endColumn": 32, - "lineCount": 1 - } - }, { "code": "reportAttributeAccessIssue", "range": { @@ -588,16 +532,6 @@ } } ], - "./git/objects/submodule/root.py": [ - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 18, - "endColumn": 21, - "lineCount": 1 - } - } - ], "./git/objects/tag.py": [ { "code": "reportIncompatibleVariableOverride", @@ -701,14 +635,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 41, - "endColumn": 45, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -855,38 +781,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 26, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 32, - "endColumn": 40, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 18, - "endColumn": 26, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 22, - "endColumn": 30, - "lineCount": 1 - } - }, { "code": "reportReturnType", "range": { @@ -967,22 +861,6 @@ "lineCount": 1 } }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 39, - "endColumn": 47, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 43, - "endColumn": 51, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -998,22 +876,6 @@ "endColumn": 38, "lineCount": 1 } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 26, - "endColumn": 42, - "lineCount": 1 - } - }, - { - "code": "reportPossiblyUnboundVariable", - "range": { - "startColumn": 18, - "endColumn": 34, - "lineCount": 1 - } } ], "./git/repo/fun.py": [ diff --git a/git/index/base.py b/git/index/base.py index 248a7f10a..dd5c1a905 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -730,14 +730,17 @@ def _entries_for_paths( ) -> List[BaseIndexEntry]: entries_added: List[BaseIndexEntry] = [] if path_rewriter: + working_tree_dir = self.repo.working_tree_dir + if working_tree_dir is None: + raise InvalidGitRepositoryError("Cannot rewrite paths without a working tree") + working_tree_dir = str(working_tree_dir) for path in paths: if osp.isabs(path): abspath = path - gitrelative_path = path[len(str(self.repo.working_tree_dir)) + 1 :] + gitrelative_path = path[len(working_tree_dir) + 1 :] else: gitrelative_path = path - if self.repo.working_tree_dir: - abspath = osp.join(self.repo.working_tree_dir, gitrelative_path) + abspath = osp.join(working_tree_dir, gitrelative_path) # END obtain relative and absolute paths blob = Blob( @@ -1467,8 +1470,8 @@ def reset( nie = new_inst.entries for path in paths: path = self._to_relative_path(path) + key = entry_key(path, 0) try: - key = entry_key(path, 0) self.entries[key] = nie[key] except KeyError: # If key is not in theirs, it mustn't be in ours. diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 3797bdc90..da0e09af4 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -877,6 +877,7 @@ def fetch_remotes(module_repo: "Repo") -> None: ############################# binsha = self.binsha hexsha = self.hexsha + is_detached = False if mrepo is not None: # mrepo is only set if we are not in dry-run mode or if the module # existed. @@ -1221,6 +1222,7 @@ def remove( for remote in mod.remotes: num_branches_with_new_commits = 0 rrefs = remote.refs + rref = None for rref in rrefs: num_branches_with_new_commits += len(mod.git.cherry(rref)) != 0 # END for each remote ref diff --git a/git/refs/log.py b/git/refs/log.py index fbbe66b22..0681943bf 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -269,6 +269,7 @@ def entry_at(cls, filepath: PathLike, index: int) -> "RefLogEntry": return RefLogEntry.from_line(fp.readlines()[index].strip()) # Read until index is reached. + line = b"" for i in range(index + 1): line = fp.readline() if not line: diff --git a/git/repo/base.py b/git/repo/base.py index dfd361747..e4d5e92c3 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -933,13 +933,17 @@ def is_valid_object(self, sha: str, object_type: Union[str, None] = None) -> boo return False def _get_daemon_export(self) -> bool: - if self.git_dir: - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) + git_dir = getattr(self, "git_dir", None) + if git_dir is None: + return False + filename = osp.join(git_dir, self.DAEMON_EXPORT_FILE) return osp.exists(filename) def _set_daemon_export(self, value: object) -> None: - if self.git_dir: - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) + git_dir = getattr(self, "git_dir", None) + if git_dir is None: + return + filename = osp.join(git_dir, self.DAEMON_EXPORT_FILE) fileexists = osp.exists(filename) if value and not fileexists: touch(filename) @@ -1279,6 +1283,7 @@ class InfoTD(TypedDict, total=False): keepends = True for line_bytes in data.splitlines(keepends): + line_str = "" try: line_str = line_bytes.rstrip().decode(defenc) except UnicodeDecodeError: @@ -1737,8 +1742,9 @@ def currently_rebasing_on(self) -> Commit | None: ``None`` if we are not currently rebasing. """ - if self.git_dir: - rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") + if not self.git_dir: + return None + rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") if not osp.isfile(rebase_head_file): return None with open(rebase_head_file, "rt") as f: diff --git a/test/test_repo.py b/test/test_repo.py index 84336a39b..5c4b416ff 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -337,6 +337,12 @@ def test_daemon_export(self): self.rorepo.daemon_export = orig_val self.assertEqual(self.rorepo.daemon_export, orig_val) + def test_daemon_export_without_git_dir(self): + repo = Repo.__new__(Repo) + repo.git_dir = None + self.assertFalse(repo._get_daemon_export()) + repo._set_daemon_export(True) + def test_alternates(self): cur_alternates = self.rorepo.alternates try: From b6f4a75a682d5c5b2e26d22350a476957d4d4117 Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 30 Jul 2026 19:55:01 +0000 Subject: [PATCH 13/22] Address review comments about Windows Bash lookup The resolver merged in #2199 only worked when Git for Windows Bash itself preceded System32 on PATH. That is typical in Git Bash and CI but not in a normal system-wide installation, where PATH commonly contains System32 followed by Git\cmd. It also skipped an explicitly listed current directory even though explicit PATH entries are trusted configuration, and its test mocked the resolver rather than exercising its precedence. The machine producing this commit provides Ubuntu under WSL 2, C:\Windows\System32\bash.exe, and Git for Windows. Direct CreateProcess-style invocation of bare bash.exe reported Linux 6.18.33.2-microsoft-standard-WSL2, while `git var GIT_SHELL_PATH` reported C:/Program Files/Git/usr/bin/sh.exe and that shell reported MINGW64. With PATH reordered to System32 followed by Git\usr\bin, the merged resolver selected the System32 WSL launcher and an actual GitPython hook wrote a Linux marker. With the more typical System32 followed by Git\cmd PATH, Bash was not present on PATH at all. Locate the Bash associated with GitPython's selected Git executable before general PATH lookup. Recognize the standard Git for Windows layouts Git\cmd\git.exe, Git\bin\git.exe, and Git\\bin\git.exe, with the platform names used by MSYS2. Configured relative Git executable paths are resolved against the parent process working directory, matching measured CreateProcess behavior even when Popen supplies a different hook working directory. Root-level bin is accepted for a selected Git executable, while usr\bin is deliberately not used to infer an unbounded parent layout. From the trusted Git root, follow gix-path's precedence of bin/bash.exe before usr/bin/bash.exe. If the Git layout is unrecognized, search explicit PATH entries while excluding candidates below SystemRoot so the WSL launcher cannot win. Empty PATH entries are ignored according to Windows semantics, but an explicitly named directory remains eligible even when it is the current directory. Finally, retain the prior bare fallback for nonstandard installations; safer_popen sets NoDefaultCurrentDirectoryInExePath, so that fallback does not reintroduce current-directory lookup. The main regression models System32 before Git\cmd with Bash absent from PATH and checks the real resolver selects the associated Git\bin\bash.exe. Additional tests distinguish an explicit current-directory entry from an empty entry and cover an explicitly configured Git\bin\git.exe. On this machine, an end-to-end hook run under exactly System32;Git\cmd selected C:\Program Files\Git\bin\bash.exe and wrote a MINGW64 marker instead of the earlier Linux/WSL marker. Validated with the focused hook suite (6 passed), the complete test_index.py module (32 passed, one expected xfail, one existing xpass), repository-wide Ruff lint and format checks targeting Python 3.7, and git diff --check. A standalone Python 3.7 interpreter was not available for an additional py_compile run. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/index/fun.py | 102 ++++++++++++++++++++++++++++++++++++++++++++- test/test_index.py | 70 +++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 5 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index 5d52486f9..886e10de9 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -26,7 +26,7 @@ from gitdb.base import IStream from gitdb.typ import str_tree_type -from git.cmd import handle_process_output, safer_popen +from git.cmd import Git, handle_process_output, safer_popen from git.compat import defenc, force_bytes, force_text, safe_decode from git.exc import HookExecutionError, UnmergedEntriesError from git.objects.fun import ( @@ -79,6 +79,99 @@ def _has_file_extension(path: str) -> str: return osp.splitext(path)[1] +def _is_in_windows_system_root(path: str) -> bool: + """Return whether ``path`` is inside the Windows installation directory.""" + system_root = os.environ.get("SystemRoot") + if not system_root: + return False + + system_root = osp.normcase(osp.realpath(system_root)) + path = osp.normcase(osp.realpath(path)) + try: + return osp.commonpath((system_root, path)) == system_root + except ValueError: + # Paths on different drives have no common path on Windows. + return False + + +def _which_from_path(command: str) -> Union[str, None]: + """Resolve ``command`` from PATH, excluding the Windows installation.""" + for directory in os.get_exec_path(): + # Unlike POSIX, Windows does not define an empty PATH entry as the current + # directory. Skip it rather than letting abspath() turn it into one. + if not directory: + continue + directory = osp.abspath(directory) + candidate = osp.join(directory, command) + # SystemRoot contains the WSL launcher stubs. They are valid executables but + # not suitable for running a Windows Git hook: the hook path and environment + # were prepared for Git for Windows, and WSL may have no distribution at all. + if _is_in_windows_system_root(candidate): + continue + if osp.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + +_GIT_FOR_WINDOWS_PREFIXES = ("mingw64", "mingw32", "clangarm64", "clang64", "clang32", "ucrt64") + + +def _git_for_windows_root() -> Union[str, None]: + """Infer a standard Git for Windows root from GitPython's selected executable.""" + git_executable = os.fspath(Git.GIT_PYTHON_GIT_EXECUTABLE or Git.git_exec_name) + if osp.dirname(git_executable): + # CreateProcess resolves a relative executable path containing a directory + # from the parent process cwd, even when Popen supplies a different child cwd. + git_executable = osp.abspath(git_executable) + else: + # GitPython deliberately retains a bare executable name so later PATH changes + # affect Git commands. Resolve it with the same PATH snapshot used for Bash. + names = (git_executable,) if _has_file_extension(git_executable) else (git_executable, f"{git_executable}.exe") + for name in names: + resolved = _which_from_path(name) + if resolved is not None: + git_executable = resolved + break + else: + git_executable = "" + if not git_executable: + return None + if osp.basename(git_executable).lower() not in ("git", "git.exe"): + return None + + executable_dir = osp.dirname(git_executable) + directory_name = osp.basename(executable_dir).lower() + if directory_name == "cmd": + # The normal system-wide PATH entry is /cmd. + return osp.dirname(executable_dir) + if directory_name == "bin": + prefix = osp.dirname(executable_dir) + if osp.basename(prefix).lower() in _GIT_FOR_WINDOWS_PREFIXES: + # Git Bash commonly exposes //bin/git.exe. + return osp.dirname(prefix) + if osp.basename(prefix).lower() != "usr": + # An explicitly configured Git may be the root-level bin/git.exe. Do + # not make the same inference from usr/bin: unlike the recognized + # platform prefixes, "usr" has no reliably bounded parent layout. + return prefix + return None + + +def _git_for_windows_bash() -> Union[str, None]: + """Return Bash from the Git for Windows installation selected by GitPython.""" + git_root = _git_for_windows_root() + if git_root is None: + return None + + # Match gix-path's precedence: prefer the lightweight bin shim, then the + # underlying usr/bin executable. Both belong to the same installation as Git. + for relative_path in ("bin/bash.exe", "usr/bin/bash.exe"): + candidate = osp.join(git_root, *relative_path.split("/")) + if osp.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: """Run the commit hook of the given name. Silently ignore hooks that do not exist. @@ -112,7 +205,12 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: # an absolute path in this form, although a relative path is preferable # because it also works with the Windows Subsystem for Linux wrapper. bash_hp = hp - cmd = ["bash.exe", Path(bash_hp).as_posix()] + # Prefer Bash associated with GitPython's selected Git installation. If + # that layout is not recognized, use an explicitly configured non-system + # PATH entry. Preserve the bare fallback for installations that previously + # relied on WSL or another CreateProcess-resolved Bash. + bash_executable = _git_for_windows_bash() or _which_from_path("bash.exe") or "bash.exe" + cmd = [bash_executable, Path(bash_hp).as_posix()] process = safer_popen( cmd + list(args), diff --git a/test/test_index.py b/test/test_index.py index 3ad5a457f..0f15d425e 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -33,7 +33,7 @@ UnmergedEntriesError, UnsafeOptionError, ) -from git.index.fun import hook_path, run_commit_hook +from git.index.fun import _git_for_windows_bash, _which_from_path, hook_path, run_commit_hook from git.index.typ import BaseIndexEntry, IndexEntry from git.index.util import TemporaryFileSwap from git.objects import Blob @@ -1128,16 +1128,80 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir): repo = Repo.init(root / "repo") hooks_dir = root / "hooks" _make_hook(root, "fake-hook", "exit 0") + system_root = root / "Windows" + system_bash = system_root / "System32" / "bash.exe" + git_executable = root / "Git" / "cmd" / "git.exe" + git_bash = root / "Git" / "bin" / "bash.exe" + for executable in (system_bash, git_executable, git_bash): + executable.parent.mkdir(parents=True) + executable.touch() + executable.chmod(0o755) with repo.config_writer() as writer: writer.set_value("core", "hooksPath", str(hooks_dir)) - with mock.patch("git.index.fun.sys.platform", "win32"): + # Model a normal Windows PATH: System32 (containing the WSL launcher) comes + # before Git's cmd directory, while Git's Bash is not itself on PATH. This + # exercises both Git-installation discovery and shell selection without + # mocking either resolver's answer. + with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch.object( + Git, "GIT_PYTHON_GIT_EXECUTABLE", "git" + ), mock.patch.dict(os.environ, {"SystemRoot": str(system_root)}), mock.patch( + "git.index.fun.os.get_exec_path", return_value=["", str(system_bash.parent), str(git_executable.parent)] + ): with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"): popen.return_value.returncode = 0 run_commit_hook("fake-hook", repo.index) command = popen.call_args[0][0] - self.assertEqual(command, ["bash.exe", "../hooks/fake-hook"]) + self.assertEqual(command, [str(git_bash), "../hooks/fake-hook"]) + + @with_rw_directory + def test_windows_bash_lookup_respects_explicit_current_directory_in_path(self, rw_dir): + root = Path(rw_dir).resolve() + bash = root / "bash.exe" + bash.touch() + bash.chmod(0o755) + + # An explicitly listed directory is trusted PATH configuration, even when + # it happens to be the current directory. This differs from an empty entry, + # which Windows requires PATH lookup to ignore. + with cwd(root), mock.patch("git.index.fun.os.get_exec_path", return_value=[str(root)]): + self.assertEqual(_which_from_path("bash.exe"), str(bash)) + + @with_rw_directory + def test_windows_bash_lookup_from_explicit_git_bin(self, rw_dir): + git_root = Path(rw_dir).resolve() / "Git" + git_executable = git_root / "bin" / "git.exe" + bash = git_root / "bin" / "bash.exe" + git_executable.parent.mkdir(parents=True) + for executable in (git_executable, bash): + executable.touch() + executable.chmod(0o755) + + # A relative executable containing a directory is resolved by CreateProcess + # from the parent process cwd, not the separately supplied child cwd. Enter the + # temporary root first because Windows cannot express a relative path between + # drives, and CI may keep the checkout and its temporary directory on different + # drives. + with cwd(Path(rw_dir).resolve()): + relative_git = osp.relpath(git_executable, os.curdir) + with mock.patch.object(Git, "GIT_PYTHON_GIT_EXECUTABLE", relative_git): + self.assertEqual(_git_for_windows_bash(), str(bash)) + + @with_rw_directory + def test_windows_bash_lookup_ignores_custom_git_executable(self, rw_dir): + root = Path(rw_dir).resolve() + for directory_name in ("cmd", "bin"): + executable = root / directory_name / "mygit.exe" + bash = root / "bin" / "bash.exe" + executable.parent.mkdir(parents=True, exist_ok=True) + bash.parent.mkdir(parents=True, exist_ok=True) + executable.touch() + bash.touch() + executable.chmod(0o755) + bash.chmod(0o755) + with mock.patch.object(Git, "GIT_PYTHON_GIT_EXECUTABLE", str(executable)): + self.assertIsNone(_git_for_windows_bash()) @ddt.data((False,), (True,)) @with_rw_directory From a495ccd3b547ccd60b2187215823b72a9c0188bf Mon Sep 17 00:00:00 2001 From: Byron Date: Sun, 2 Aug 2026 08:57:12 +0000 Subject: [PATCH 14/22] Reject syntax-bearing git config option names GHSA-jm78-9fvv-mhgr reports that config option names containing Git syntax can be serialized as unintended directives. A regression test showed that set, set_value, and add_value accepted delimiter, comment, bracket, and whitespace characters in option names. Restrict written option names to GitPython's established safe character set of letters, digits, hyphens, underscores, and dots. This blocks characters that can change config syntax while preserving option names historically supported by the writer and SectionConstraint. A broader audit confirmed that every public option-creating config API and SectionConstraint delegate reaches this validator; no separate config writer sink was found. The behavior was checked against Git cf5497b14, and the full config test module plus dotted-option regression pass. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/config.py | 5 +++++ test/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/git/config.py b/git/config.py index 8b26be722..c300de499 100644 --- a/git/config.py +++ b/git/config.py @@ -75,6 +75,9 @@ UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]") """Characters that cannot be safely written in config names or values.""" +VALID_CONFIG_OPTION_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +"""Pattern for option names that can be written without changing config syntax.""" + class MetaParserBuilder(abc.ABCMeta): # noqa: B024 """Utility class wrapping base-class methods into decorators that assure read-only @@ -900,6 +903,8 @@ def _value_to_string_safe(self, value: Union[str, bytes, int, float, bool]) -> s def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) + if label == "option" and isinstance(name, str) and not VALID_CONFIG_OPTION_NAME_RE.fullmatch(name): + raise ValueError("Git config option names may contain only letters, digits, '-', '_', or '.'") if label == "section" and isinstance(name, str): in_quotes = False escaped = False diff --git a/test/test_config.py b/test/test_config.py index f5316296b..fd0d347a4 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -194,6 +194,43 @@ def test_set_value_rejects_unsafe_section_and_option_names(self, rw_dir): self.assertEqual(git_config.get_value("user", "name"), "safe") self.assertFalse(git_config.has_section("core")) + @with_rw_directory + def test_writer_rejects_invalid_option_names(self, rw_dir): + config_path = osp.join(rw_dir, "config") + bad_options = ( + "name=value", + "name#comment", + "name;comment", + "name with space", + "name\twith-tab", + "name[section", + "name]section", + "name:colon", + 'name"quote', + "name\\escape", + ) + + with GitConfigParser(config_path, read_only=False) as git_config: + git_config.add_section("user") + for bad_option in bad_options: + with pytest.raises(ValueError, match="option name"): + git_config.set("user", bad_option, "unsafe") + with pytest.raises(ValueError, match="option name"): + git_config.set_value("user", bad_option, "unsafe") + with pytest.raises(ValueError, match="option name"): + git_config.add_value("user", bad_option, "unsafe") + + git_config.set_value("user", "safe-option1", "safe") + git_config.set_value("user", "safe_option2", "safe") + git_config.set_value("user", "3safe_option", "safe") + git_config.set_value("user", "safe.option3", "safe") + + with GitConfigParser(config_path, read_only=True) as git_config: + self.assertEqual(git_config.get_value("user", "safe-option1"), "safe") + self.assertEqual(git_config.get_value("user", "safe_option2"), "safe") + self.assertEqual(git_config.get_value("user", "3safe_option"), "safe") + self.assertEqual(git_config.get_value("user", "safe.option3"), "safe") + @with_rw_directory def test_writer_rejects_unquoted_section_terminators(self, rw_dir): config_path = osp.join(rw_dir, "config") From 96a888f4d782cb2f80452148e48e60ce4af6d541 Mon Sep 17 00:00:00 2001 From: Byron Date: Sun, 2 Aug 2026 08:59:57 +0000 Subject: [PATCH 15/22] Check joined short-option values before Git execution GHSA-wvpp-8hx9-p66j reports that unsafe-option checks omitted the value joined to a one-character option when split_single_char_options was false. A regression test reproduced the mismatch: GitPython checked only -n even though it emitted a joined -nVALUE token that Git parses as clustered short options. Collect the exact joined token for unsplit one-character keyword arguments so the existing clustered-short-option validation sees every option character. The split form and long-option behavior remain unchanged. A broader audit confirmed that all guarded keyword-forwarding APIs use _option_candidates, including clone, ls-remote, fetch, pull, push, archive, revision, diff, checkout-index, and tag paths. Git cf5497b14 confirms repeated short-option parsing within a joined token. Focused candidate and unsafe-option tests pass. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/cmd.py | 12 ++++++++++-- test/test_git.py | 10 +++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 4f7c3b443..6e3d07c98 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1044,13 +1044,21 @@ def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[s values = value if isinstance(value, (list, tuple)) else (value,) if any(value is True or (value is not False and value is not None) for value in values): key = str(key) - options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}") - if len(key) == 1 and split_single_char_options: + if len(key) != 1: + options.append(f"--{dashify(key)}") + elif split_single_char_options: + options.append(f"-{key}") options.extend( str(value) for value in values if value is not True and value not in (False, None) and str(value).startswith("-") ) + else: + options.extend( + f"-{key}" if value is True else f"-{key}{value}" + for value in values + if value is True or (value is not False and value is not None) + ) return options AutoInterrupt: TypeAlias = _AutoInterrupt diff --git a/test/test_git.py b/test/test_git.py index 96df3c8f0..d3c43b247 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -230,7 +230,15 @@ def test_option_candidates_include_split_single_char_option_values(self): unsplit_kwargs = {"n": "--upload-pack=helper", "split_single_char_options": False} self.assertEqual(self.git.transform_kwargs(**unsplit_kwargs), ["-n--upload-pack=helper"]) - self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n"]) + self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n--upload-pack=helper"]) + + def test_option_candidates_include_joined_single_char_option_values(self): + kwargs = {"n": "uhelper", "split_single_char_options": False} + candidates = Git._option_candidates(kwargs=kwargs) + + self.assertEqual(candidates, ["-nuhelper"]) + with self.assertRaises(UnsafeOptionError): + Git.check_unsafe_options(options=candidates, unsafe_options=["-u"]) _shell_cases = ( # value_in_call, value_from_class, expected_popen_arg From 9b5dcaf85da5946dbf69dcd53f9edba08f760b32 Mon Sep 17 00:00:00 2001 From: Byron Date: Sun, 2 Aug 2026 09:06:40 +0000 Subject: [PATCH 16/22] Guard read-tree index output paths GHSA-4gmw-gg2m-w46p reports that caller-controlled treeish arguments could be parsed by git read-tree as --index-output and select an arbitrary output path. A regression test showed that from_tree reached Git instead of raising UnsafeOptionError; the same unchecked path was reachable through reset and both merge_tree treeish positions. Add the project-standard unsafe-option guard and explicit opt-out to from_tree, merge_tree, and reset. Check positional and keyword candidates so abbreviations and alternate forwarding forms are covered before read-tree runs. A broader audit found only two read-tree sinks in the codebase; both are now guarded, and reset delegates to the guarded from_tree path. The only remaining index-output use is GitPython's controlled temporary index. Git cf5497b14 confirms read-tree parses this path-taking option before tree arguments. Focused index tests and Ruff checks pass. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/index/base.py | 40 +++++++++++++++++++++++++++++++++++++--- test/test_index.py | 16 ++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index dd5c1a905..a12ece7ec 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -134,6 +134,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): """ unsafe_git_checkout_index_options = ["--prefix"] + unsafe_git_read_tree_options = ["--index-output"] __slots__ = ("repo", "version", "entries", "_extension_data", "_file_path") @@ -259,7 +260,12 @@ def write( @post_clear_cache @default_index - def merge_tree(self, rhs: Treeish, base: Union[None, Treeish] = None) -> "IndexFile": + def merge_tree( + self, + rhs: Treeish, + base: Union[None, Treeish] = None, + allow_unsafe_options: bool = False, + ) -> "IndexFile": """Merge the given `rhs` treeish into the current index, possibly taking a common base treeish into account. @@ -273,6 +279,9 @@ def merge_tree(self, rhs: Treeish, base: Union[None, Treeish] = None) -> "IndexF Optional treeish reference pointing to the common base of `rhs` and this index which equals lhs. + :param allow_unsafe_options: + Allow options that may write to arbitrary paths. + :return: self (containing the merge and possibly unmerged entries in case of conflicts) @@ -283,6 +292,12 @@ def merge_tree(self, rhs: Treeish, base: Union[None, Treeish] = None) -> "IndexF yourself, you have to commit the changed index (or make a valid tree from it) and retry with a three-way :meth:`index.from_tree ` call. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([base, rhs]), + unsafe_options=self.unsafe_git_read_tree_options, + ) + # -i : ignore working tree status # --aggressive : handle more merge cases # -m : do an actual merge @@ -327,7 +342,13 @@ def new(cls, repo: "Repo", *tree_sha: Union[str, Tree]) -> "IndexFile": return inst @classmethod - def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile": + def from_tree( + cls, + repo: "Repo", + *treeish: Treeish, + allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> "IndexFile": R"""Merge the given treeish revisions into a new index which is returned. The original index will remain unaltered. @@ -351,6 +372,9 @@ def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile :param kwargs: Additional arguments passed to :manpage:`git-read-tree(1)`. + :param allow_unsafe_options: + Allow options that may write to arbitrary paths. + :return: New :class:`IndexFile` instance. It will point to a temporary index location which does not exist anymore. If you intend to write such a merged Index, @@ -368,6 +392,12 @@ def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile if len(treeish) == 0 or len(treeish) > 3: raise ValueError("Please specify between 1 and 3 treeish, got %i" % len(treeish)) + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates(treeish, kwargs), + unsafe_options=cls.unsafe_git_read_tree_options, + ) + arg_list: List[Union[Treeish, str]] = [] # Ignore that the working tree and index possibly are out of date. if len(treeish) > 1: @@ -1416,6 +1446,7 @@ def reset( working_tree: bool = False, paths: Union[None, Iterable[PathLike]] = None, head: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "IndexFile": """Reset the index to reflect the tree at the given commit. This will not adjust @@ -1447,6 +1478,9 @@ def reset( The paths need to exist at the commit, otherwise an exception will be raised. + :param allow_unsafe_options: + Allow options that may write to arbitrary paths. + :param kwargs: Additional keyword arguments passed to :manpage:`git-reset(1)`. @@ -1463,7 +1497,7 @@ def reset( """ # What we actually want to do is to merge the tree into our existing index, # which is what git-read-tree does. - new_inst = type(self).from_tree(self.repo, commit) + new_inst = type(self).from_tree(self.repo, commit, allow_unsafe_options=allow_unsafe_options) if not paths: self.entries = new_inst.entries else: diff --git a/test/test_index.py b/test/test_index.py index bbb798b05..ce6274ed0 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -327,6 +327,22 @@ def add_bad_blob(): except Exception as ex: assert "index.lock' could not be obtained" not in str(ex) + @with_rw_repo("0.1.6") + def test_read_tree_methods_reject_index_output(self, rw_repo): + output_path = (Path(rw_repo.working_tree_dir) / "alternate-index").as_posix() + unsafe_option = f"--index-output={output_path}" + + with pytest.raises(UnsafeOptionError): + IndexFile.from_tree(rw_repo, unsafe_option) + with pytest.raises(UnsafeOptionError): + IndexFile.from_tree(rw_repo, "HEAD", index_output=output_path) + with pytest.raises(UnsafeOptionError): + rw_repo.index.reset(unsafe_option) + with pytest.raises(UnsafeOptionError): + rw_repo.index.merge_tree(unsafe_option) + with pytest.raises(UnsafeOptionError): + rw_repo.index.merge_tree("HEAD", base=unsafe_option) + @with_rw_repo("0.1.6") def test_index_file_from_tree(self, rw_repo): common_ancestor_sha = "5117c9c8a4d3af19a9958677e45cda9269de1541" From d9ddb55bdc66ffe8c9932fe460e6b8c8211e47c7 Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 4 Aug 2026 02:48:01 +0000 Subject: [PATCH 17/22] Guard unsafe git init options GHSA-9rj7-rf2p-w77r reports that Repo.init forwarded git-init options without applying GitPython's unsafe-option policy. A regression showed template and abbreviated option spellings reached Git without an UnsafeOptionError and could create the destination before validation. Add a git-init denylist for template installation and separate Git directory redirection, check keyword options before any path or directory mutation, and provide the standard explicit allow_unsafe_options escape hatch. This preserves trusted uses while rejecting untrusted forwarding by default. An audit against Git cf5497b14c5a24f10c13f7e0ee85cb95af13ea6a (v2.55.0.windows.3-16-gcf5497b14c) confirmed that init and clone are the built-in commands that consume repository template directories. Clone, clone_from, and submodule cloning already share the guarded clone helper; the similarly named commit option only reads a commit-message template. Validated with the focused init regression, the clone/init unsafe-option suite, 185 config/Git/index/clone tests, Ruff, and basedpyright. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/repo/base.py | 18 ++++++++++++++++++ test/test_repo.py | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/git/repo/base.py b/git/repo/base.py index e4d5e92c3..df61e2d28 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -142,6 +142,14 @@ class Repo: re_author_committer_start = re.compile(r"^(author|committer)") re_tab_full_line = re.compile(r"^\t(.*)$") + unsafe_git_init_options = [ + # Can install hooks that execute during later Git commands: + "--template", + # Redirects the repository metadata to a caller-controlled path: + "--separate-git-dir", + ] + """Options to :manpage:`git-init(1)` that permit unsafe code execution or I/O.""" + unsafe_git_clone_options = [ # Executes arbitrary commands: "--upload-pack", @@ -1398,6 +1406,7 @@ def init( mkdir: bool = True, odbt: Type[GitCmdObjectDB] = GitCmdObjectDB, expand_vars: bool = True, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "Repo": """Initialize a git repository at the given path if specified. @@ -1422,6 +1431,10 @@ def init( information disclosure, allowing attackers to access the contents of environment variables. + :param allow_unsafe_options: + Allow unsafe options to be used, such as ``--template`` and + ``--separate-git-dir``. + :param kwargs: Keyword arguments serving as additional options to the :manpage:`git-init(1)` command. @@ -1429,6 +1442,11 @@ def init( :return: :class:`Repo` (the newly created repo) """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=cls.unsafe_git_init_options, + ) if path: path = expand_path(path, expand_vars) if mkdir and path and not osp.exists(path): diff --git a/test/test_repo.py b/test/test_repo.py index 5c4b416ff..a465c84a3 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -82,6 +82,33 @@ def test_new_should_raise_on_invalid_repo_location(self): with tempfile.TemporaryDirectory() as tdir: self.assertRaises(InvalidGitRepositoryError, Repo, tdir) + def test_init_rejects_unsafe_options(self): + with tempfile.TemporaryDirectory() as tdir: + template_dir = osp.join(tdir, "template") + os.mkdir(template_dir) + unsafe_options = [ + {"template": template_dir}, + {"templa": template_dir}, + {"separate_git_dir": osp.join(tdir, "git-dir")}, + {"separate_git_di": osp.join(tdir, "git-dir")}, + ] + for index, kwargs in enumerate(unsafe_options): + repo_dir = osp.join(tdir, f"repo-{index}") + with self.assertRaises(UnsafeOptionError): + Repo.init(repo_dir, **kwargs) + assert not osp.exists(repo_dir) + + def test_init_allows_explicitly_unsafe_options(self): + with tempfile.TemporaryDirectory() as tdir: + template_dir = osp.join(tdir, "template") + os.mkdir(template_dir) + repo = Repo.init( + osp.join(tdir, "repo"), + template=template_dir, + allow_unsafe_options=True, + ) + assert repo.git_dir + @with_rw_directory def test_new_should_raise_on_invalid_repo_location_within_repo(self, rw_dir): repo_dir = osp.join(rw_dir, "repo") From f2550b65bf60ca087190981e2c7b6865e201f40c Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 4 Aug 2026 03:18:54 +0000 Subject: [PATCH 18/22] Guard pathspec file inputs in high-level commands GHSA-hh9p-6wh2-4mfc reports that high-level rm and checkout wrappers forwarded pathspec file options without GitPython's unsafe-option policy. A regression showed that both commands surfaced multi-line pathspec data in Git errors, while reset consumed the same caller-selected file without a validation error. The audit also found that reset's positional commit could carry the option before its argument separator. Define one shared unsafe pathspec-file option list and apply it to IndexFile.remove, Head.checkout, and HEAD.reset before invoking Git. Check reset's positional commit as well as keyword options, retain the standard allow_unsafe_options escape hatch for trusted callers, and cover abbreviated long-option spellings. An audit against Git cf5497b14c5a24f10c13f7e0ee85cb95af13ea6a (v2.55.0.windows.3-16-gcf5497b14c) found pathspec-file support in add, checkout/restore, commit, reset, rm, and stash. GitPython has no arbitrary high-level option forwarding to the other commands, and git mv does not support this option. Validated with focused rejection and opt-in tests, 214 affected-module regressions, Ruff, basedpyright, and git diff --check. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/cmd.py | 6 ++++++ git/index/base.py | 10 ++++++++++ git/refs/head.py | 27 ++++++++++++++++++++++++++- test/test_git.py | 12 ++++++++++++ test/test_index.py | 26 ++++++++++++++++++++++++++ test/test_refs.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) diff --git a/git/cmd.py b/git/cmd.py index 6e3d07c98..03ecd13f5 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -654,6 +654,12 @@ class Git(metaclass=_GitMeta): "--upload-pack", ] + unsafe_git_pathspec_from_file_options = [ + # Reads pathspecs from a caller-controlled file. Some commands include an + # unmatched pathspec in their error output, which can disclose the file. + "--pathspec-from-file", + ] + def __getstate__(self) -> Dict[str, Any]: return slots_to_dict(self, exclude=self._excluded_) diff --git a/git/index/base.py b/git/index/base.py index a12ece7ec..0e7b5f918 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -1024,6 +1024,7 @@ def remove( self, items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]], working_tree: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> List[str]: R"""Remove the given items from the index and optionally from the working tree @@ -1054,6 +1055,10 @@ def remove( physically removing the respective file. This may fail if there are uncommitted changes in it. + :param allow_unsafe_options: + Allow unsafe options such as ``--pathspec-from-file`` to be passed to + :manpage:`git-rm(1)`. + :param kwargs: Additional keyword arguments to be passed to :manpage:`git-rm(1)`, such as ``r`` to allow recursive removal. @@ -1065,6 +1070,11 @@ def remove( This is interesting to know in case you have provided a directory or globs. Paths are relative to the repository. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) args = [] if not working_tree: args.append("--cached") diff --git a/git/refs/head.py b/git/refs/head.py index 3c43993e7..7f563374e 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -19,6 +19,7 @@ from typing import Any, Sequence, TYPE_CHECKING, Union +from git.cmd import Git from git.types import Commit_ish, PathLike if TYPE_CHECKING: @@ -62,6 +63,7 @@ def reset( index: bool = True, working_tree: bool = False, paths: Union[PathLike, Sequence[PathLike], None] = None, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "HEAD": """Reset our HEAD to the given commit optionally synchronizing the index and @@ -84,12 +86,21 @@ def reset( Single path or list of paths relative to the git root directory that are to be reset. This allows to partially reset individual files. + :param allow_unsafe_options: + Allow unsafe options such as ``--pathspec-from-file`` to be passed to + :manpage:`git-reset(1)`. + :param kwargs: Additional arguments passed to :manpage:`git-reset(1)`. :return: self """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([commit], kwargs), + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) mode: Union[str, None] mode = "--soft" if index: @@ -234,7 +245,12 @@ def rename(self, new_path: PathLike, force: bool = False) -> "Head": self.path = "%s/%s" % (self._common_path_default, new_path) return self - def checkout(self, force: bool = False, **kwargs: Any) -> Union["HEAD", "Head"]: + def checkout( + self, + force: bool = False, + allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> Union["HEAD", "Head"]: """Check out this head by setting the HEAD to this reference, by updating the index to reflect the tree we point to and by updating the working tree to reflect the latest index. @@ -246,6 +262,10 @@ def checkout(self, force: bool = False, **kwargs: Any) -> Union["HEAD", "Head"]: If ``False``, :exc:`~git.exc.GitCommandError` will be raised in that situation. + :param allow_unsafe_options: + Allow unsafe options such as ``--pathspec-from-file`` to be passed to + :manpage:`git-checkout(1)`. + :param kwargs: Additional keyword arguments to be passed to git checkout, e.g. ``b="new_branch"`` to create a new branch at the given spot. @@ -261,6 +281,11 @@ def checkout(self, force: bool = False, **kwargs: Any) -> Union["HEAD", "Head"]: the HEAD detached which is allowed and possible, but remains a special state that some tools might not be able to handle. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) kwargs["f"] = force if kwargs["f"] is False: kwargs.pop("f") diff --git a/test/test_git.py b/test/test_git.py index d3c43b247..a88d980fb 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -215,6 +215,18 @@ def test_option_candidates_ignore_untransformed_kwargs(self): self.assertEqual(options, ["--max-count"]) + def test_option_candidates_include_falsey_non_boolean_values(self): + kwargs = {"pathspec_from_file": 0} + candidates = Git._option_candidates(kwargs=kwargs) + + self.assertEqual(candidates, ["--pathspec-from-file"]) + self.assertEqual(self.git.transform_kwargs(**kwargs), ["--pathspec-from-file=0"]) + with self.assertRaises(UnsafeOptionError): + Git.check_unsafe_options( + options=candidates, + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) + def test_option_candidates_include_split_single_char_option_values(self): cases = [ ({"n": "--upload-pack=helper"}, ["-n", "--upload-pack=helper"], ["--upload-pack"]), diff --git a/test/test_index.py b/test/test_index.py index ce6274ed0..a9dc8ca61 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -212,6 +212,32 @@ def test_checkout_rejects_unsafe_prefix(self, rw_repo): rw_repo.index.checkout(prefix=f"{target}/", allow_unsafe_options=True) self.assertTrue(osp.isfile(osp.join(target, "CHANGES"))) + @with_rw_repo("HEAD") + def test_remove_rejects_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") + for option_name in ("pathspec_from_file", "pathspec_from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.index.remove( + [], + pathspec_file_nul=True, + **{option_name: str(pathspecs)}, + ) + + @with_rw_repo("HEAD") + def test_remove_allows_explicit_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"CHANGES\0") + removed = rw_repo.index.remove( + [], + pathspec_from_file=str(pathspecs), + pathspec_file_nul=True, + allow_unsafe_options=True, + ) + assert "CHANGES" in removed + def __init__(self, *args): super().__init__(*args) self._reset_progress() diff --git a/test/test_refs.py b/test/test_refs.py index 38e15cfa1..e5235be9b 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -262,6 +262,51 @@ def test_head_checkout_detached_head(self, rw_repo): assert isinstance(res, SymbolicReference) assert res.name == "HEAD" + @with_rw_repo("HEAD") + def test_head_checkout_rejects_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") + for option_name in ("pathspec_from_file", "pathspec_from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.active_branch.checkout( + pathspec_file_nul=True, + **{option_name: str(pathspecs)}, + ) + + @with_rw_repo("HEAD") + def test_head_reset_rejects_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") + for option_name in ("pathspec_from_file", "pathspec_from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.head.reset( + pathspec_file_nul=True, + **{option_name: str(pathspecs)}, + ) + for option_name in ("--pathspec-from-file", "--pathspec-from"): + with self.assertRaises(UnsafeOptionError): + rw_repo.head.reset( + f"{option_name}={pathspecs}", + pathspec_file_nul=True, + ) + + @with_rw_repo("HEAD") + def test_head_commands_allow_explicit_pathspec_from_file(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + pathspecs = Path(tdir) / "pathspecs" + pathspecs.write_bytes(b"CHANGES\0") + options = { + "pathspec_from_file": str(pathspecs), + "pathspec_file_nul": True, + "allow_unsafe_options": True, + } + head = rw_repo.head + branch = rw_repo.active_branch + assert head.reset(**options) is head + assert branch.checkout(**options) == branch + @with_rw_repo("0.1.6") def test_head_reset(self, rw_repo): cur_head = rw_repo.head From 30d05e30c0b7f28bed7e10cb17b10b68e0708af9 Mon Sep 17 00:00:00 2001 From: Cyrus Date: Mon, 27 Jul 2026 22:43:47 +0800 Subject: [PATCH 19/22] 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 e3e5da8476d9e184ebc729d2d85ea035e582d467 Mon Sep 17 00:00:00 2001 From: Cyrus Date: Mon, 27 Jul 2026 22:43:47 +0800 Subject: [PATCH 20/22] 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 a465c84a3..0c97041f9 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): @@ -1433,6 +1433,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 e5e27d7fb..cf4299d5a 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 b10e2501438e31cbc1220c4879fc3747c34dccdb Mon Sep 17 00:00:00 2001 From: Cyrus Date: Mon, 27 Jul 2026 22:43:47 +0800 Subject: [PATCH 21/22] 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 a9dc8ca61..469a9dc17 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) @@ -655,7 +642,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 e5235be9b..a87134ab8 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): @@ -780,6 +780,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) @@ -791,10 +792,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") From 30be45d786e95023e23c616fec5cbabca861b44c Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 4 Aug 2026 15:45:20 +0200 Subject: [PATCH 22/22] prepare changelog for upcoming release --- VERSION | 2 +- doc/source/changes.rst | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index cb1a6470a..dfabc766a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.57 +3.1.58 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 2bce3058e..ffddf56a1 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,24 @@ Changelog ========= +3.1.58 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-9rj7-rf2p-w77r +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-4gmw-gg2m-w46p +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-wvpp-8hx9-p66j +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-jm78-9fvv-mhgr +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hmq2-w58f-27jc + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58 + 3.1.57 ====== @@ -14,7 +32,7 @@ If you can, also try and provide feedback on the upcoming v4 branch https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. See the following for all changes. -https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55 +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57 3.1.56 ====== @@ -27,7 +45,7 @@ If you can, also try and provide feedback on the upcoming v4 branch https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. See the following for all changes. -https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55 +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.56 3.1.55 ======