From de6d177aaa8ccd6d82576ab0b3de5074cf7647fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:05:41 +0000 Subject: [PATCH 01/69] Bump actions/checkout from 4 to 5 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/alpine-test.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/cygwin-test.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/pythonpackage.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index ceba11fb8..a9c29117e 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -26,7 +26,7 @@ jobs: adduser runner docker shell: sh -exo pipefail {0} # Run this as root, not the "runner" user. - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2bee952af..9191471c3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index cc9e1edf0..5c42c8583 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -34,7 +34,7 @@ jobs: git config --global core.autocrlf false # Affects the non-Cygwin git. shell: pwsh # Do this outside Cygwin, to affect actions/checkout. - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ceba0dd85..16978f9a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4457a341f..4e5d82a55 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -39,7 +39,7 @@ jobs: shell: bash --noprofile --norc -exo pipefail {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 From fe81519436a8ab8b735a40a3973c8c5bd9cfec47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:42:38 +0000 Subject: [PATCH 02/69] Bump git/ext/gitdb from `335c0f6` to `39d7dbf` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `335c0f6` to `39d7dbf`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/335c0f66173eecdc7b2597c2b6c3d1fde795df30...39d7dbf285df058e44ea501c23ea8d31ae8bce0e) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 39d7dbf285df058e44ea501c23ea8d31ae8bce0e dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 335c0f661..39d7dbf28 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 +Subproject commit 39d7dbf285df058e44ea501c23ea8d31ae8bce0e From ca51dad69071898af377c8e62210c69e8d211c69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:41:33 +0000 Subject: [PATCH 03/69] Bump actions/setup-python from 5 to 6 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/lint.yml | 2 +- .github/workflows/pythonpackage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 16978f9a8..ed535a914 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.x" diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4e5d82a55..7088310e5 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -44,7 +44,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: ${{ matrix.experimental }} From 9f913ec0cb0c6f7ab3eea7245657d01048fd7065 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:00:47 +0000 Subject: [PATCH 04/69] Bump git/ext/gitdb from `39d7dbf` to `f8fdfec` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `39d7dbf` to `f8fdfec`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/39d7dbf285df058e44ea501c23ea8d31ae8bce0e...f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 39d7dbf28..f8fdfec0f 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 39d7dbf285df058e44ea501c23ea8d31ae8bce0e +Subproject commit f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305 From 7c55a2b839e05f10a9dc3cf2bc53785350372c88 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Tue, 30 Sep 2025 17:47:14 +0300 Subject: [PATCH 05/69] Fix type hint for `SymbolicReference.reference` property Signed-off-by: Emmanuel Ferdman --- git/refs/symbolic.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 1b90a3115..74bb1fe0a 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -40,6 +40,7 @@ from git.config import GitConfigParser from git.objects.commit import Actor from git.refs.log import RefLogEntry + from git.refs.reference import Reference from git.repo import Repo @@ -404,7 +405,7 @@ def object(self) -> AnyGitObject: def object(self, object: Union[AnyGitObject, "SymbolicReference", str]) -> "SymbolicReference": return self.set_object(object) - def _get_reference(self) -> "SymbolicReference": + def _get_reference(self) -> "Reference": """ :return: :class:`~git.refs.reference.Reference` object we point to @@ -416,7 +417,7 @@ def _get_reference(self) -> "SymbolicReference": sha, target_ref_path = self._get_ref_info(self.repo, self.path) if target_ref_path is None: raise TypeError("%s is a detached symbolic reference as it points to %r" % (self, sha)) - return self.from_path(self.repo, target_ref_path) + return cast("Reference", self.from_path(self.repo, target_ref_path)) def set_reference( self, @@ -502,7 +503,7 @@ def set_reference( # Aliased reference @property - def reference(self) -> "SymbolicReference": + def reference(self) -> "Reference": return self._get_reference() @reference.setter From bcdcccdc7ea7d50ec5831aad961ba80df0f1379b Mon Sep 17 00:00:00 2001 From: Brunno Vanelli Date: Tue, 7 Oct 2025 20:49:46 +0200 Subject: [PATCH 06/69] feat: Add support for hasconfig git rule. --- git/config.py | 8 ++++++-- test/test_config.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/git/config.py b/git/config.py index 345290a39..ffe1c8ccd 100644 --- a/git/config.py +++ b/git/config.py @@ -66,7 +66,7 @@ CONFIG_LEVELS: ConfigLevels_Tup = ("system", "user", "global", "repository") """The configuration level of a configuration file.""" -CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeIf )\"(gitdir|gitdir/i|onbranch):(.+)\"") +CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeIf )\"(gitdir|gitdir/i|onbranch|hasconfig:remote\.\*\.url):(.+)\"") """Section pattern to detect conditional includes. See: https://git-scm.com/docs/git-config#_conditional_includes @@ -590,7 +590,11 @@ def _included_paths(self) -> List[Tuple[str, str]]: if fnmatch.fnmatchcase(branch_name, value): paths += self.items(section) - + elif keyword == "hasconfig:remote.*.url": + for remote in self._repo.remotes: + if fnmatch.fnmatch(remote.url, value): + paths += self.items(section) + break return paths def read(self) -> None: # type: ignore[override] diff --git a/test/test_config.py b/test/test_config.py index 8e1007d9e..56ac0f304 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -373,6 +373,41 @@ def test_conditional_includes_from_branch_name_error(self, rw_dir): assert not config._has_includes() assert config._included_paths() == [] + @with_rw_directory + def test_conditional_includes_remote_url(self, rw_dir): + # Initiate mocked repository. + repo = mock.Mock() + repo.remotes = [mock.Mock(url="https://github.com/foo/repo")] + + # Initiate config files. + path1 = osp.join(rw_dir, "config1") + path2 = osp.join(rw_dir, "config2") + template = '[includeIf "hasconfig:remote.*.url:{}"]\n path={}\n' + + # Ensure that config with hasconfig and full url is correct. + with open(path1, "w") as stream: + stream.write(template.format("https://github.com/foo/repo", path2)) + + with GitConfigParser(path1, repo=repo) as config: + assert config._has_includes() + assert config._included_paths() == [("path", path2)] + + # Ensure that config with hasconfig and incorrect url is incorrect. + with open(path1, "w") as stream: + stream.write(template.format("incorrect", path2)) + + with GitConfigParser(path1, repo=repo) as config: + assert not config._has_includes() + assert config._included_paths() == [] + + # Ensure that config with hasconfig and url using glob pattern is correct. + with open(path1, "w") as stream: + stream.write(template.format("**/**github.com*/**", path2)) + + with GitConfigParser(path1, repo=repo) as config: + assert config._has_includes() + assert config._included_paths() == [("path", path2)] + def test_rename(self): file_obj = self._to_memcache(fixture_path("git_config")) with GitConfigParser(file_obj, read_only=False, merge_includes=False) as cw: From 6cf863374820a1bcf1fa14b3c2ea87214752bf74 Mon Sep 17 00:00:00 2001 From: Brunno Vanelli Date: Tue, 7 Oct 2025 21:19:31 +0200 Subject: [PATCH 07/69] fix: Use fnmatch instead. --- git/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/config.py b/git/config.py index ffe1c8ccd..200c81bb7 100644 --- a/git/config.py +++ b/git/config.py @@ -592,7 +592,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: paths += self.items(section) elif keyword == "hasconfig:remote.*.url": for remote in self._repo.remotes: - if fnmatch.fnmatch(remote.url, value): + if fnmatch.fnmatchcase(remote.url, value): paths += self.items(section) break return paths From a6247a585600c09894a9fae85e11f7581bfccbe0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:32:58 +0000 Subject: [PATCH 08/69] Bump github/codeql-action from 3 to 4 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9191471c3..32d5e84e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -85,6 +85,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" From 9dd0081213d57f41919ed37e93656410277bfb0b Mon Sep 17 00:00:00 2001 From: Andreas Oberritter Date: Tue, 21 Oct 2025 11:11:16 +0200 Subject: [PATCH 09/69] Use actual return type in annotation for method submodule_update Fixes #2077 --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index 7e918df8c..6ea96aad2 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -520,7 +520,7 @@ def iter_submodules(self, *args: Any, **kwargs: Any) -> Iterator[Submodule]: """ return RootModule(self).traverse(*args, **kwargs) - def submodule_update(self, *args: Any, **kwargs: Any) -> Iterator[Submodule]: + def submodule_update(self, *args: Any, **kwargs: Any) -> RootModule: """Update the submodules, keeping the repository consistent as it will take the previous state into consideration. From 74ff8e5e1cb814fbf3b916111d7181bd6e3f3906 Mon Sep 17 00:00:00 2001 From: Yikai Zhao Date: Sun, 2 Nov 2025 10:25:33 +0800 Subject: [PATCH 10/69] Support index format v3 --- git/index/fun.py | 16 +++++++++++----- git/index/typ.py | 15 ++++++++++++++- test/test_index.py | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index d03ec6759..0b3d79cf1 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -36,7 +36,7 @@ ) from git.util import IndexFileSHA1Writer, finalize_process -from .typ import BaseIndexEntry, IndexEntry, CE_NAMEMASK, CE_STAGESHIFT +from .typ import CE_EXTENDED, BaseIndexEntry, IndexEntry, CE_NAMEMASK, CE_STAGESHIFT from .util import pack, unpack # typing ----------------------------------------------------------------------------- @@ -158,7 +158,7 @@ def write_cache( write = stream_sha.write # Header - version = 2 + version = 3 if any(entry.extended_flags for entry in entries) else 2 write(b"DIRC") write(pack(">LL", version, len(entries))) @@ -172,6 +172,8 @@ def write_cache( plen = len(path) & CE_NAMEMASK # Path length assert plen == len(path), "Path %s too long to fit into index" % entry.path flags = plen | (entry.flags & CE_NAMEMASK_INV) # Clear possible previous values. + if entry.extended_flags: + flags |= CE_EXTENDED write( pack( ">LLLLLL20sH", @@ -185,6 +187,8 @@ def write_cache( flags, ) ) + if entry.extended_flags: + write(pack(">H", entry.extended_flags)) write(path) real_size = (tell() - beginoffset + 8) & ~7 write(b"\0" * ((beginoffset + real_size) - tell())) @@ -206,8 +210,7 @@ def read_header(stream: IO[bytes]) -> Tuple[int, int]: unpacked = cast(Tuple[int, int], unpack(">LL", stream.read(4 * 2))) version, num_entries = unpacked - # TODO: Handle version 3: extended data, see read-cache.c. - assert version in (1, 2), "Unsupported git index version %i, only 1 and 2 are supported" % version + assert version in (1, 2, 3), "Unsupported git index version %i, only 1, 2, and 3 are supported" % version return version, num_entries @@ -260,12 +263,15 @@ def read_cache( ctime = unpack(">8s", read(8))[0] mtime = unpack(">8s", read(8))[0] (dev, ino, mode, uid, gid, size, sha, flags) = unpack(">LLLLLL20sH", read(20 + 4 * 6 + 2)) + extended_flags = 0 + if flags & CE_EXTENDED: + extended_flags = unpack(">H", read(2))[0] path_size = flags & CE_NAMEMASK path = read(path_size).decode(defenc) real_size = (tell() - beginoffset + 8) & ~7 read((beginoffset + real_size) - tell()) - entry = IndexEntry((mode, sha, flags, path, ctime, mtime, dev, ino, uid, gid, size)) + entry = IndexEntry((mode, sha, flags, path, ctime, mtime, dev, ino, uid, gid, size, extended_flags)) # entry_key would be the method to use, but we save the effort. entries[(path, entry.stage)] = entry count += 1 diff --git a/git/index/typ.py b/git/index/typ.py index 974252528..4bcb604ab 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -32,6 +32,9 @@ CE_VALID = 0x8000 CE_STAGESHIFT = 12 +CE_EXT_SKIP_WORKTREE = 0x4000 +CE_EXT_INTENT_TO_ADD = 0x2000 + # } END invariants @@ -87,6 +90,8 @@ class BaseIndexEntryHelper(NamedTuple): uid: int = 0 gid: int = 0 size: int = 0 + # version 3 extended flags, only when (flags & CE_EXTENDED) is set + extended_flags: int = 0 class BaseIndexEntry(BaseIndexEntryHelper): @@ -102,7 +107,7 @@ def __new__( cls, inp_tuple: Union[ Tuple[int, bytes, int, PathLike], - Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int], + Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int, int], ], ) -> "BaseIndexEntry": """Override ``__new__`` to allow construction from a tuple for backwards @@ -134,6 +139,14 @@ def stage(self) -> int: """ return (self.flags & CE_STAGEMASK) >> CE_STAGESHIFT + @property + def skip_worktree(self) -> bool: + return (self.extended_flags & CE_EXT_SKIP_WORKTREE) > 0 + + @property + def intent_to_add(self) -> bool: + return (self.extended_flags & CE_EXT_INTENT_TO_ADD) > 0 + @classmethod def from_blob(cls, blob: Blob, stage: int = 0) -> "BaseIndexEntry": """:return: Fully equipped BaseIndexEntry at the given stage""" diff --git a/test/test_index.py b/test/test_index.py index cf3b90fa6..6d90d7965 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1218,6 +1218,31 @@ def test_index_add_non_normalized_path(self, rw_repo): rw_repo.index.add(non_normalized_path) + @with_rw_directory + def test_index_version_v3(self, tmp_dir): + tmp_dir = Path(tmp_dir) + with cwd(tmp_dir): + subprocess.run(["git", "init", "-q"], check=True) + file = tmp_dir / "file.txt" + file.write_text("hello") + subprocess.run(["git", "add", "-N", "file.txt"], check=True) + + repo = Repo(tmp_dir) + + assert len(repo.index.entries) == 1 + entry = list(repo.index.entries.values())[0] + assert entry.path == "file.txt" + assert entry.intent_to_add + + file2 = tmp_dir / "file2.txt" + file2.write_text("world") + repo.index.add(["file2.txt"]) + repo.index.write() + + status_str = subprocess.check_output(["git", "status", "--porcelain"], text=True) + assert " A file.txt\n" in status_str + assert "A file2.txt\n" in status_str + class TestIndexUtils: @pytest.mark.parametrize("file_path_type", [str, Path]) From 3150ebdaa43df5be2c27e717807381724131b128 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:08:06 +0000 Subject: [PATCH 11/69] Bump git/ext/gitdb from `f8fdfec` to `65321a2` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `f8fdfec` to `65321a2`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305...65321a28b586df60b9d1508228e2f53a35f938eb) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 65321a28b586df60b9d1508228e2f53a35f938eb dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index f8fdfec0f..65321a28b 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit f8fdfec0fd0a0aed9171c6cf2c5cb8d73e2bb305 +Subproject commit 65321a28b586df60b9d1508228e2f53a35f938eb From 8a884fea3ff91f1444a36785cc22c8d7fc6bf329 Mon Sep 17 00:00:00 2001 From: Yikai Zhao Date: Sat, 8 Nov 2025 14:01:41 +0800 Subject: [PATCH 12/69] improve unit test --- test/fixtures/index_extended_flags | Bin 0 -> 436 bytes test/test_index.py | 37 +++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/index_extended_flags diff --git a/test/fixtures/index_extended_flags b/test/fixtures/index_extended_flags new file mode 100644 index 0000000000000000000000000000000000000000..f03713b684711d4a5aedab0bafd6b254137c9e5d GIT binary patch literal 436 zcmZ?q402{*U|l!>^Ob+tC2sPz z@}frL4{5V^!oJTI8&x~7IWT1AWtQlbFff4htEWE|gV7LkPHLi=!|-hGquHA-UUT;D z)?8N}b>q;Jp5TcNoDK}drAhjUDJiKbK+8Y?WR7Zr=1~|8b=Nnd%;P~auOvSoVcry8 zhad+>tlD0`4JmGI|5U?^ V?tRm?Z1&1ue!#pbGJMwrUjTmQph*A# literal 0 HcmV?d00001 diff --git a/test/test_index.py b/test/test_index.py index 6d90d7965..bb05d3108 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1218,30 +1218,47 @@ def test_index_add_non_normalized_path(self, rw_repo): rw_repo.index.add(non_normalized_path) + def test_index_file_v3(self): + index = IndexFile(self.rorepo, fixture_path("index_extended_flags")) + assert index.entries + assert index.version == 3 + assert len(index.entries) == 4 + assert index.entries[('init.t', 0)].skip_worktree + + # Write the data - it must match the original. + with tempfile.NamedTemporaryFile() as tmpfile: + index.write(tmpfile.name) + assert Path(tmpfile.name).read_bytes() == Path(fixture_path("index_extended_flags")).read_bytes() + @with_rw_directory - def test_index_version_v3(self, tmp_dir): + def test_index_file_v3_with_git_command(self, tmp_dir): tmp_dir = Path(tmp_dir) with cwd(tmp_dir): - subprocess.run(["git", "init", "-q"], check=True) + git = Git(tmp_dir) + git.init() + file = tmp_dir / "file.txt" file.write_text("hello") - subprocess.run(["git", "add", "-N", "file.txt"], check=True) + git.add("--intent-to-add", "file.txt") # intent-to-add sets extended flag repo = Repo(tmp_dir) + index = repo.index - assert len(repo.index.entries) == 1 - entry = list(repo.index.entries.values())[0] + assert len(index.entries) == 1 + assert index.version == 3 + entry = list(index.entries.values())[0] assert entry.path == "file.txt" assert entry.intent_to_add file2 = tmp_dir / "file2.txt" file2.write_text("world") - repo.index.add(["file2.txt"]) - repo.index.write() + index.add(["file2.txt"]) + index.write() - status_str = subprocess.check_output(["git", "status", "--porcelain"], text=True) - assert " A file.txt\n" in status_str - assert "A file2.txt\n" in status_str + status_str = git.status(porcelain=True) + status_lines = status_str.splitlines() + assert " A file.txt" in status_lines + assert "A file2.txt" in status_lines class TestIndexUtils: From 107b1b44e91a19ebbe5e41cd7312ce7838534732 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 9 Nov 2025 08:41:10 +0100 Subject: [PATCH 13/69] make linter happy --- test/test_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_index.py b/test/test_index.py index bb05d3108..711b43a0b 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1223,7 +1223,7 @@ def test_index_file_v3(self): assert index.entries assert index.version == 3 assert len(index.entries) == 4 - assert index.entries[('init.t', 0)].skip_worktree + assert index.entries[("init.t", 0)].skip_worktree # Write the data - it must match the original. with tempfile.NamedTemporaryFile() as tmpfile: From 98e860d1c3a0855e2ff29bf24c5adaca7a57366f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:52:55 +0000 Subject: [PATCH 14/69] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/alpine-test.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/cygwin-test.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/pythonpackage.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml index a9c29117e..b7de7482e 100644 --- a/.github/workflows/alpine-test.yml +++ b/.github/workflows/alpine-test.yml @@ -26,7 +26,7 @@ jobs: adduser runner docker shell: sh -exo pipefail {0} # Run this as root, not the "runner" user. - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 32d5e84e4..e243416a8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index 5c42c8583..327e1f10c 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -34,7 +34,7 @@ jobs: git config --global core.autocrlf false # Affects the non-Cygwin git. shell: pwsh # Do this outside Cygwin, to affect actions/checkout. - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ed535a914..956b38963 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 7088310e5..975c2e29d 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -39,7 +39,7 @@ jobs: shell: bash --noprofile --norc -exo pipefail {0} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 From e3f38ffe1c3e37ec5a8f18d60961ddd0cd0f62f4 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:32:48 +0000 Subject: [PATCH 15/69] Move clone tests into dedicated file --- test/test_clone.py | 294 ++++++++++++++++++++++++++++++++++++++++++++- test/test_repo.py | 283 +------------------------------------------ 2 files changed, 294 insertions(+), 283 deletions(-) diff --git a/test/test_clone.py b/test/test_clone.py index 126ef0063..91b7d7621 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,12 +1,23 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ +import os +import os.path as osp +import pathlib +import sys +import tempfile +from unittest import skip + +from git import GitCommandError, Repo +from git.exc import UnsafeOptionError, UnsafeProtocolError + +from test.lib import TestBase, with_rw_directory, with_rw_repo + from pathlib import Path import re import git - -from test.lib import TestBase, with_rw_directory +import pytest class TestClone(TestBase): @@ -29,3 +40,282 @@ def test_checkout_in_non_empty_dir(self, rw_dir): ) else: self.fail("GitCommandError not raised") + + @with_rw_directory + def test_clone_from_pathlib(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + + Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib") + + @with_rw_directory + def test_clone_from_pathlib_withConfig(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + + cloned = Repo.clone_from( + original_repo.git_dir, + pathlib.Path(rw_dir) / "clone_pathlib_withConfig", + multi_options=[ + "--recurse-submodules=repo", + "--config core.filemode=false", + "--config submodule.repo.update=checkout", + "--config filter.lfs.clean='git-lfs clean -- %f'", + ], + allow_unsafe_options=True, + ) + + self.assertEqual(cloned.config_reader().get_value("submodule", "active"), "repo") + self.assertEqual(cloned.config_reader().get_value("core", "filemode"), False) + self.assertEqual(cloned.config_reader().get_value('submodule "repo"', "update"), "checkout") + self.assertEqual( + cloned.config_reader().get_value('filter "lfs"', "clean"), + "git-lfs clean -- %f", + ) + + def test_clone_from_with_path_contains_unicode(self): + with tempfile.TemporaryDirectory() as tmpdir: + unicode_dir_name = "\u0394" + path_with_unicode = os.path.join(tmpdir, unicode_dir_name) + os.makedirs(path_with_unicode) + + try: + Repo.clone_from( + url=self._small_repo_url(), + to_path=path_with_unicode, + ) + except UnicodeEncodeError: + self.fail("Raised UnicodeEncodeError") + + @with_rw_directory + @skip( + """The referenced repository was removed, and one needs to set up a new + password controlled repo under the org's control.""" + ) + def test_leaking_password_in_clone_logs(self, rw_dir): + password = "fakepassword1234" + try: + Repo.clone_from( + url="https://fakeuser:{}@fakerepo.example.com/testrepo".format(password), + to_path=rw_dir, + ) + except GitCommandError as err: + assert password not in str(err), "The error message '%s' should not contain the password" % err + # Working example from a blank private project. + Repo.clone_from( + url="https://gitlab+deploy-token-392045:mLWhVus7bjLsy8xj8q2V@gitlab.com/mercierm/test_git_python", + to_path=rw_dir, + ) + + @with_rw_repo("HEAD") + def test_clone_unsafe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() + + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + rw_repo.clone(tmp_dir, **unsafe_option) + assert not tmp_file.exists() + + @pytest.mark.xfail( + sys.platform == "win32", + reason=( + "File not created. A separate Windows command may be needed. This and the " + "currently passing test test_clone_unsafe_options must be adjusted in the " + "same way. Until then, test_clone_unsafe_options is unreliable on Windows." + ), + raises=AssertionError, + ) + @with_rw_repo("HEAD") + def test_clone_unsafe_options_allowed(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not tmp_file.exists() + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert tmp_file.exists() + tmp_file.unlink() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() + rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) + assert destination.exists() + + @with_rw_repo("HEAD") + def test_clone_safe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + rw_repo.clone(destination, multi_options=[option]) + assert destination.exists() + + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() + + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) + assert not tmp_file.exists() + + @pytest.mark.xfail( + sys.platform == "win32", + reason=( + "File not created. A separate Windows command may be needed. This and the " + "currently passing test test_clone_from_unsafe_options must be adjusted in the " + "same way. Until then, test_clone_from_unsafe_options is unreliable on Windows." + ), + raises=AssertionError, + ) + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options_allowed(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not tmp_file.exists() + # The options will be allowed, but the command will fail. + with self.assertRaises(GitCommandError): + Repo.clone_from( + rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True + ) + assert tmp_file.exists() + tmp_file.unlink() + + unsafe_options = [ + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + ] + for i, unsafe_option in enumerate(unsafe_options): + destination = tmp_dir / str(i) + assert not destination.exists() + Repo.clone_from( + rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True + ) + assert destination.exists() + + @with_rw_repo("HEAD") + def test_clone_from_safe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + options = [ + "--depth=1", + "--single-branch", + "-q", + ] + for option in options: + destination = tmp_dir / option + assert not destination.exists() + Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) + assert destination.exists() + + def test_clone_from_unsafe_protocol(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::17/foo", + ] + for url in urls: + with self.assertRaises(UnsafeProtocolError): + Repo.clone_from(url, tmp_dir / "repo") + assert not tmp_file.exists() + + def test_clone_from_unsafe_protocol_allowed(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + "fd::/foo", + ] + for url in urls: + # The URL will be allowed into the command, but the command will + # fail since we don't have that protocol enabled in the Git config file. + with self.assertRaises(GitCommandError): + Repo.clone_from(url, tmp_dir / "repo", allow_unsafe_protocols=True) + assert not tmp_file.exists() + + def test_clone_from_unsafe_protocol_allowed_and_enabled(self): + with tempfile.TemporaryDirectory() as tdir: + tmp_dir = pathlib.Path(tdir) + tmp_file = tmp_dir / "pwn" + urls = [ + f"ext::sh -c touch% {tmp_file}", + ] + allow_ext = [ + "--config=protocol.ext.allow=always", + ] + for url in urls: + # The URL will be allowed into the command, and the protocol is enabled, + # but the command will fail since it can't read from the remote repo. + assert not tmp_file.exists() + with self.assertRaises(GitCommandError): + Repo.clone_from( + url, + tmp_dir / "repo", + multi_options=allow_ext, + allow_unsafe_protocols=True, + allow_unsafe_options=True, + ) + assert tmp_file.exists() + tmp_file.unlink() diff --git a/test/test_repo.py b/test/test_repo.py index bfa1bbb78..dc2cfe7b1 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -14,7 +14,7 @@ import pickle import sys import tempfile -from unittest import mock, skip +from unittest import mock import pytest @@ -36,7 +36,7 @@ Submodule, Tree, ) -from git.exc import BadObject, UnsafeOptionError, UnsafeProtocolError +from git.exc import BadObject from git.repo.fun import touch from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree @@ -214,285 +214,6 @@ def test_date_format(self, rw_dir): # @-timestamp is the format used by git commit hooks. repo.index.commit("Commit messages", commit_date="@1400000000 +0000") - @with_rw_directory - def test_clone_from_pathlib(self, rw_dir): - original_repo = Repo.init(osp.join(rw_dir, "repo")) - - Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib") - - @with_rw_directory - def test_clone_from_pathlib_withConfig(self, rw_dir): - original_repo = Repo.init(osp.join(rw_dir, "repo")) - - cloned = Repo.clone_from( - original_repo.git_dir, - pathlib.Path(rw_dir) / "clone_pathlib_withConfig", - multi_options=[ - "--recurse-submodules=repo", - "--config core.filemode=false", - "--config submodule.repo.update=checkout", - "--config filter.lfs.clean='git-lfs clean -- %f'", - ], - allow_unsafe_options=True, - ) - - self.assertEqual(cloned.config_reader().get_value("submodule", "active"), "repo") - self.assertEqual(cloned.config_reader().get_value("core", "filemode"), False) - self.assertEqual(cloned.config_reader().get_value('submodule "repo"', "update"), "checkout") - self.assertEqual( - cloned.config_reader().get_value('filter "lfs"', "clean"), - "git-lfs clean -- %f", - ) - - def test_clone_from_with_path_contains_unicode(self): - with tempfile.TemporaryDirectory() as tmpdir: - unicode_dir_name = "\u0394" - path_with_unicode = os.path.join(tmpdir, unicode_dir_name) - os.makedirs(path_with_unicode) - - try: - Repo.clone_from( - url=self._small_repo_url(), - to_path=path_with_unicode, - ) - except UnicodeEncodeError: - self.fail("Raised UnicodeEncodeError") - - @with_rw_directory - @skip( - """The referenced repository was removed, and one needs to set up a new - password controlled repo under the org's control.""" - ) - def test_leaking_password_in_clone_logs(self, rw_dir): - password = "fakepassword1234" - try: - Repo.clone_from( - url="https://fakeuser:{}@fakerepo.example.com/testrepo".format(password), - to_path=rw_dir, - ) - except GitCommandError as err: - assert password not in str(err), "The error message '%s' should not contain the password" % err - # Working example from a blank private project. - Repo.clone_from( - url="https://gitlab+deploy-token-392045:mLWhVus7bjLsy8xj8q2V@gitlab.com/mercierm/test_git_python", - to_path=rw_dir, - ) - - @with_rw_repo("HEAD") - def test_clone_unsafe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) - assert not tmp_file.exists() - - unsafe_options = [ - {"upload-pack": f"touch {tmp_file}"}, - {"u": f"touch {tmp_file}"}, - {"config": "protocol.ext.allow=always"}, - {"c": "protocol.ext.allow=always"}, - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - rw_repo.clone(tmp_dir, **unsafe_option) - assert not tmp_file.exists() - - @pytest.mark.xfail( - sys.platform == "win32", - reason=( - "File not created. A separate Windows command may be needed. This and the " - "currently passing test test_clone_unsafe_options must be adjusted in the " - "same way. Until then, test_clone_unsafe_options is unreliable on Windows." - ), - raises=AssertionError, - ) - @with_rw_repo("HEAD") - def test_clone_unsafe_options_allowed(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not tmp_file.exists() - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): - rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) - assert tmp_file.exists() - tmp_file.unlink() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not destination.exists() - rw_repo.clone(destination, multi_options=[unsafe_option], allow_unsafe_options=True) - assert destination.exists() - - @with_rw_repo("HEAD") - def test_clone_safe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - options = [ - "--depth=1", - "--single-branch", - "-q", - ] - for option in options: - destination = tmp_dir / option - assert not destination.exists() - rw_repo.clone(destination, multi_options=[option]) - assert destination.exists() - - @with_rw_repo("HEAD") - def test_clone_from_unsafe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) - assert not tmp_file.exists() - - unsafe_options = [ - {"upload-pack": f"touch {tmp_file}"}, - {"u": f"touch {tmp_file}"}, - {"config": "protocol.ext.allow=always"}, - {"c": "protocol.ext.allow=always"}, - ] - for unsafe_option in unsafe_options: - with self.assertRaises(UnsafeOptionError): - Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) - assert not tmp_file.exists() - - @pytest.mark.xfail( - sys.platform == "win32", - reason=( - "File not created. A separate Windows command may be needed. This and the " - "currently passing test test_clone_from_unsafe_options must be adjusted in the " - "same way. Until then, test_clone_from_unsafe_options is unreliable on Windows." - ), - raises=AssertionError, - ) - @with_rw_repo("HEAD") - def test_clone_from_unsafe_options_allowed(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - unsafe_options = [ - f"--upload-pack='touch {tmp_file}'", - f"-u 'touch {tmp_file}'", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not tmp_file.exists() - # The options will be allowed, but the command will fail. - with self.assertRaises(GitCommandError): - Repo.clone_from( - rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True - ) - assert tmp_file.exists() - tmp_file.unlink() - - unsafe_options = [ - "--config=protocol.ext.allow=always", - "-c protocol.ext.allow=always", - ] - for i, unsafe_option in enumerate(unsafe_options): - destination = tmp_dir / str(i) - assert not destination.exists() - Repo.clone_from( - rw_repo.working_dir, destination, multi_options=[unsafe_option], allow_unsafe_options=True - ) - assert destination.exists() - - @with_rw_repo("HEAD") - def test_clone_from_safe_options(self, rw_repo): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - options = [ - "--depth=1", - "--single-branch", - "-q", - ] - for option in options: - destination = tmp_dir / option - assert not destination.exists() - Repo.clone_from(rw_repo.common_dir, destination, multi_options=[option]) - assert destination.exists() - - def test_clone_from_unsafe_protocol(self): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::17/foo", - ] - for url in urls: - with self.assertRaises(UnsafeProtocolError): - Repo.clone_from(url, tmp_dir / "repo") - assert not tmp_file.exists() - - def test_clone_from_unsafe_protocol_allowed(self): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - "fd::/foo", - ] - for url in urls: - # The URL will be allowed into the command, but the command will - # fail since we don't have that protocol enabled in the Git config file. - with self.assertRaises(GitCommandError): - Repo.clone_from(url, tmp_dir / "repo", allow_unsafe_protocols=True) - assert not tmp_file.exists() - - def test_clone_from_unsafe_protocol_allowed_and_enabled(self): - with tempfile.TemporaryDirectory() as tdir: - tmp_dir = pathlib.Path(tdir) - tmp_file = tmp_dir / "pwn" - urls = [ - f"ext::sh -c touch% {tmp_file}", - ] - allow_ext = [ - "--config=protocol.ext.allow=always", - ] - for url in urls: - # The URL will be allowed into the command, and the protocol is enabled, - # but the command will fail since it can't read from the remote repo. - assert not tmp_file.exists() - with self.assertRaises(GitCommandError): - Repo.clone_from( - url, - tmp_dir / "repo", - multi_options=allow_ext, - allow_unsafe_protocols=True, - allow_unsafe_options=True, - ) - assert tmp_file.exists() - tmp_file.unlink() - @with_rw_repo("HEAD") def test_max_chunk_size(self, repo): class TestOutputStream(TestBase): From 24abf10dc2913f9c1674c6d60dd70c0ec775a6d4 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:39:15 +0000 Subject: [PATCH 16/69] Allow Pathlike urls and destinations when cloning --- git/repo/base.py | 6 ++++-- test/test_clone.py | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 6ea96aad2..fbed6e471 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1362,8 +1362,10 @@ def _clone( odbt = kwargs.pop("odbt", odb_default_type) # When pathlib.Path or other class-based path is passed + if not isinstance(url, str): + url = url.__fspath__() if not isinstance(path, str): - path = str(path) + path = path.__fspath__() ## A bug win cygwin's Git, when `--bare` or `--separate-git-dir` # it prepends the cwd or(?) the `url` into the `path, so:: @@ -1380,7 +1382,7 @@ def _clone( multi = shlex.split(" ".join(multi_options)) if not allow_unsafe_protocols: - Git.check_unsafe_protocols(str(url)) + Git.check_unsafe_protocols(url) if not allow_unsafe_options: Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) if not allow_unsafe_options and multi_options: diff --git a/test/test_clone.py b/test/test_clone.py index 91b7d7621..489931458 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,6 +1,7 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ +from dataclasses import dataclass import os import os.path as osp import pathlib @@ -45,7 +46,20 @@ def test_checkout_in_non_empty_dir(self, rw_dir): def test_clone_from_pathlib(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) - Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib") + Repo.clone_from(pathlib.Path(original_repo.git_dir), pathlib.Path(rw_dir) / "clone_pathlib") + + @with_rw_directory + def test_clone_from_pathlike(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo")) + + @dataclass + class PathLikeMock: + path: str + + def __fspath__(self) -> str: + return self.path + + Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) @with_rw_directory def test_clone_from_pathlib_withConfig(self, rw_dir): From ad1ae5fea338d2a716506f46532932dc458f791a Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:41:07 +0000 Subject: [PATCH 17/69] Simplify logic with direct path conversion --- git/index/typ.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git/index/typ.py b/git/index/typ.py index 4bcb604ab..5fb1b9abc 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -58,9 +58,9 @@ def __init__(self, paths: Sequence[PathLike]) -> None: def __call__(self, stage_blob: Tuple[StageType, Blob]) -> bool: blob_pathlike: PathLike = stage_blob[1].path - blob_path: Path = blob_pathlike if isinstance(blob_pathlike, Path) else Path(blob_pathlike) + blob_path = Path(blob_pathlike) for pathlike in self.paths: - path: Path = pathlike if isinstance(pathlike, Path) else Path(pathlike) + path = Path(pathlike) # TODO: Change to use `PosixPath.is_relative_to` once Python 3.8 is no # longer supported. filter_parts = path.parts From 5d26325f59880864863b5e56a08aa0f83b623f2d Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 17:46:13 +0000 Subject: [PATCH 18/69] Allow Pathlike paths when creating a git repo --- git/repo/base.py | 2 +- test/test_repo.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index fbed6e471..b1b95ce42 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -223,7 +223,7 @@ def __init__( epath = epath or path or os.getcwd() if not isinstance(epath, str): - epath = str(epath) + epath = epath.__fspath__() if expand_vars and re.search(self.re_envvars, epath): warnings.warn( "The use of environment variables in paths is deprecated" diff --git a/test/test_repo.py b/test/test_repo.py index dc2cfe7b1..cd22430a7 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -3,6 +3,7 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ +from dataclasses import dataclass import gc import glob import io @@ -105,6 +106,18 @@ def test_repo_creation_pathlib(self, rw_repo): r_from_gitdir = Repo(pathlib.Path(rw_repo.git_dir)) self.assertEqual(r_from_gitdir.git_dir, rw_repo.git_dir) + @with_rw_repo("0.3.2.1") + def test_repo_creation_pathlike(self, rw_repo): + @dataclass + class PathLikeMock: + path: str + + def __fspath__(self) -> str: + return self.path + + r_from_gitdir = Repo(PathLikeMock(rw_repo.git_dir)) + self.assertEqual(r_from_gitdir.git_dir, rw_repo.git_dir) + def test_description(self): txt = "Test repository" self.rorepo.description = txt From 59c3c8065a402c4cd8a71625f51b6792fdc04863 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 27 Nov 2025 19:20:07 +0000 Subject: [PATCH 19/69] Fix missing path conversion --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index b1b95ce42..be50300b5 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -219,7 +219,7 @@ def __init__( # Given how the tests are written, this seems more likely to catch Cygwin # git used from Windows than Windows git used from Cygwin. Therefore # changing to Cygwin-style paths is the relevant operation. - epath = cygpath(str(epath)) + epath = cygpath(epath if isinstance(epath, str) else epath.__fspath__()) epath = epath or path or os.getcwd() if not isinstance(epath, str): From 91d4cc5ea05df04c82fcfd3e35a6af2e903cc554 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 08:57:54 +0000 Subject: [PATCH 20/69] Use os.fspath instead of __fspath__ for reading paths --- git/config.py | 2 +- git/repo/base.py | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/git/config.py b/git/config.py index 200c81bb7..e3081401d 100644 --- a/git/config.py +++ b/git/config.py @@ -634,7 +634,7 @@ def read(self) -> None: # type: ignore[override] self._read(file_path, file_path.name) else: # Assume a path if it is not a file-object. - file_path = cast(PathLike, file_path) + file_path = os.fspath(file_path) try: with open(file_path, "rb") as fp: file_ok = True diff --git a/git/repo/base.py b/git/repo/base.py index be50300b5..2d87a06b7 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -219,11 +219,9 @@ def __init__( # Given how the tests are written, this seems more likely to catch Cygwin # git used from Windows than Windows git used from Cygwin. Therefore # changing to Cygwin-style paths is the relevant operation. - epath = cygpath(epath if isinstance(epath, str) else epath.__fspath__()) + epath = cygpath(os.fspath(epath)) - epath = epath or path or os.getcwd() - if not isinstance(epath, str): - epath = epath.__fspath__() + epath = os.fspath(epath) if expand_vars and re.search(self.re_envvars, epath): warnings.warn( "The use of environment variables in paths is deprecated" @@ -1361,11 +1359,8 @@ def _clone( ) -> "Repo": odbt = kwargs.pop("odbt", odb_default_type) - # When pathlib.Path or other class-based path is passed - if not isinstance(url, str): - url = url.__fspath__() - if not isinstance(path, str): - path = path.__fspath__() + url = os.fspath(url) + path = os.fspath(path) ## A bug win cygwin's Git, when `--bare` or `--separate-git-dir` # it prepends the cwd or(?) the `url` into the `path, so:: From 497ca401fe094fcae11410a46518e8f56d7bd665 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:04:19 +0000 Subject: [PATCH 21/69] Pin mypy==1.18.2 --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 75e9e81fa..460597539 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ coverage[toml] ddt >= 1.1.1, != 1.4.3 mock ; python_version < "3.8" -mypy +mypy==1.18.2 # pin mypy to avoid new errors pre-commit pytest >= 7.3.1 pytest-cov From 50762f112fef28230deea55c2d0ca344c6c6cb2c Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:08:42 +0000 Subject: [PATCH 22/69] Fail remote pipeline when mypy fails --- .github/workflows/pythonpackage.yml | 1 - pyproject.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 975c2e29d..4666f3480 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -103,7 +103,6 @@ jobs: PYTHON_VERSION: ${{ matrix.python-version }} # With new versions of mypy new issues might arise. This is a problem if there is # nobody able to fix them, so we have to ignore errors until that changes. - continue-on-error: true - name: Test with pytest run: | diff --git a/pyproject.toml b/pyproject.toml index 58ed81f17..149f2dc92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ testpaths = "test" # Space separated list of paths from root e.g test tests doc # filterwarnings ignore::WarningType # ignores those warnings [tool.mypy] -python_version = "3.8" files = ["git/", "test/deprecation/"] disallow_untyped_defs = true no_implicit_optional = true From 8469a1292f51d5e211e69849844f418d773268e1 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:37:54 +0000 Subject: [PATCH 23/69] Fix or ignore all mypy errors --- git/config.py | 6 ++---- git/diff.py | 6 ++++-- git/index/typ.py | 4 ++-- git/objects/commit.py | 2 +- git/objects/submodule/base.py | 3 ++- git/refs/head.py | 6 +----- git/refs/log.py | 2 +- git/refs/symbolic.py | 3 +-- git/refs/tag.py | 8 ++++---- git/repo/base.py | 10 +++------- git/repo/fun.py | 2 +- git/types.py | 6 +++--- git/util.py | 8 +++++--- test/deprecation/test_types.py | 2 +- test/lib/helper.py | 4 ++-- test/test_submodule.py | 2 +- 16 files changed, 34 insertions(+), 40 deletions(-) diff --git a/git/config.py b/git/config.py index 200c81bb7..458151d05 100644 --- a/git/config.py +++ b/git/config.py @@ -574,7 +574,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: if keyword.endswith("/i"): value = re.sub( r"[a-zA-Z]", - lambda m: "[{}{}]".format(m.group().lower(), m.group().upper()), + lambda m: f"[{m.group().lower()!r}{m.group().upper()!r}]", value, ) if self._repo.git_dir: @@ -633,8 +633,6 @@ def read(self) -> None: # type: ignore[override] file_path = cast(IO[bytes], file_path) self._read(file_path, file_path.name) else: - # Assume a path if it is not a file-object. - file_path = cast(PathLike, file_path) try: with open(file_path, "rb") as fp: file_ok = True @@ -768,7 +766,7 @@ def _assure_writable(self, method_name: str) -> None: if self.read_only: raise IOError("Cannot execute non-constant method %s.%s" % (self, method_name)) - def add_section(self, section: str) -> None: + def add_section(self, section: str | cp._UNNAMED_SECTION) -> None: """Assures added options will stay in order.""" return super().add_section(section) diff --git a/git/diff.py b/git/diff.py index 9c6ae59e0..2b1fd928c 100644 --- a/git/diff.py +++ b/git/diff.py @@ -21,15 +21,17 @@ Any, Iterator, List, + Literal, Match, Optional, + Sequence, Tuple, TYPE_CHECKING, TypeVar, Union, cast, ) -from git.types import Literal, PathLike +from git.types import PathLike if TYPE_CHECKING: from subprocess import Popen @@ -289,7 +291,7 @@ class DiffIndex(List[T_Diff]): The class improves the diff handling convenience. """ - change_type = ("A", "C", "D", "R", "M", "T") + change_type: Sequence[Literal["A", "C", "D", "R", "M", "T"]] = ("A", "C", "D", "R", "M", "T") """Change type invariant identifying possible ways a blob can have changed: * ``A`` = Added diff --git a/git/index/typ.py b/git/index/typ.py index 4bcb604ab..927633a9f 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -192,7 +192,7 @@ def from_base(cls, base: "BaseIndexEntry") -> "IndexEntry": Instance of type :class:`BaseIndexEntry`. """ time = pack(">LL", 0, 0) - return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0)) + return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0)) # type: ignore[arg-type] @classmethod def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry": @@ -211,5 +211,5 @@ def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry": 0, 0, blob.size, - ) + ) # type: ignore[arg-type] ) diff --git a/git/objects/commit.py b/git/objects/commit.py index fbe0ee9c0..8c51254a2 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -900,7 +900,7 @@ def co_authors(self) -> List[Actor]: if self.message: results = re.findall( r"^Co-authored-by: (.*) <(.*?)>$", - self.message, + str(self.message), re.MULTILINE, ) for author in results: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 5031a2e71..b4a4ca467 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -25,6 +25,7 @@ ) from git.objects.base import IndexObject, Object from git.objects.util import TraversableIterableObj +from ...refs.remote import RemoteReference from git.util import ( IterableList, RemoteProgress, @@ -355,7 +356,7 @@ def _clone_repo( module_checkout_path = osp.join(str(repo.working_tree_dir), path) if url.startswith("../"): - remote_name = repo.active_branch.tracking_branch().remote_name + remote_name = cast(RemoteReference, repo.active_branch.tracking_branch()).remote_name repo_remote_url = repo.remote(remote_name).url url = os.path.join(repo_remote_url, url) diff --git a/git/refs/head.py b/git/refs/head.py index 683634451..3c43993e7 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -22,7 +22,6 @@ from git.types import Commit_ish, PathLike if TYPE_CHECKING: - from git.objects import Commit from git.refs import RemoteReference from git.repo import Repo @@ -44,9 +43,6 @@ class HEAD(SymbolicReference): __slots__ = () - # TODO: This can be removed once SymbolicReference.commit has static type hints. - commit: "Commit" - def __init__(self, repo: "Repo", path: PathLike = _HEAD_NAME) -> None: if path != self._HEAD_NAME: raise ValueError("HEAD instance must point to %r, got %r" % (self._HEAD_NAME, path)) @@ -149,7 +145,7 @@ class Head(Reference): k_config_remote_ref = "merge" # Branch to merge from remote. @classmethod - def delete(cls, repo: "Repo", *heads: "Union[Head, str]", force: bool = False, **kwargs: Any) -> None: + def delete(cls, repo: "Repo", *heads: "Union[Head, str]", force: bool = False, **kwargs: Any) -> None: # type: ignore[override] """Delete the given heads. :param force: diff --git a/git/refs/log.py b/git/refs/log.py index 8f2f2cd38..4e3666993 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -145,7 +145,7 @@ def from_line(cls, line: bytes) -> "RefLogEntry": actor = Actor._from_string(info[82 : email_end + 1]) time, tz_offset = parse_date(info[email_end + 2 :]) # skipcq: PYL-W0621 - return RefLogEntry((oldhexsha, newhexsha, actor, (time, tz_offset), msg)) + return RefLogEntry(oldhexsha, newhexsha, actor, (time, tz_offset), msg) # type: ignore[call-arg] class RefLog(List[RefLogEntry], Serializable): diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 74bb1fe0a..f0d2abcf4 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -916,8 +916,7 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere SymbolicReference, ): try: - instance: T_References - instance = ref_type(repo, path) + instance = cast(T_References, ref_type(repo, path)) if instance.__class__ is SymbolicReference and instance.is_detached: raise ValueError("SymbolicRef was detached, we drop it") else: diff --git a/git/refs/tag.py b/git/refs/tag.py index 1e38663ae..4525b09cb 100644 --- a/git/refs/tag.py +++ b/git/refs/tag.py @@ -45,8 +45,8 @@ class TagReference(Reference): _common_default = "tags" _common_path_default = Reference._common_path_default + "/" + _common_default - @property - def commit(self) -> "Commit": # type: ignore[override] # LazyMixin has unrelated commit method + @property # type: ignore[misc] + def commit(self) -> "Commit": # LazyMixin has unrelated commit method """:return: Commit object the tag ref points to :raise ValueError: @@ -80,8 +80,8 @@ def tag(self) -> Union["TagObject", None]: return None # Make object read-only. It should be reasonably hard to adjust an existing tag. - @property - def object(self) -> AnyGitObject: # type: ignore[override] + @property # type: ignore[misc] + def object(self) -> AnyGitObject: return Reference._get_object(self) @classmethod diff --git a/git/repo/base.py b/git/repo/base.py index 6ea96aad2..1ef7114af 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -684,11 +684,7 @@ def _config_reader( git_dir: Optional[PathLike] = None, ) -> GitConfigParser: if config_level is None: - files = [ - self._get_config_path(cast(Lit_config_levels, f), git_dir) - for f in self.config_level - if cast(Lit_config_levels, f) - ] + files = [self._get_config_path(f, git_dir) for f in self.config_level if f] else: files = [self._get_config_path(config_level, git_dir)] return GitConfigParser(files, read_only=True, repo=self) @@ -1484,7 +1480,7 @@ def clone( self.common_dir, path, type(self.odb), - progress, + progress, # type: ignore[arg-type] multi_options, allow_unsafe_protocols=allow_unsafe_protocols, allow_unsafe_options=allow_unsafe_options, @@ -1545,7 +1541,7 @@ def clone_from( url, to_path, GitCmdObjectDB, - progress, + progress, # type: ignore[arg-type] multi_options, allow_unsafe_protocols=allow_unsafe_protocols, allow_unsafe_options=allow_unsafe_options, diff --git a/git/repo/fun.py b/git/repo/fun.py index 1c995c6c6..3f00e60ea 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -286,7 +286,7 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject: # END handle refname else: if ref is not None: - obj = cast("Commit", ref.commit) + obj = ref.commit # END handle ref # END initialize obj on first token diff --git a/git/types.py b/git/types.py index cce184530..c6dbb717b 100644 --- a/git/types.py +++ b/git/types.py @@ -13,7 +13,7 @@ Sequence as Sequence, Tuple, TYPE_CHECKING, - Type, + TypeAlias, TypeVar, Union, ) @@ -117,7 +117,7 @@ object types. """ -GitObjectTypeString = Literal["commit", "tag", "blob", "tree"] +GitObjectTypeString: TypeAlias = Literal["commit", "tag", "blob", "tree"] """Literal strings identifying git object types and the :class:`~git.objects.base.Object`-based types that represent them. @@ -130,7 +130,7 @@ https://git-scm.com/docs/gitglossary#def_object_type """ -Lit_commit_ish: Type[Literal["commit", "tag"]] +Lit_commit_ish: TypeAlias = Literal["commit", "tag"] """Deprecated. Type of literal strings identifying typically-commitish git object types. Prior to a bugfix, this type had been defined more broadly. Any usage is in practice diff --git a/git/util.py b/git/util.py index 0aff5eb64..54a5b7bd1 100644 --- a/git/util.py +++ b/git/util.py @@ -1143,7 +1143,7 @@ def _obtain_lock(self) -> None: # END endless loop -class IterableList(List[T_IterableObj]): +class IterableList(List[T_IterableObj]): # type: ignore[type-var] """List of iterable objects allowing to query an object by id or by named index:: heads = repo.heads @@ -1214,14 +1214,16 @@ def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_Iterabl raise ValueError("Index should be an int or str") else: try: + if not isinstance(index, str): + raise AttributeError(f"{index} is not a valid attribute") return getattr(self, index) except AttributeError as e: - raise IndexError("No item found with id %r" % (self._prefix + index)) from e + raise IndexError(f"No item found with id {self._prefix}{index}") from e # END handle getattr def __delitem__(self, index: Union[SupportsIndex, int, slice, str]) -> None: delindex = cast(int, index) - if not isinstance(index, int): + if isinstance(index, str): delindex = -1 name = self._prefix + index for i, item in enumerate(self): diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py index f97375a85..d3c6af645 100644 --- a/test/deprecation/test_types.py +++ b/test/deprecation/test_types.py @@ -36,7 +36,7 @@ def test_can_access_lit_commit_ish_but_it_is_not_usable() -> None: assert 'Literal["commit", "tag"]' in message, "Has new definition." assert "GitObjectTypeString" in message, "Has new type name for old definition." - _: Lit_commit_ish = "commit" # type: ignore[valid-type] + _: Lit_commit_ish = "commit" # It should be as documented (even though deliberately unusable in static checks). assert Lit_commit_ish == Literal["commit", "tag"] diff --git a/test/lib/helper.py b/test/lib/helper.py index 241d27341..b4615f400 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -149,7 +149,7 @@ def repo_creator(self): os.chdir(rw_repo.working_dir) try: return func(self, rw_repo) - except: # noqa: E722 B001 + except: # noqa: E722 _logger.info("Keeping repo after failure: %s", repo_dir) repo_dir = None raise @@ -309,7 +309,7 @@ def remote_repo_creator(self): with cwd(rw_repo.working_dir): try: return func(self, rw_repo, rw_daemon_repo) - except: # noqa: E722 B001 + except: # noqa: E722 _logger.info( "Keeping repos after failure: \n rw_repo_dir: %s \n rw_daemon_repo_dir: %s", rw_repo_dir, diff --git a/test/test_submodule.py b/test/test_submodule.py index 4a248eb60..a92dd8fd4 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -932,7 +932,7 @@ def assert_exists(sm, value=True): csm.repo.index.commit("Have to commit submodule change for algorithm to pick it up") assert csm.url == "bar" - self.assertRaises( + self.assertRaises( # noqa: B017 Exception, rsm.update, recursive=True, From a9756bc0c8997482a7f69cc8e46a9f461afea8f6 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 09:46:47 +0000 Subject: [PATCH 24/69] Fix typing so that code can run --- git/config.py | 2 +- git/objects/submodule/base.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/git/config.py b/git/config.py index 458151d05..bccf61258 100644 --- a/git/config.py +++ b/git/config.py @@ -766,7 +766,7 @@ def _assure_writable(self, method_name: str) -> None: if self.read_only: raise IOError("Cannot execute non-constant method %s.%s" % (self, method_name)) - def add_section(self, section: str | cp._UNNAMED_SECTION) -> None: + def add_section(self, section: "cp._SectionName") -> None: """Assures added options will stay in order.""" return super().add_section(section) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index b4a4ca467..20f3e9ccf 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -25,7 +25,6 @@ ) from git.objects.base import IndexObject, Object from git.objects.util import TraversableIterableObj -from ...refs.remote import RemoteReference from git.util import ( IterableList, RemoteProgress, @@ -67,7 +66,7 @@ if TYPE_CHECKING: from git.index import IndexFile from git.objects.commit import Commit - from git.refs import Head + from git.refs import Head, RemoteReference from git.repo import Repo # ----------------------------------------------------------------------------- @@ -356,7 +355,7 @@ def _clone_repo( module_checkout_path = osp.join(str(repo.working_tree_dir), path) if url.startswith("../"): - remote_name = cast(RemoteReference, repo.active_branch.tracking_branch()).remote_name + remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name repo_remote_url = repo.remote(remote_name).url url = os.path.join(repo_remote_url, url) From 0aba3e7bdec7544a86b6e6ba4b0ad8e2ac5cd2c7 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 10:02:07 +0000 Subject: [PATCH 25/69] Stop Lit_commit_ish being imported --- git/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git/types.py b/git/types.py index c6dbb717b..100fff43f 100644 --- a/git/types.py +++ b/git/types.py @@ -13,7 +13,6 @@ Sequence as Sequence, Tuple, TYPE_CHECKING, - TypeAlias, TypeVar, Union, ) @@ -117,7 +116,7 @@ object types. """ -GitObjectTypeString: TypeAlias = Literal["commit", "tag", "blob", "tree"] +GitObjectTypeString = Literal["commit", "tag", "blob", "tree"] """Literal strings identifying git object types and the :class:`~git.objects.base.Object`-based types that represent them. @@ -130,7 +129,8 @@ https://git-scm.com/docs/gitglossary#def_object_type """ -Lit_commit_ish: TypeAlias = Literal["commit", "tag"] +if TYPE_CHECKING: + Lit_commit_ish = Literal["commit", "tag"] """Deprecated. Type of literal strings identifying typically-commitish git object types. Prior to a bugfix, this type had been defined more broadly. Any usage is in practice From 019f270785fd01558b48d21fe1469b9a2132d04b Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 10:02:45 +0000 Subject: [PATCH 26/69] Set __test__ = False in not tested classes --- test/test_remote.py | 5 ++++- test/test_submodule.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_remote.py b/test/test_remote.py index 5ddb41bc0..b1d686f05 100644 --- a/test/test_remote.py +++ b/test/test_remote.py @@ -44,7 +44,7 @@ class TestRemoteProgress(RemoteProgress): __slots__ = ("_seen_lines", "_stages_per_op", "_num_progress_messages") - def __init__(self): + def __init__(self) -> None: super().__init__() self._seen_lines = [] self._stages_per_op = {} @@ -103,6 +103,9 @@ def assert_received_message(self): assert self._num_progress_messages +TestRemoteProgress.__test__ = False # type: ignore + + class TestRemote(TestBase): def tearDown(self): gc.collect() diff --git a/test/test_submodule.py b/test/test_submodule.py index a92dd8fd4..edff064c4 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -58,6 +58,7 @@ def update(self, op, cur_count, max_count, message=""): print(op, cur_count, max_count, message) +TestRootProgress.__test__ = False prog = TestRootProgress() From ca5a2e817829861c5a0830806c0a40a33a5ab83f Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 11:40:23 +0000 Subject: [PATCH 27/69] Add missing parentheses around tuple constructor --- git/refs/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/log.py b/git/refs/log.py index 4e3666993..4751cff99 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -145,7 +145,7 @@ def from_line(cls, line: bytes) -> "RefLogEntry": actor = Actor._from_string(info[82 : email_end + 1]) time, tz_offset = parse_date(info[email_end + 2 :]) # skipcq: PYL-W0621 - return RefLogEntry(oldhexsha, newhexsha, actor, (time, tz_offset), msg) # type: ignore[call-arg] + return RefLogEntry((oldhexsha, newhexsha, actor, (time, tz_offset), msg)) # type: ignore [arg-type] class RefLog(List[RefLogEntry], Serializable): From c75790837d0fd5bb9a7ba26a48b957b5d70987fb Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 11:43:21 +0000 Subject: [PATCH 28/69] Install mypy for Python >= 3.9 --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 460597539..e6e01c683 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ coverage[toml] ddt >= 1.1.1, != 1.4.3 mock ; python_version < "3.8" -mypy==1.18.2 # pin mypy to avoid new errors +mypy==1.18.2 ; python_version >= "3.9" # pin mypy version to avoid new errors pre-commit pytest >= 7.3.1 pytest-cov From 9decf740ad2f1d89b55bda3a42880fa4f7b652ea Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 11:49:10 +0000 Subject: [PATCH 29/69] Skip mypy when Python < 3.9 --- .github/workflows/pythonpackage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4666f3480..9e05b3fe6 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -95,6 +95,7 @@ jobs: continue-on-error: true - name: Check types with mypy + if: matrix.python-version != '3.7' && matrix.python-version != '3.8' run: | mypy --python-version="${PYTHON_VERSION%t}" # Version only, with no "t" for free-threaded. env: From a1f094c81fcf4a6b559c2a26fc622c89e4f19735 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 12:10:04 +0000 Subject: [PATCH 30/69] Use git.types.Literal instead of typing.Literal --- git/diff.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/git/diff.py b/git/diff.py index 2b1fd928c..23cb5675e 100644 --- a/git/diff.py +++ b/git/diff.py @@ -21,7 +21,6 @@ Any, Iterator, List, - Literal, Match, Optional, Sequence, @@ -31,7 +30,7 @@ Union, cast, ) -from git.types import PathLike +from git.types import PathLike, Literal if TYPE_CHECKING: from subprocess import Popen @@ -291,7 +290,7 @@ class DiffIndex(List[T_Diff]): The class improves the diff handling convenience. """ - change_type: Sequence[Literal["A", "C", "D", "R", "M", "T"]] = ("A", "C", "D", "R", "M", "T") + change_type: Sequence[Literal["A", "C", "D", "R", "M", "T"]] = ("A", "C", "D", "R", "M", "T") # noqa: F821 """Change type invariant identifying possible ways a blob can have changed: * ``A`` = Added From 0414bf78cfc7d1889121c414efa7841f57343984 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 21:28:17 +0000 Subject: [PATCH 31/69] Replace extra occurrences of str with fspath --- git/config.py | 2 +- git/index/base.py | 18 +++++++++--------- git/index/fun.py | 4 ++-- git/index/util.py | 2 +- git/objects/blob.py | 3 ++- git/objects/submodule/base.py | 10 +++++----- git/refs/reference.py | 3 ++- git/refs/symbolic.py | 18 +++++++++--------- git/repo/base.py | 4 ++-- git/util.py | 20 ++++++++++---------- test/lib/helper.py | 11 +++++++++++ test/test_clone.py | 11 +---------- test/test_index.py | 22 ++++++++++++---------- test/test_repo.py | 10 +--------- 14 files changed, 68 insertions(+), 70 deletions(-) diff --git a/git/config.py b/git/config.py index e3081401d..732f347f0 100644 --- a/git/config.py +++ b/git/config.py @@ -578,7 +578,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: value, ) if self._repo.git_dir: - if fnmatch.fnmatchcase(str(self._repo.git_dir), value): + if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value): paths += self.items(section) elif keyword == "onbranch": diff --git a/git/index/base.py b/git/index/base.py index 7cc9d3ade..905feb076 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -404,10 +404,10 @@ def _iter_expand_paths(self: "IndexFile", paths: Sequence[PathLike]) -> Iterator def raise_exc(e: Exception) -> NoReturn: raise e - r = str(self.repo.working_tree_dir) + r = os.fspath(self.repo.working_tree_dir) rs = r + os.sep for path in paths: - abs_path = str(path) + abs_path = os.fspath(path) if not osp.isabs(abs_path): abs_path = osp.join(r, path) # END make absolute path @@ -434,7 +434,7 @@ def raise_exc(e: Exception) -> NoReturn: # characters. if abs_path not in resolved_paths: for f in self._iter_expand_paths(glob.glob(abs_path)): - yield str(f).replace(rs, "") + yield os.fspath(f).replace(rs, "") continue # END glob handling try: @@ -569,7 +569,7 @@ def resolve_blobs(self, iter_blobs: Iterator[Blob]) -> "IndexFile": for blob in iter_blobs: stage_null_key = (blob.path, 0) if stage_null_key in self.entries: - raise ValueError("Path %r already exists at stage 0" % str(blob.path)) + raise ValueError("Path %r already exists at stage 0" % os.fspath(blob.path)) # END assert blob is not stage 0 already # Delete all possible stages. @@ -656,10 +656,10 @@ def _to_relative_path(self, path: PathLike) -> PathLike: return path if self.repo.bare: raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(str(path)).startswith(str(self.repo.working_tree_dir)): + if not osp.normpath(os.fspath(path)).startswith(os.fspath(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 str(path).endswith(os.sep) and not result.endswith(os.sep): + if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): result += os.sep return result @@ -731,7 +731,7 @@ def _entries_for_paths( 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(os.fspath(self.repo.working_tree_dir)) + 1 :] else: gitrelative_path = path if self.repo.working_tree_dir: @@ -1359,11 +1359,11 @@ def make_exc() -> GitCommandError: try: self.entries[(co_path, 0)] except KeyError: - folder = str(co_path) + folder = os.fspath(co_path) if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): - if str(entry.path).startswith(folder): + if os.fspath(entry.path).startswith(folder): p = entry.path self._write_path_to_stdin(proc, p, p, make_exc, fprogress, read_from_stdout=False) checked_out_files.append(p) diff --git a/git/index/fun.py b/git/index/fun.py index 0b3d79cf1..845221c61 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -87,7 +87,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: return env = os.environ.copy() - env["GIT_INDEX_FILE"] = safe_decode(str(index.path)) + env["GIT_INDEX_FILE"] = safe_decode(os.fspath(index.path)) env["GIT_EDITOR"] = ":" cmd = [hp] try: @@ -167,7 +167,7 @@ def write_cache( beginoffset = tell() write(entry.ctime_bytes) # ctime write(entry.mtime_bytes) # mtime - path_str = str(entry.path) + path_str = os.fspath(entry.path) path: bytes = force_bytes(path_str, encoding=defenc) plen = len(path) & CE_NAMEMASK # Path length assert plen == len(path), "Path %s too long to fit into index" % entry.path diff --git a/git/index/util.py b/git/index/util.py index e59cb609f..2d2422ab4 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -106,7 +106,7 @@ def git_working_dir(func: Callable[..., _T]) -> Callable[..., _T]: @wraps(func) def set_git_working_dir(self: "IndexFile", *args: Any, **kwargs: Any) -> _T: cur_wd = os.getcwd() - os.chdir(str(self.repo.working_tree_dir)) + os.chdir(os.fspath(self.repo.working_tree_dir)) try: return func(self, *args, **kwargs) finally: diff --git a/git/objects/blob.py b/git/objects/blob.py index 58de59642..f7d49c9cc 100644 --- a/git/objects/blob.py +++ b/git/objects/blob.py @@ -6,6 +6,7 @@ __all__ = ["Blob"] from mimetypes import guess_type +import os import sys if sys.version_info >= (3, 8): @@ -44,5 +45,5 @@ def mime_type(self) -> str: """ guesses = None if self.path: - guesses = guess_type(str(self.path)) + guesses = guess_type(os.fspath(self.path)) return guesses and guesses[0] or self.DEFAULT_MIME_TYPE diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 5031a2e71..cc1f94c6c 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -352,7 +352,7 @@ def _clone_repo( module_abspath_dir = osp.dirname(module_abspath) if not osp.isdir(module_abspath_dir): os.makedirs(module_abspath_dir) - module_checkout_path = osp.join(str(repo.working_tree_dir), path) + module_checkout_path = osp.join(os.fspath(repo.working_tree_dir), path) if url.startswith("../"): remote_name = repo.active_branch.tracking_branch().remote_name @@ -541,7 +541,7 @@ def add( if sm.exists(): # Reretrieve submodule from tree. try: - sm = repo.head.commit.tree[str(path)] + sm = repo.head.commit.tree[os.fspath(path)] sm._name = name return sm except KeyError: @@ -1016,7 +1016,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool = return self # END handle no change - module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path) + module_checkout_abspath = join_path_native(os.fspath(self.repo.working_tree_dir), module_checkout_path) if osp.isfile(module_checkout_abspath): raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath) # END handle target files @@ -1313,7 +1313,7 @@ def set_parent_commit(self, commit: Union[Commit_ish, str, None], check: bool = # If check is False, we might see a parent-commit that doesn't even contain the # submodule anymore. in that case, mark our sha as being NULL. try: - self.binsha = pctree[str(self.path)].binsha + self.binsha = pctree[os.fspath(self.path)].binsha except KeyError: self.binsha = self.NULL_BIN_SHA @@ -1395,7 +1395,7 @@ def rename(self, new_name: str) -> "Submodule": destination_module_abspath = self._module_abspath(self.repo, self.path, new_name) source_dir = mod.git_dir # Let's be sure the submodule name is not so obviously tied to a directory. - if str(destination_module_abspath).startswith(str(mod.git_dir)): + if os.fspath(destination_module_abspath).startswith(os.fspath(mod.git_dir)): tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4())) os.renames(source_dir, tmp_dir) source_dir = tmp_dir diff --git a/git/refs/reference.py b/git/refs/reference.py index e5d473779..0c4327225 100644 --- a/git/refs/reference.py +++ b/git/refs/reference.py @@ -3,6 +3,7 @@ __all__ = ["Reference"] +import os from git.util import IterableObj, LazyMixin from .symbolic import SymbolicReference, T_References @@ -65,7 +66,7 @@ def __init__(self, repo: "Repo", path: PathLike, check_path: bool = True) -> Non If ``False``, you can provide any path. Otherwise the path must start with the default path prefix of this type. """ - if check_path and not str(path).startswith(self._common_path_default + "/"): + if check_path and not os.fspath(path).startswith(self._common_path_default + "/"): raise ValueError(f"Cannot instantiate {self.__class__.__name__!r} from path {path}") self.path: str # SymbolicReference converts to string at the moment. super().__init__(repo, path) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 74bb1fe0a..c7db129d9 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -79,7 +79,7 @@ def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> No self.path = path def __str__(self) -> str: - return str(self.path) + return os.fspath(self.path) def __repr__(self) -> str: return '' % (self.__class__.__name__, self.path) @@ -103,7 +103,7 @@ def name(self) -> str: In case of symbolic references, the shortest assumable name is the path itself. """ - return str(self.path) + return os.fspath(self.path) @property def abspath(self) -> PathLike: @@ -178,7 +178,7 @@ def _check_ref_name_valid(ref_path: PathLike) -> None: """ previous: Union[str, None] = None one_before_previous: Union[str, None] = None - for c in str(ref_path): + for c in os.fspath(ref_path): if c in " ~^:?*[\\": raise ValueError( f"Invalid reference '{ref_path}': references cannot contain spaces, tildes (~), carets (^)," @@ -212,7 +212,7 @@ def _check_ref_name_valid(ref_path: PathLike) -> None: raise ValueError(f"Invalid reference '{ref_path}': references cannot end with a forward slash (/)") elif previous == "@" and one_before_previous is None: raise ValueError(f"Invalid reference '{ref_path}': references cannot be '@'") - elif any(component.endswith(".lock") for component in str(ref_path).split("/")): + elif any(component.endswith(".lock") for component in os.fspath(ref_path).split("/")): raise ValueError( f"Invalid reference '{ref_path}': references cannot have slash-separated components that end with" " '.lock'" @@ -235,7 +235,7 @@ def _get_ref_info_helper( tokens: Union[None, List[str], Tuple[str, str]] = None repodir = _git_dir(repo, ref_path) try: - with open(os.path.join(repodir, str(ref_path)), "rt", encoding="UTF-8") as fp: + with open(os.path.join(repodir, os.fspath(ref_path)), "rt", encoding="UTF-8") as fp: value = fp.read().rstrip() # Don't only split on spaces, but on whitespace, which allows to parse lines like: # 60b64ef992065e2600bfef6187a97f92398a9144 branch 'master' of git-server:/path/to/repo @@ -614,7 +614,7 @@ def to_full_path(cls, path: Union[PathLike, "SymbolicReference"]) -> PathLike: full_ref_path = path if not cls._common_path_default: return full_ref_path - if not str(path).startswith(cls._common_path_default + "/"): + if not os.fspath(path).startswith(cls._common_path_default + "/"): full_ref_path = "%s/%s" % (cls._common_path_default, path) return full_ref_path @@ -706,7 +706,7 @@ def _create( if not force and os.path.isfile(abs_ref_path): target_data = str(target) if isinstance(target, SymbolicReference): - target_data = str(target.path) + target_data = os.fspath(target.path) if not resolve: target_data = "ref: " + target_data with open(abs_ref_path, "rb") as fd: @@ -842,7 +842,7 @@ def _iter_items( # Read packed refs. for _sha, rela_path in cls._iter_packed_refs(repo): - if rela_path.startswith(str(common_path)): + if rela_path.startswith(os.fspath(common_path)): rela_paths.add(rela_path) # END relative path matches common path # END packed refs reading @@ -931,4 +931,4 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere def is_remote(self) -> bool: """:return: True if this symbolic reference points to a remote branch""" - return str(self.path).startswith(self._remote_common_path_default + "/") + return os.fspath(self.path).startswith(self._remote_common_path_default + "/") diff --git a/git/repo/base.py b/git/repo/base.py index 2d87a06b7..2fe18f48c 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -554,7 +554,7 @@ def tag(self, path: PathLike) -> TagReference: @staticmethod def _to_full_tag_path(path: PathLike) -> str: - path_str = str(path) + path_str = os.fspath(path) if path_str.startswith(TagReference._common_path_default + "/"): return path_str if path_str.startswith(TagReference._common_default + "/"): @@ -959,7 +959,7 @@ def is_dirty( if not submodules: default_args.append("--ignore-submodules") if path: - default_args.extend(["--", str(path)]) + default_args.extend(["--", os.fspath(path)]) if index: # diff index against HEAD. if osp.isfile(self.index.path) and len(self.git.diff("--cached", *default_args)): diff --git a/git/util.py b/git/util.py index 0aff5eb64..f18eb6e52 100644 --- a/git/util.py +++ b/git/util.py @@ -272,9 +272,9 @@ def stream_copy(source: BinaryIO, destination: BinaryIO, chunk_size: int = 512 * def join_path(a: PathLike, *p: PathLike) -> PathLike: R"""Join path tokens together similar to osp.join, but always use ``/`` instead of possibly ``\`` on Windows.""" - path = str(a) + path = os.fspath(a) for b in p: - b = str(b) + b = os.fspath(b) if not b: continue if b.startswith("/"): @@ -290,18 +290,18 @@ def join_path(a: PathLike, *p: PathLike) -> PathLike: if sys.platform == "win32": def to_native_path_windows(path: PathLike) -> PathLike: - path = str(path) + path = os.fspath(path) return path.replace("/", "\\") def to_native_path_linux(path: PathLike) -> str: - path = str(path) + path = os.fspath(path) return path.replace("\\", "/") to_native_path = to_native_path_windows else: # No need for any work on Linux. def to_native_path_linux(path: PathLike) -> str: - return str(path) + return os.fspath(path) to_native_path = to_native_path_linux @@ -372,7 +372,7 @@ def is_exec(fpath: str) -> bool: progs = [] if not path: path = os.environ["PATH"] - for folder in str(path).split(os.pathsep): + for folder in os.fspath(path).split(os.pathsep): folder = folder.strip('"') if folder: exe_path = osp.join(folder, program) @@ -397,7 +397,7 @@ def _cygexpath(drive: Optional[str], path: str) -> str: p = cygpath(p) elif drive: p = "/proc/cygdrive/%s/%s" % (drive.lower(), p) - p_str = str(p) # ensure it is a str and not AnyPath + p_str = os.fspath(p) # ensure it is a str and not AnyPath return p_str.replace("\\", "/") @@ -418,7 +418,7 @@ def _cygexpath(drive: Optional[str], path: str) -> str: def cygpath(path: str) -> str: """Use :meth:`git.cmd.Git.polish_url` instead, that works on any environment.""" - path = str(path) # Ensure is str and not AnyPath. + path = os.fspath(path) # Ensure is str and not AnyPath. # Fix to use Paths when 3.5 dropped. Or to be just str if only for URLs? if not path.startswith(("/cygdrive", "//", "/proc/cygdrive")): for regex, parser, recurse in _cygpath_parsers: @@ -438,7 +438,7 @@ def cygpath(path: str) -> str: def decygpath(path: PathLike) -> str: - path = str(path) + path = os.fspath(path) m = _decygpath_regex.match(path) if m: drive, rest_path = m.groups() @@ -497,7 +497,7 @@ def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool: elif git_executable is None: return False else: - return _is_cygwin_git(str(git_executable)) + return _is_cygwin_git(os.fspath(git_executable)) def get_user_id() -> str: diff --git a/test/lib/helper.py b/test/lib/helper.py index 241d27341..e51f428e3 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -10,6 +10,7 @@ "with_rw_directory", "with_rw_repo", "with_rw_and_rw_remote_repo", + "PathLikeMock", "TestBase", "VirtualEnvironment", "TestCase", @@ -20,6 +21,7 @@ ] import contextlib +from dataclasses import dataclass from functools import wraps import gc import io @@ -49,6 +51,15 @@ _logger = logging.getLogger(__name__) + +@dataclass +class PathLikeMock: + path: str + + def __fspath__(self) -> str: + return self.path + + # { Routines diff --git a/test/test_clone.py b/test/test_clone.py index 489931458..143a3b51f 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,7 +1,6 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ -from dataclasses import dataclass import os import os.path as osp import pathlib @@ -12,7 +11,7 @@ from git import GitCommandError, Repo from git.exc import UnsafeOptionError, UnsafeProtocolError -from test.lib import TestBase, with_rw_directory, with_rw_repo +from test.lib import TestBase, with_rw_directory, with_rw_repo, PathLikeMock from pathlib import Path import re @@ -51,14 +50,6 @@ def test_clone_from_pathlib(self, rw_dir): @with_rw_directory def test_clone_from_pathlike(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) - - @dataclass - class PathLikeMock: - path: str - - def __fspath__(self) -> str: - return self.path - Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) @with_rw_directory diff --git a/test/test_index.py b/test/test_index.py index 711b43a0b..c1db4166b 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -37,14 +37,7 @@ from git.objects import Blob 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, -) +from test.lib import TestBase, VirtualEnvironment, fixture, fixture_path, with_rw_directory, with_rw_repo, PathLikeMock HOOKS_SHEBANG = "#!/usr/bin/env sh\n" @@ -586,7 +579,7 @@ def mixed_iterator(): if type_id == 0: # path (str) yield entry.path elif type_id == 1: # path (PathLike) - yield Path(entry.path) + yield PathLikeMock(entry.path) elif type_id == 2: # blob yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) elif type_id == 3: # BaseIndexEntry @@ -1198,7 +1191,7 @@ def test_commit_msg_hook_fail(self, rw_repo): raise AssertionError("Should have caught a HookExecutionError") @with_rw_repo("HEAD") - def test_index_add_pathlike(self, rw_repo): + def test_index_add_pathlib(self, rw_repo): git_dir = Path(rw_repo.git_dir) file = git_dir / "file.txt" @@ -1206,6 +1199,15 @@ def test_index_add_pathlike(self, rw_repo): rw_repo.index.add(file) + @with_rw_repo("HEAD") + def test_index_add_pathlike(self, rw_repo): + git_dir = Path(rw_repo.git_dir) + + file = git_dir / "file.txt" + file.touch() + + rw_repo.index.add(PathLikeMock(str(file))) + @with_rw_repo("HEAD") def test_index_add_non_normalized_path(self, rw_repo): git_dir = Path(rw_repo.git_dir) diff --git a/test/test_repo.py b/test/test_repo.py index cd22430a7..f089bf6b8 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -3,7 +3,6 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ -from dataclasses import dataclass import gc import glob import io @@ -41,7 +40,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 +from test.lib import TestBase, fixture, with_rw_directory, with_rw_repo, PathLikeMock def iter_flatten(lol): @@ -108,13 +107,6 @@ def test_repo_creation_pathlib(self, rw_repo): @with_rw_repo("0.3.2.1") def test_repo_creation_pathlike(self, rw_repo): - @dataclass - class PathLikeMock: - path: str - - def __fspath__(self) -> str: - return self.path - r_from_gitdir = Repo(PathLikeMock(rw_repo.git_dir)) self.assertEqual(r_from_gitdir.git_dir, rw_repo.git_dir) From 3801505d1218242e853dda17e981e2a2fa795b0e Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 21:43:09 +0000 Subject: [PATCH 32/69] Convert paths in constructors and large function calls --- git/index/base.py | 5 +++-- git/index/util.py | 2 +- git/refs/head.py | 2 ++ git/refs/log.py | 3 ++- git/refs/symbolic.py | 2 +- git/repo/fun.py | 1 + git/util.py | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 905feb076..cf7220ef8 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -652,14 +652,15 @@ def _to_relative_path(self, path: PathLike) -> PathLike: :raise ValueError: """ + path = os.fspath(path) if not osp.isabs(path): return path if self.repo.bare: raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(os.fspath(path)).startswith(os.fspath(self.repo.working_tree_dir)): + if not osp.normpath(path).startswith(os.fspath(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): + if path.endswith(os.sep) and not result.endswith(os.sep): result += os.sep return result diff --git a/git/index/util.py b/git/index/util.py index 2d2422ab4..231634cd6 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -37,7 +37,7 @@ class TemporaryFileSwap: __slots__ = ("file_path", "tmp_file_path") def __init__(self, file_path: PathLike) -> None: - self.file_path = file_path + self.file_path = os.fspath(file_path) dirname, basename = osp.split(file_path) fd, self.tmp_file_path = tempfile.mkstemp(prefix=basename, dir=dirname) os.close(fd) diff --git a/git/refs/head.py b/git/refs/head.py index 683634451..03daa3973 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -8,6 +8,7 @@ __all__ = ["HEAD", "Head"] +import os from git.config import GitConfigParser, SectionConstraint from git.exc import GitCommandError from git.util import join_path @@ -48,6 +49,7 @@ class HEAD(SymbolicReference): commit: "Commit" def __init__(self, repo: "Repo", path: PathLike = _HEAD_NAME) -> None: + path = os.fspath(path) if path != self._HEAD_NAME: raise ValueError("HEAD instance must point to %r, got %r" % (self._HEAD_NAME, path)) super().__init__(repo, path) diff --git a/git/refs/log.py b/git/refs/log.py index 8f2f2cd38..48bb02c60 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -4,6 +4,7 @@ __all__ = ["RefLog", "RefLogEntry"] from mmap import mmap +import os import os.path as osp import re import time as _time @@ -167,7 +168,7 @@ def __init__(self, filepath: Union[PathLike, None] = None) -> None: """Initialize this instance with an optional filepath, from which we will initialize our data. The path is also used to write changes back using the :meth:`write` method.""" - self._path = filepath + self._path = os.fspath(filepath) if filepath is not None: self._read_from_file() # END handle filepath diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index c7db129d9..24a72257d 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -76,7 +76,7 @@ class SymbolicReference: def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> None: self.repo = repo - self.path = path + self.path = os.fspath(path) def __str__(self) -> str: return os.fspath(self.path) diff --git a/git/repo/fun.py b/git/repo/fun.py index 1c995c6c6..1c7cfcb04 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -62,6 +62,7 @@ def is_git_dir(d: PathLike) -> bool: clearly indicates that we don't support it. There is the unlikely danger to throw if we see directories which just look like a worktree dir, but are none. """ + d = os.fspath(d) if osp.isdir(d): if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir( osp.join(d, "refs") diff --git a/git/util.py b/git/util.py index f18eb6e52..5326af9d1 100644 --- a/git/util.py +++ b/git/util.py @@ -1011,7 +1011,7 @@ class LockFile: __slots__ = ("_file_path", "_owns_lock") def __init__(self, file_path: PathLike) -> None: - self._file_path = file_path + self._file_path = os.fspath(file_path) self._owns_lock = False def __del__(self) -> None: From 086e83239388988772e21ee820c23e59533382f7 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 28 Nov 2025 21:48:38 +0000 Subject: [PATCH 33/69] Fix union type conversion to path --- git/refs/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/log.py b/git/refs/log.py index 48bb02c60..d1cc393f4 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -168,7 +168,7 @@ def __init__(self, filepath: Union[PathLike, None] = None) -> None: """Initialize this instance with an optional filepath, from which we will initialize our data. The path is also used to write changes back using the :meth:`write` method.""" - self._path = os.fspath(filepath) + self._path = None if filepath is None else os.fspath(filepath) if filepath is not None: self._read_from_file() # END handle filepath From b5c834af59531456551d406eb857934e7e87f1ce Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 11:49:23 +0000 Subject: [PATCH 34/69] Remve comment about skipping mypy --- .github/workflows/pythonpackage.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 9e05b3fe6..ac764d9a7 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -102,8 +102,6 @@ jobs: MYPY_FORCE_COLOR: "1" TERM: "xterm-256color" # For color: https://github.com/python/mypy/issues/13817 PYTHON_VERSION: ${{ matrix.python-version }} - # With new versions of mypy new issues might arise. This is a problem if there is - # nobody able to fix them, so we have to ignore errors until that changes. - name: Test with pytest run: | From eb15123b82dbd13f9cc88606b8a580c424335fe7 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 11:51:47 +0000 Subject: [PATCH 35/69] Use cast to allow silent getattrs --- git/util.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/git/util.py b/git/util.py index 54a5b7bd1..1f1595f1c 100644 --- a/git/util.py +++ b/git/util.py @@ -1214,9 +1214,7 @@ def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_Iterabl raise ValueError("Index should be an int or str") else: try: - if not isinstance(index, str): - raise AttributeError(f"{index} is not a valid attribute") - return getattr(self, index) + return getattr(self, cast(str, index)) except AttributeError as e: raise IndexError(f"No item found with id {self._prefix}{index}") from e # END handle getattr From b3908ed04815b1d89a000fc7824a804c37906365 Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:56:57 +0000 Subject: [PATCH 36/69] Use converted file path Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- git/index/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/index/util.py b/git/index/util.py index 231634cd6..80f0b014c 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -38,7 +38,7 @@ class TemporaryFileSwap: def __init__(self, file_path: PathLike) -> None: self.file_path = os.fspath(file_path) - dirname, basename = osp.split(file_path) + dirname, basename = osp.split(self.file_path) fd, self.tmp_file_path = tempfile.mkstemp(prefix=basename, dir=dirname) os.close(fd) with contextlib.suppress(OSError): # It may be that the source does not exist. From 50aea998641248f501735421ddc6165cbdb5d08c Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:57:25 +0000 Subject: [PATCH 37/69] Remove redundant `fspath` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- git/refs/symbolic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 24a72257d..7cf812416 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -79,7 +79,7 @@ def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> No self.path = os.fspath(path) def __str__(self) -> str: - return os.fspath(self.path) + return self.path def __repr__(self) -> str: return '' % (self.__class__.__name__, self.path) From 57a3af1ddc9b03f59a3e6d3f012c6043905763c0 Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:57:55 +0000 Subject: [PATCH 38/69] Remove redundant `fspath` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- git/refs/symbolic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 7cf812416..3cadb5061 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -103,7 +103,7 @@ def name(self) -> str: In case of symbolic references, the shortest assumable name is the path itself. """ - return os.fspath(self.path) + return self.path @property def abspath(self) -> PathLike: From df8087a2c90e25692eb8d4f09c8726fc78e21d05 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 12:24:01 +0000 Subject: [PATCH 39/69] Remove a large number of redundant fspaths --- git/index/base.py | 2 +- git/index/fun.py | 2 +- git/objects/submodule/base.py | 2 +- git/refs/symbolic.py | 4 ++-- git/repo/base.py | 6 +++--- git/util.py | 3 +-- test/test_index.py | 8 +++++--- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index cf7220ef8..2489949c1 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -1360,7 +1360,7 @@ def make_exc() -> GitCommandError: try: self.entries[(co_path, 0)] except KeyError: - folder = os.fspath(co_path) + folder = co_path if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): diff --git a/git/index/fun.py b/git/index/fun.py index 845221c61..9832aea6b 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -87,7 +87,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: return env = os.environ.copy() - env["GIT_INDEX_FILE"] = safe_decode(os.fspath(index.path)) + env["GIT_INDEX_FILE"] = safe_decode(index.path) env["GIT_EDITOR"] = ":" cmd = [hp] try: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index cc1f94c6c..ffc1d3595 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -1229,7 +1229,7 @@ def remove( wtd = mod.working_tree_dir del mod # Release file-handles (Windows). gc.collect() - rmtree(str(wtd)) + rmtree(wtd) # END delete tree if possible # END handle force diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 3cadb5061..557e8f5b4 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -706,7 +706,7 @@ def _create( if not force and os.path.isfile(abs_ref_path): target_data = str(target) if isinstance(target, SymbolicReference): - target_data = os.fspath(target.path) + target_data = target.path if not resolve: target_data = "ref: " + target_data with open(abs_ref_path, "rb") as fd: @@ -931,4 +931,4 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere def is_remote(self) -> bool: """:return: True if this symbolic reference points to a remote branch""" - return os.fspath(self.path).startswith(self._remote_common_path_default + "/") + return self.path.startswith(self._remote_common_path_default + "/") diff --git a/git/repo/base.py b/git/repo/base.py index 2fe18f48c..e721aea40 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -126,7 +126,7 @@ class Repo: working_dir: PathLike """The working directory of the git command.""" - _working_tree_dir: Optional[PathLike] = None + _working_tree_dir: Optional[str] = None git_dir: PathLike """The ``.git`` repository directory.""" @@ -306,7 +306,7 @@ def __init__( self._working_tree_dir = None # END working dir handling - self.working_dir: PathLike = self._working_tree_dir or self.common_dir + self.working_dir: str = self._working_tree_dir or self.common_dir self.git = self.GitCommandWrapperType(self.working_dir) # Special handling, in special times. @@ -366,7 +366,7 @@ def description(self, descr: str) -> None: fp.write((descr + "\n").encode(defenc)) @property - def working_tree_dir(self) -> Optional[PathLike]: + def working_tree_dir(self) -> Optional[str]: """ :return: The working tree directory of our git repository. diff --git a/git/util.py b/git/util.py index 5326af9d1..94452ab17 100644 --- a/git/util.py +++ b/git/util.py @@ -397,8 +397,7 @@ def _cygexpath(drive: Optional[str], path: str) -> str: p = cygpath(p) elif drive: p = "/proc/cygdrive/%s/%s" % (drive.lower(), p) - p_str = os.fspath(p) # ensure it is a str and not AnyPath - return p_str.replace("\\", "/") + return p.replace("\\", "/") _cygpath_parsers: Tuple[Tuple[Pattern[str], Callable, bool], ...] = ( diff --git a/test/test_index.py b/test/test_index.py index c1db4166b..bca353748 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -579,12 +579,14 @@ def mixed_iterator(): if type_id == 0: # path (str) yield entry.path elif type_id == 1: # path (PathLike) + yield Path(entry.path) + elif type_id == 2: # path mock (PathLike) yield PathLikeMock(entry.path) - elif type_id == 2: # blob + elif type_id == 3: # blob yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) - elif type_id == 3: # BaseIndexEntry + elif type_id == 4: # BaseIndexEntry yield BaseIndexEntry(entry[:4]) - elif type_id == 4: # IndexEntry + elif type_id == 5: # IndexEntry yield entry else: raise AssertionError("Invalid Type") From 17225612969dd68cdfa6dc6b7d5ea8d1956da533 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 13:31:22 +0000 Subject: [PATCH 40/69] Remove redundant str call --- git/repo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/repo/base.py b/git/repo/base.py index e721aea40..72869c562 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1386,7 +1386,7 @@ def _clone( proc = git.clone( multi, "--", - Git.polish_url(str(url)), + Git.polish_url(url), clone_path, with_extended_output=True, as_process=True, From 921ca8a1ddcfe84fce3d6f7f9b50a421cbee9012 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 13:37:12 +0000 Subject: [PATCH 41/69] Limit mypy version due to Cygwin errors --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 75e9e81fa..552496ae4 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ coverage[toml] ddt >= 1.1.1, != 1.4.3 mock ; python_version < "3.8" -mypy +mypy<1.19.0 pre-commit pytest >= 7.3.1 pytest-cov From 12e15ba71a49652af33a8bb1556a24a19dd15c91 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 21:27:12 +0000 Subject: [PATCH 42/69] Validate every fspath with tests --- git/index/base.py | 9 ++++----- git/index/fun.py | 4 ++-- git/index/typ.py | 4 ++-- git/index/util.py | 6 +++--- git/objects/submodule/base.py | 10 +++++----- git/refs/head.py | 2 -- git/refs/log.py | 3 +-- git/refs/symbolic.py | 15 ++++++++------- git/repo/base.py | 14 ++++++++------ git/repo/fun.py | 1 - git/util.py | 13 +++++++------ test/test_index.py | 8 +++++--- test/test_refs.py | 21 ++++++++++++++++++++- test/test_repo.py | 10 ++++++++++ test/test_submodule.py | 6 +++++- 15 files changed, 80 insertions(+), 46 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 2489949c1..43def2f06 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -434,7 +434,7 @@ def raise_exc(e: Exception) -> NoReturn: # characters. if abs_path not in resolved_paths: for f in self._iter_expand_paths(glob.glob(abs_path)): - yield os.fspath(f).replace(rs, "") + yield str(f).replace(rs, "") continue # END glob handling try: @@ -569,7 +569,7 @@ def resolve_blobs(self, iter_blobs: Iterator[Blob]) -> "IndexFile": for blob in iter_blobs: stage_null_key = (blob.path, 0) if stage_null_key in self.entries: - raise ValueError("Path %r already exists at stage 0" % os.fspath(blob.path)) + raise ValueError("Path %r already exists at stage 0" % str(blob.path)) # END assert blob is not stage 0 already # Delete all possible stages. @@ -652,7 +652,6 @@ def _to_relative_path(self, path: PathLike) -> PathLike: :raise ValueError: """ - path = os.fspath(path) if not osp.isabs(path): return path if self.repo.bare: @@ -660,7 +659,7 @@ def _to_relative_path(self, path: PathLike) -> PathLike: if not osp.normpath(path).startswith(os.fspath(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 path.endswith(os.sep) and not result.endswith(os.sep): + if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep): result += os.sep return result @@ -1364,7 +1363,7 @@ def make_exc() -> GitCommandError: if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): - if os.fspath(entry.path).startswith(folder): + if entry.path.startswith(folder): p = entry.path self._write_path_to_stdin(proc, p, p, make_exc, fprogress, read_from_stdout=False) checked_out_files.append(p) diff --git a/git/index/fun.py b/git/index/fun.py index 9832aea6b..629c19b1e 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -87,7 +87,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: return env = os.environ.copy() - env["GIT_INDEX_FILE"] = safe_decode(index.path) + env["GIT_INDEX_FILE"] = safe_decode(os.fspath(index.path)) env["GIT_EDITOR"] = ":" cmd = [hp] try: @@ -167,7 +167,7 @@ def write_cache( beginoffset = tell() write(entry.ctime_bytes) # ctime write(entry.mtime_bytes) # mtime - path_str = os.fspath(entry.path) + path_str = str(entry.path) path: bytes = force_bytes(path_str, encoding=defenc) plen = len(path) & CE_NAMEMASK # Path length assert plen == len(path), "Path %s too long to fit into index" % entry.path diff --git a/git/index/typ.py b/git/index/typ.py index 8273e5895..927633a9f 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -58,9 +58,9 @@ def __init__(self, paths: Sequence[PathLike]) -> None: def __call__(self, stage_blob: Tuple[StageType, Blob]) -> bool: blob_pathlike: PathLike = stage_blob[1].path - blob_path = Path(blob_pathlike) + blob_path: Path = blob_pathlike if isinstance(blob_pathlike, Path) else Path(blob_pathlike) for pathlike in self.paths: - path = Path(pathlike) + path: Path = pathlike if isinstance(pathlike, Path) else Path(pathlike) # TODO: Change to use `PosixPath.is_relative_to` once Python 3.8 is no # longer supported. filter_parts = path.parts diff --git a/git/index/util.py b/git/index/util.py index 80f0b014c..15eba0052 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -37,8 +37,8 @@ class TemporaryFileSwap: __slots__ = ("file_path", "tmp_file_path") def __init__(self, file_path: PathLike) -> None: - self.file_path = os.fspath(file_path) - dirname, basename = osp.split(self.file_path) + self.file_path = file_path + dirname, basename = osp.split(file_path) fd, self.tmp_file_path = tempfile.mkstemp(prefix=basename, dir=dirname) os.close(fd) with contextlib.suppress(OSError): # It may be that the source does not exist. @@ -106,7 +106,7 @@ def git_working_dir(func: Callable[..., _T]) -> Callable[..., _T]: @wraps(func) def set_git_working_dir(self: "IndexFile", *args: Any, **kwargs: Any) -> _T: cur_wd = os.getcwd() - os.chdir(os.fspath(self.repo.working_tree_dir)) + os.chdir(self.repo.working_tree_dir) try: return func(self, *args, **kwargs) finally: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index ca6253883..36ec7c538 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -352,7 +352,7 @@ def _clone_repo( module_abspath_dir = osp.dirname(module_abspath) if not osp.isdir(module_abspath_dir): os.makedirs(module_abspath_dir) - module_checkout_path = osp.join(os.fspath(repo.working_tree_dir), path) + module_checkout_path = osp.join(repo.working_tree_dir, path) if url.startswith("../"): remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name @@ -1016,7 +1016,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool = return self # END handle no change - module_checkout_abspath = join_path_native(os.fspath(self.repo.working_tree_dir), module_checkout_path) + module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path) if osp.isfile(module_checkout_abspath): raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath) # END handle target files @@ -1229,7 +1229,7 @@ def remove( wtd = mod.working_tree_dir del mod # Release file-handles (Windows). gc.collect() - rmtree(wtd) + rmtree(str(wtd)) # END delete tree if possible # END handle force @@ -1313,7 +1313,7 @@ def set_parent_commit(self, commit: Union[Commit_ish, str, None], check: bool = # If check is False, we might see a parent-commit that doesn't even contain the # submodule anymore. in that case, mark our sha as being NULL. try: - self.binsha = pctree[os.fspath(self.path)].binsha + self.binsha = pctree[str(self.path)].binsha except KeyError: self.binsha = self.NULL_BIN_SHA @@ -1395,7 +1395,7 @@ def rename(self, new_name: str) -> "Submodule": destination_module_abspath = self._module_abspath(self.repo, self.path, new_name) source_dir = mod.git_dir # Let's be sure the submodule name is not so obviously tied to a directory. - if os.fspath(destination_module_abspath).startswith(os.fspath(mod.git_dir)): + if str(destination_module_abspath).startswith(str(mod.git_dir)): tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4())) os.renames(source_dir, tmp_dir) source_dir = tmp_dir diff --git a/git/refs/head.py b/git/refs/head.py index 9959b889b..3c43993e7 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -8,7 +8,6 @@ __all__ = ["HEAD", "Head"] -import os from git.config import GitConfigParser, SectionConstraint from git.exc import GitCommandError from git.util import join_path @@ -45,7 +44,6 @@ class HEAD(SymbolicReference): __slots__ = () def __init__(self, repo: "Repo", path: PathLike = _HEAD_NAME) -> None: - path = os.fspath(path) if path != self._HEAD_NAME: raise ValueError("HEAD instance must point to %r, got %r" % (self._HEAD_NAME, path)) super().__init__(repo, path) diff --git a/git/refs/log.py b/git/refs/log.py index a5dfa6d20..4751cff99 100644 --- a/git/refs/log.py +++ b/git/refs/log.py @@ -4,7 +4,6 @@ __all__ = ["RefLog", "RefLogEntry"] from mmap import mmap -import os import os.path as osp import re import time as _time @@ -168,7 +167,7 @@ def __init__(self, filepath: Union[PathLike, None] = None) -> None: """Initialize this instance with an optional filepath, from which we will initialize our data. The path is also used to write changes back using the :meth:`write` method.""" - self._path = None if filepath is None else os.fspath(filepath) + self._path = filepath if filepath is not None: self._read_from_file() # END handle filepath diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 77e4b98f2..a422fb78c 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -4,6 +4,7 @@ __all__ = ["SymbolicReference"] import os +from pathlib import Path from gitdb.exc import BadName, BadObject @@ -76,10 +77,10 @@ class SymbolicReference: def __init__(self, repo: "Repo", path: PathLike, check_path: bool = False) -> None: self.repo = repo - self.path = os.fspath(path) + self.path: PathLike = path def __str__(self) -> str: - return self.path + return os.fspath(self.path) def __repr__(self) -> str: return '' % (self.__class__.__name__, self.path) @@ -103,7 +104,7 @@ def name(self) -> str: In case of symbolic references, the shortest assumable name is the path itself. """ - return self.path + return os.fspath(self.path) @property def abspath(self) -> PathLike: @@ -212,7 +213,7 @@ def _check_ref_name_valid(ref_path: PathLike) -> None: raise ValueError(f"Invalid reference '{ref_path}': references cannot end with a forward slash (/)") elif previous == "@" and one_before_previous is None: raise ValueError(f"Invalid reference '{ref_path}': references cannot be '@'") - elif any(component.endswith(".lock") for component in os.fspath(ref_path).split("/")): + elif any(component.endswith(".lock") for component in Path(ref_path).parts): raise ValueError( f"Invalid reference '{ref_path}': references cannot have slash-separated components that end with" " '.lock'" @@ -235,7 +236,7 @@ def _get_ref_info_helper( tokens: Union[None, List[str], Tuple[str, str]] = None repodir = _git_dir(repo, ref_path) try: - with open(os.path.join(repodir, os.fspath(ref_path)), "rt", encoding="UTF-8") as fp: + with open(os.path.join(repodir, ref_path), "rt", encoding="UTF-8") as fp: value = fp.read().rstrip() # Don't only split on spaces, but on whitespace, which allows to parse lines like: # 60b64ef992065e2600bfef6187a97f92398a9144 branch 'master' of git-server:/path/to/repo @@ -706,7 +707,7 @@ def _create( if not force and os.path.isfile(abs_ref_path): target_data = str(target) if isinstance(target, SymbolicReference): - target_data = target.path + target_data = os.fspath(target.path) if not resolve: target_data = "ref: " + target_data with open(abs_ref_path, "rb") as fd: @@ -930,4 +931,4 @@ def from_path(cls: Type[T_References], repo: "Repo", path: PathLike) -> T_Refere def is_remote(self) -> bool: """:return: True if this symbolic reference points to a remote branch""" - return self.path.startswith(self._remote_common_path_default + "/") + return os.fspath(self.path).startswith(self._remote_common_path_default + "/") diff --git a/git/repo/base.py b/git/repo/base.py index d1af79620..1f543cc57 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -126,7 +126,8 @@ class Repo: working_dir: PathLike """The working directory of the git command.""" - _working_tree_dir: Optional[str] = None + # stored as string for easier processing, but annotated as path for clearer intention + _working_tree_dir: Optional[PathLike] = None git_dir: PathLike """The ``.git`` repository directory.""" @@ -215,13 +216,13 @@ def __init__( epath = path or os.getenv("GIT_DIR") if not epath: epath = os.getcwd() + epath = os.fspath(epath) if Git.is_cygwin(): # Given how the tests are written, this seems more likely to catch Cygwin # git used from Windows than Windows git used from Cygwin. Therefore # changing to Cygwin-style paths is the relevant operation. - epath = cygpath(os.fspath(epath)) + epath = cygpath(epath) - epath = os.fspath(epath) if expand_vars and re.search(self.re_envvars, epath): warnings.warn( "The use of environment variables in paths is deprecated" @@ -306,7 +307,7 @@ def __init__( self._working_tree_dir = None # END working dir handling - self.working_dir: str = self._working_tree_dir or self.common_dir + self.working_dir: PathLike = self._working_tree_dir or self.common_dir self.git = self.GitCommandWrapperType(self.working_dir) # Special handling, in special times. @@ -366,7 +367,7 @@ def description(self, descr: str) -> None: fp.write((descr + "\n").encode(defenc)) @property - def working_tree_dir(self) -> Optional[str]: + def working_tree_dir(self) -> Optional[PathLike]: """ :return: The working tree directory of our git repository. @@ -554,7 +555,7 @@ def tag(self, path: PathLike) -> TagReference: @staticmethod def _to_full_tag_path(path: PathLike) -> str: - path_str = os.fspath(path) + path_str = str(path) if path_str.startswith(TagReference._common_path_default + "/"): return path_str if path_str.startswith(TagReference._common_default + "/"): @@ -1355,6 +1356,7 @@ def _clone( ) -> "Repo": odbt = kwargs.pop("odbt", odb_default_type) + # url may be a path and this has no effect if it is a string url = os.fspath(url) path = os.fspath(path) diff --git a/git/repo/fun.py b/git/repo/fun.py index 49097c373..3f00e60ea 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -62,7 +62,6 @@ def is_git_dir(d: PathLike) -> bool: clearly indicates that we don't support it. There is the unlikely danger to throw if we see directories which just look like a worktree dir, but are none. """ - d = os.fspath(d) if osp.isdir(d): if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir( osp.join(d, "refs") diff --git a/git/util.py b/git/util.py index e82ccdcfa..c3ffdd62b 100644 --- a/git/util.py +++ b/git/util.py @@ -36,7 +36,7 @@ import logging import os import os.path as osp -import pathlib +from pathlib import Path import platform import re import shutil @@ -397,7 +397,8 @@ def _cygexpath(drive: Optional[str], path: str) -> str: p = cygpath(p) elif drive: p = "/proc/cygdrive/%s/%s" % (drive.lower(), p) - return p.replace("\\", "/") + p_str = os.fspath(p) # ensure it is a str and not AnyPath + return p_str.replace("\\", "/") _cygpath_parsers: Tuple[Tuple[Pattern[str], Callable, bool], ...] = ( @@ -464,7 +465,7 @@ def _is_cygwin_git(git_executable: str) -> bool: # Just a name given, not a real path. uname_cmd = osp.join(git_dir, "uname") - if not (pathlib.Path(uname_cmd).is_file() and os.access(uname_cmd, os.X_OK)): + if not (Path(uname_cmd).is_file() and os.access(uname_cmd, os.X_OK)): _logger.debug(f"Failed checking if running in CYGWIN: {uname_cmd} is not an executable") _is_cygwin_cache[git_executable] = is_cygwin return is_cygwin @@ -496,7 +497,7 @@ def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool: elif git_executable is None: return False else: - return _is_cygwin_git(os.fspath(git_executable)) + return _is_cygwin_git(str(git_executable)) def get_user_id() -> str: @@ -522,7 +523,7 @@ def expand_path(p: PathLike, expand_vars: bool = ...) -> str: def expand_path(p: Union[None, PathLike], expand_vars: bool = True) -> Optional[PathLike]: - if isinstance(p, pathlib.Path): + if isinstance(p, Path): return p.resolve() try: p = osp.expanduser(p) # type: ignore[arg-type] @@ -1010,7 +1011,7 @@ class LockFile: __slots__ = ("_file_path", "_owns_lock") def __init__(self, file_path: PathLike) -> None: - self._file_path = os.fspath(file_path) + self._file_path = file_path self._owns_lock = False def __del__(self) -> None: diff --git a/test/test_index.py b/test/test_index.py index bca353748..33490f907 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -582,11 +582,13 @@ def mixed_iterator(): yield Path(entry.path) elif type_id == 2: # path mock (PathLike) yield PathLikeMock(entry.path) - elif type_id == 3: # blob + elif type_id == 3: # path mock in a blob yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) - elif type_id == 4: # BaseIndexEntry + elif type_id == 4: # blob + yield Blob(rw_repo, entry.binsha, entry.mode, entry.path) + elif type_id == 5: # BaseIndexEntry yield BaseIndexEntry(entry[:4]) - elif type_id == 5: # IndexEntry + elif type_id == 6: # IndexEntry yield entry else: raise AssertionError("Invalid Type") diff --git a/test/test_refs.py b/test/test_refs.py index 08096e69e..329515807 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -25,7 +25,7 @@ import git.refs as refs from git.util import Actor -from test.lib import TestBase, with_rw_repo +from test.lib import TestBase, with_rw_repo, PathLikeMock class TestRefs(TestBase): @@ -43,6 +43,25 @@ def test_from_path(self): self.assertRaises(ValueError, TagReference, self.rorepo, "refs/invalid/tag") # Works without path check. TagReference(self.rorepo, "refs/invalid/tag", check_path=False) + # Check remoteness + assert Reference(self.rorepo, "refs/remotes/origin").is_remote() + + def test_from_pathlike(self): + # Should be able to create any reference directly. + for ref_type in (Reference, Head, TagReference, RemoteReference): + for name in ("rela_name", "path/rela_name"): + full_path = ref_type.to_full_path(PathLikeMock(name)) + instance = ref_type.from_path(self.rorepo, PathLikeMock(full_path)) + assert isinstance(instance, ref_type) + # END for each name + # END for each type + + # Invalid path. + self.assertRaises(ValueError, TagReference, self.rorepo, "refs/invalid/tag") + # Works without path check. + TagReference(self.rorepo, PathLikeMock("refs/invalid/tag"), check_path=False) + # Check remoteness + assert Reference(self.rorepo, PathLikeMock("refs/remotes/origin")).is_remote() def test_tag_base(self): tag_object_refs = [] diff --git a/test/test_repo.py b/test/test_repo.py index f089bf6b8..2a92c2523 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -15,6 +15,7 @@ import sys import tempfile from unittest import mock +from pathlib import Path import pytest @@ -369,6 +370,15 @@ def test_is_dirty_with_path(self, rwrepo): assert rwrepo.is_dirty(path="doc") is False assert rwrepo.is_dirty(untracked_files=True, path="doc") is True + @with_rw_repo("HEAD") + def test_is_dirty_with_pathlib_and_pathlike(self, rwrepo): + with open(osp.join(rwrepo.working_dir, "git", "util.py"), "at") as f: + f.write("junk") + assert rwrepo.is_dirty(path=Path("git")) is True + assert rwrepo.is_dirty(path=PathLikeMock("git")) is True + assert rwrepo.is_dirty(path=Path("doc")) is False + assert rwrepo.is_dirty(path=PathLikeMock("doc")) is False + def test_head(self): self.assertEqual(self.rorepo.head.reference.object, self.rorepo.active_branch.object) diff --git a/test/test_submodule.py b/test/test_submodule.py index edff064c4..2bf0940c9 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -28,7 +28,7 @@ from git.repo.fun import find_submodule_git_dir, touch from git.util import HIDE_WINDOWS_KNOWN_ERRORS, join_path_native, to_native_path_linux -from test.lib import TestBase, with_rw_directory, with_rw_repo +from test.lib import TestBase, with_rw_directory, with_rw_repo, PathLikeMock @contextlib.contextmanager @@ -175,6 +175,10 @@ def _do_base_tests(self, rwrepo): sma = Submodule.add(rwrepo, sm.name, sm.path) assert sma.path == sm.path + # Adding existing as pathlike + sma = Submodule.add(rwrepo, sm.name, PathLikeMock(sm.path)) + assert sma.path == sm.path + # No url and no module at path fails. self.assertRaises(ValueError, Submodule.add, rwrepo, "newsubm", "pathtorepo", url=None) From 8434967c010cc108d3a8a01d1db6d0c3971b0048 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sat, 29 Nov 2025 21:46:54 +0000 Subject: [PATCH 43/69] Fix type hints --- git/index/base.py | 10 +++++----- git/index/util.py | 4 ++-- git/objects/submodule/base.py | 2 +- git/refs/symbolic.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/git/index/base.py b/git/index/base.py index 43def2f06..93de7933c 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -404,7 +404,7 @@ def _iter_expand_paths(self: "IndexFile", paths: Sequence[PathLike]) -> Iterator def raise_exc(e: Exception) -> NoReturn: raise e - r = os.fspath(self.repo.working_tree_dir) + r = str(self.repo.working_tree_dir) rs = r + os.sep for path in paths: abs_path = os.fspath(path) @@ -656,7 +656,7 @@ def _to_relative_path(self, path: PathLike) -> PathLike: return path if self.repo.bare: raise InvalidGitRepositoryError("require non-bare repository") - if not osp.normpath(path).startswith(os.fspath(self.repo.working_tree_dir)): + 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): @@ -731,7 +731,7 @@ def _entries_for_paths( for path in paths: if osp.isabs(path): abspath = path - gitrelative_path = path[len(os.fspath(self.repo.working_tree_dir)) + 1 :] + gitrelative_path = path[len(str(self.repo.working_tree_dir)) + 1 :] else: gitrelative_path = path if self.repo.working_tree_dir: @@ -1036,7 +1036,7 @@ def remove( args.append("--") # Preprocess paths. - paths = self._items_to_rela_paths(items) + paths = list(map(os.fspath, self._items_to_rela_paths(items))) # type: ignore[arg-type] removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines() # Process output to gain proper paths. @@ -1363,7 +1363,7 @@ def make_exc() -> GitCommandError: if not folder.endswith("/"): folder += "/" for entry in self.entries.values(): - if entry.path.startswith(folder): + if os.fspath(entry.path).startswith(folder): p = entry.path self._write_path_to_stdin(proc, p, p, make_exc, fprogress, read_from_stdout=False) checked_out_files.append(p) diff --git a/git/index/util.py b/git/index/util.py index 15eba0052..982a5afb7 100644 --- a/git/index/util.py +++ b/git/index/util.py @@ -15,7 +15,7 @@ # typing ---------------------------------------------------------------------- -from typing import Any, Callable, TYPE_CHECKING, Optional, Type +from typing import Any, Callable, TYPE_CHECKING, Optional, Type, cast from git.types import Literal, PathLike, _T @@ -106,7 +106,7 @@ def git_working_dir(func: Callable[..., _T]) -> Callable[..., _T]: @wraps(func) def set_git_working_dir(self: "IndexFile", *args: Any, **kwargs: Any) -> _T: cur_wd = os.getcwd() - os.chdir(self.repo.working_tree_dir) + os.chdir(cast(PathLike, self.repo.working_tree_dir)) try: return func(self, *args, **kwargs) finally: diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 36ec7c538..d183672db 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -352,7 +352,7 @@ def _clone_repo( module_abspath_dir = osp.dirname(module_abspath) if not osp.isdir(module_abspath_dir): os.makedirs(module_abspath_dir) - module_checkout_path = osp.join(repo.working_tree_dir, path) + module_checkout_path = osp.join(repo.working_tree_dir, path) # type: ignore[arg-type] if url.startswith("../"): remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index a422fb78c..99af4f57c 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -236,7 +236,7 @@ def _get_ref_info_helper( tokens: Union[None, List[str], Tuple[str, str]] = None repodir = _git_dir(repo, ref_path) try: - with open(os.path.join(repodir, ref_path), "rt", encoding="UTF-8") as fp: + with open(os.path.join(repodir, ref_path), "rt", encoding="UTF-8") as fp: # type: ignore[arg-type] value = fp.read().rstrip() # Don't only split on spaces, but on whitespace, which allows to parse lines like: # 60b64ef992065e2600bfef6187a97f92398a9144 branch 'master' of git-server:/path/to/repo From 171062655e24b6a6ca1a3beab3c7679278350ab5 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Mon, 1 Dec 2025 16:44:37 +0000 Subject: [PATCH 44/69] Add tests with non-ascii characters --- test/test_clone.py | 7 +++++++ test/test_index.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/test/test_clone.py b/test/test_clone.py index 143a3b51f..2d00a9e79 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -52,6 +52,13 @@ def test_clone_from_pathlike(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) + @with_rw_directory + def test_clone_from_pathlike_unicode_repr(self, rw_dir): + original_repo = Repo.init(osp.join(rw_dir, "repo-áēñöưḩ̣")) + Repo.clone_from( + PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike-áēñöưḩ̣")) + ) + @with_rw_directory def test_clone_from_pathlib_withConfig(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) diff --git a/test/test_index.py b/test/test_index.py index 33490f907..dcdc3b56d 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1212,6 +1212,15 @@ def test_index_add_pathlike(self, rw_repo): rw_repo.index.add(PathLikeMock(str(file))) + @with_rw_repo("HEAD") + def test_index_add_pathlike_unicode(self, rw_repo): + git_dir = Path(rw_repo.git_dir) + + file = git_dir / "file-áēñöưḩ̣.txt" + file.touch() + + rw_repo.index.add(PathLikeMock(str(file))) + @with_rw_repo("HEAD") def test_index_add_non_normalized_path(self, rw_repo): git_dir = Path(rw_repo.git_dir) From 0cb55fb4adca4f2b26767e85ef8652ef13b834a1 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 5 Dec 2025 18:12:07 +0000 Subject: [PATCH 45/69] Revert "Add tests with non-ascii characters" This reverts commit 171062655e24b6a6ca1a3beab3c7679278350ab5. --- test/test_clone.py | 7 ------- test/test_index.py | 9 --------- 2 files changed, 16 deletions(-) diff --git a/test/test_clone.py b/test/test_clone.py index 2d00a9e79..143a3b51f 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -52,13 +52,6 @@ def test_clone_from_pathlike(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) Repo.clone_from(PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike"))) - @with_rw_directory - def test_clone_from_pathlike_unicode_repr(self, rw_dir): - original_repo = Repo.init(osp.join(rw_dir, "repo-áēñöưḩ̣")) - Repo.clone_from( - PathLikeMock(original_repo.git_dir), PathLikeMock(os.path.join(rw_dir, "clone_pathlike-áēñöưḩ̣")) - ) - @with_rw_directory def test_clone_from_pathlib_withConfig(self, rw_dir): original_repo = Repo.init(osp.join(rw_dir, "repo")) diff --git a/test/test_index.py b/test/test_index.py index dcdc3b56d..33490f907 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1212,15 +1212,6 @@ def test_index_add_pathlike(self, rw_repo): rw_repo.index.add(PathLikeMock(str(file))) - @with_rw_repo("HEAD") - def test_index_add_pathlike_unicode(self, rw_repo): - git_dir = Path(rw_repo.git_dir) - - file = git_dir / "file-áēñöưḩ̣.txt" - file.touch() - - rw_repo.index.add(PathLikeMock(str(file))) - @with_rw_repo("HEAD") def test_index_add_non_normalized_path(self, rw_repo): git_dir = Path(rw_repo.git_dir) From f738029ab05fe8356022248e68f9119c46b2f1e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:06:22 +0000 Subject: [PATCH 46/69] Bump git/ext/gitdb from `65321a2` to `4c63ee6` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `65321a2` to `4c63ee6`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/65321a28b586df60b9d1508228e2f53a35f938eb...4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 65321a28b..4c63ee663 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 65321a28b586df60b9d1508228e2f53a35f938eb +Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a From 9fa28ae108dc39cfb13282cd18d4251d0118dd52 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 12 Dec 2025 21:41:25 +0000 Subject: [PATCH 47/69] Add failing tests for joining paths --- test/test_tree.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/test_tree.py b/test/test_tree.py index 7ba93bd36..dafd32847 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -8,10 +8,14 @@ from pathlib import Path import subprocess +import pytest + from git.objects import Blob, Tree +from git.repo import Repo from git.util import cwd from test.lib import TestBase, with_rw_directory +from .lib.helper import PathLikeMock, with_rw_repo class TestTree(TestBase): @@ -161,3 +165,57 @@ def lib_folder(t, _d): assert root[item.path] == item == root / item.path # END for each item assert found_slash + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / ".gitignore" + assert isinstance(blob, Blob) + assert blob.hexsha == "787b3d442a113b78e343deb585ab5531eb7187fa" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / PathLikeMock(".gitignore") + assert isinstance(blob, Blob) + assert blob.hexsha == "787b3d442a113b78e343deb585ab5531eb7187fa" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_invalid_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + with pytest.raises(KeyError): + repo.tree() / "doesnotexist" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_invalid_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + with pytest.raises(KeyError): + repo.tree() / PathLikeMock("doesnotexist") + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_nested_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / "git/__init__.py" + assert isinstance(blob, Blob) + assert blob.hexsha == "d87dcbdbb65d2782e14eea27e7f833a209c052f3" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_nested_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / PathLikeMock("git/__init__.py") + assert isinstance(blob, Blob) + assert blob.hexsha == "d87dcbdbb65d2782e14eea27e7f833a209c052f3" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_folder_string_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / "git" + assert isinstance(blob, Tree) + assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + + @with_rw_repo("0.3.2.1") + def test_repo_lookup_folder_pathlike_path(self, rw_repo): + repo = Repo(rw_repo.git_dir) + blob = repo.tree() / PathLikeMock("git") + assert isinstance(blob, Tree) + assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" From 88e26141c738f6ac3beb1a433039611f88c2c30d Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 12 Dec 2025 21:41:50 +0000 Subject: [PATCH 48/69] Allow joining path to tree --- git/objects/tree.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/git/objects/tree.py b/git/objects/tree.py index 1845d0d0d..a3d611c80 100644 --- a/git/objects/tree.py +++ b/git/objects/tree.py @@ -5,6 +5,7 @@ __all__ = ["TreeModifier", "Tree"] +import os import sys import git.diff as git_diff @@ -230,7 +231,7 @@ def _iter_convert_to_object(self, iterable: Iterable[TreeCacheTup]) -> Iterator[ raise TypeError("Unknown mode %o found in tree data for path '%s'" % (mode, path)) from e # END for each item - def join(self, file: str) -> IndexObjUnion: + def join(self, file: PathLike) -> IndexObjUnion: """Find the named object in this tree's contents. :return: @@ -241,6 +242,7 @@ def join(self, file: str) -> IndexObjUnion: If the given file or tree does not exist in this tree. """ msg = "Blob or Tree named %r not found" + file = os.fspath(file) if "/" in file: tree = self item = self @@ -269,7 +271,7 @@ def join(self, file: str) -> IndexObjUnion: raise KeyError(msg % file) # END handle long paths - def __truediv__(self, file: str) -> IndexObjUnion: + def __truediv__(self, file: PathLike) -> IndexObjUnion: """The ``/`` operator is another syntax for joining. See :meth:`join` for details. From c8b58c09904dabe67222165e4d3eecf4c8f07490 Mon Sep 17 00:00:00 2001 From: George Ogden <38294960+George-Ogden@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:42:21 +0000 Subject: [PATCH 49/69] Update test/test_tree.py Rename blob to tree Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/test_tree.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_tree.py b/test/test_tree.py index dafd32847..629fd4d32 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -209,13 +209,13 @@ def test_repo_lookup_nested_pathlike_path(self, rw_repo): @with_rw_repo("0.3.2.1") def test_repo_lookup_folder_string_path(self, rw_repo): repo = Repo(rw_repo.git_dir) - blob = repo.tree() / "git" - assert isinstance(blob, Tree) - assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + tree = repo.tree() / "git" + assert isinstance(tree, Tree) + assert tree.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" @with_rw_repo("0.3.2.1") def test_repo_lookup_folder_pathlike_path(self, rw_repo): repo = Repo(rw_repo.git_dir) - blob = repo.tree() / PathLikeMock("git") - assert isinstance(blob, Tree) - assert blob.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" + tree = repo.tree() / PathLikeMock("git") + assert isinstance(tree, Tree) + assert tree.hexsha == "ec8ae429156d65afde4bbb3455570193b56f0977" From 9e24eb6b72c1851e46e09133b83b48f2059037d7 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 1 Jan 2026 16:32:19 +0100 Subject: [PATCH 50/69] Prepare next release --- VERSION | 2 +- doc/source/changes.rst | 34 ++++++++++++++++++++-------------- git/ext/gitdb | 2 +- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index 3c91929a4..fd84d1e83 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.45 +3.1.46 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 151059ed2..9b82e7513 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.46 +====== + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.46 + 3.1.45 ====== @@ -111,7 +117,7 @@ https://github.com/gitpython-developers/gitpython/milestone/61?closed=1 but a necessary fix for https://github.com/gitpython-developers/GitPython/issues/1515. Please take a look at the PR for more information and how to bypass these protections in case they cause breakage: https://github.com/gitpython-developers/GitPython/pull/1521. - + See the following for all changes. https://github.com/gitpython-developers/gitpython/milestone/60?closed=1 @@ -176,38 +182,38 @@ https://github.com/gitpython-developers/gitpython/milestone/53?closed=1 * General: - Remove python 3.6 support - + - Remove distutils ahead of deprecation in standard library. - + - Update sphinx to 4.1.12 and use autodoc-typehints. - + - Include README as long_description on PyPI - + - Test against earliest and latest minor version available on Github Actions (e.g. 3.9.0 and 3.9.7) - + * Typing: - Add types to ALL functions. - + - Ensure py.typed is collected. - + - Increase mypy strictness with disallow_untyped_defs, warn_redundant_casts, warn_unreachable. - + - Use typing.NamedTuple and typing.OrderedDict now 3.6 dropped. - + - Make Protocol classes ABCs at runtime due to new behaviour/bug in 3.9.7 & 3.10.0-rc1 - + - Remove use of typing.TypeGuard until later release, to allow dependent libs time to update. - + - Tracking issue: https://github.com/gitpython-developers/GitPython/issues/1095 * Runtime improvements: - Add clone_multi_options support to submodule.add() - + - Delay calling get_user_id() unless essential, to support sand-boxed environments. - + - Add timeout to handle_process_output(), in case thread.join() hangs. See the following for details: diff --git a/git/ext/gitdb b/git/ext/gitdb index 4c63ee663..335c0f661 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a +Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 From 0b45d122c68cd7de9e0fbcd9f6cc336bee9371fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:05:40 +0000 Subject: [PATCH 51/69] Bump git/ext/gitdb from `335c0f6` to `4c63ee6` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `335c0f6` to `4c63ee6`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/335c0f66173eecdc7b2597c2b6c3d1fde795df30...4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 335c0f661..4c63ee663 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30 +Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a From acc6a8cc83188b107344797cdf6b88398fef0676 Mon Sep 17 00:00:00 2001 From: Ilyas Timour Date: Sat, 10 Jan 2026 11:07:29 +0100 Subject: [PATCH 52/69] DOC: README Add urls and updated a relative url --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 59c6f995b..412d38205 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ by setting the `GIT_PYTHON_GIT_EXECUTABLE=` environment variable. - Git (1.7.x or newer) - Python >= 3.7 -The list of dependencies are listed in `./requirements.txt` and `./test-requirements.txt`. +The list of dependencies are listed in [`./requirements.txt`](https://github.com/gitpython-developers/GitPython/blob/main/requirements.txt) and [`./test-requirements.txt`](https://github.com/gitpython-developers/GitPython/blob/main/test-requirements.txt). The installer takes care of installing them for you. ### INSTALL @@ -180,7 +180,7 @@ Style and formatting checks, and running tests on all the different supported Py #### Configuration files -Specific tools are all configured in the `./pyproject.toml` file: +Specific tools are all configured in the [`./pyproject.toml`](https://github.com/gitpython-developers/GitPython/blob/main/pyproject.toml) file: - `pytest` (test runner) - `coverage.py` (code coverage) @@ -189,9 +189,9 @@ Specific tools are all configured in the `./pyproject.toml` file: Orchestration tools: -- Configuration for `pre-commit` is in the `./.pre-commit-config.yaml` file. -- Configuration for `tox` is in `./tox.ini`. -- Configuration for GitHub Actions (CI) is in files inside `./.github/workflows/`. +- Configuration for `pre-commit` is in the [`./.pre-commit-config.yaml`](https://github.com/gitpython-developers/GitPython/blob/main/.pre-commit-config.yaml) file. +- Configuration for `tox` is in [`./tox.ini`](https://github.com/gitpython-developers/GitPython/blob/main/tox.ini). +- Configuration for GitHub Actions (CI) is in files inside [`./.github/workflows/`](https://github.com/gitpython-developers/GitPython/tree/main/.github/workflows). ### Contributions @@ -212,8 +212,8 @@ Please have a look at the [contributions file][contributing]. ### How to make a new release -1. Update/verify the **version** in the `VERSION` file. -2. Update/verify that the `doc/source/changes.rst` changelog file was updated. It should include a link to the forthcoming release page: `https://github.com/gitpython-developers/GitPython/releases/tag/` +1. Update/verify the **version** in the [`VERSION`](https://github.com/gitpython-developers/GitPython/blob/main/VERSION) file. +2. Update/verify that the [`doc/source/changes.rst`](https://github.com/gitpython-developers/GitPython/blob/main/doc/source/changes.rst) changelog file was updated. It should include a link to the forthcoming release page: `https://github.com/gitpython-developers/GitPython/releases/tag/` 3. Commit everything. 4. Run `git tag -s ` to tag the version in Git. 5. _Optionally_ create and activate a [virtual environment](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment). (Then the next step can install `build` and `twine`.) @@ -240,7 +240,7 @@ Please have a look at the [contributions file][contributing]. [3-Clause BSD License](https://opensource.org/license/bsd-3-clause/), also known as the New BSD License. See the [LICENSE file][license]. -One file exclusively used for fuzz testing is subject to [a separate license, detailed here](./fuzzing/README.md#license). +One file exclusively used for fuzz testing is subject to [a separate license, detailed here](https://github.com/gitpython-developers/GitPython/blob/main/fuzzing/README.md#license). This file is not included in the wheel or sdist packages published by the maintainers of GitPython. [contributing]: https://github.com/gitpython-developers/GitPython/blob/main/CONTRIBUTING.md From 1a74bce18b5492a3cec9b839a4cb706d78eca466 Mon Sep 17 00:00:00 2001 From: danielyan Date: Mon, 9 Feb 2026 20:03:39 +0000 Subject: [PATCH 53/69] Fix GitConfigParser ignoring multiple [include] path entries When an [include] section has multiple entries with the same key (e.g. multiple 'path' values), only the last one was respected. This is because _included_paths() used self.items(section) which delegates to _OMD.items(), and _OMD.__getitem__ returns only the last value for a given key. Fix by using _OMD.items_all() to retrieve all values for each key in the include/includeIf sections, ensuring all paths are processed. Fixes gitpython-developers#2099 --- git/config.py | 18 ++++++++++++++---- test/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/git/config.py b/git/config.py index 769929441..c6eaf8f7b 100644 --- a/git/config.py +++ b/git/config.py @@ -549,11 +549,21 @@ def _included_paths(self) -> List[Tuple[str, str]]: :return: The list of paths, where each path is a tuple of (option, value). """ + + def _all_items(section: str) -> List[Tuple[str, str]]: + """Return all (key, value) pairs for a section, including duplicate keys.""" + return [ + (key, value) + for key, values in self._sections[section].items_all() + if key != "__name__" + for value in values + ] + paths = [] for section in self.sections(): if section == "include": - paths += self.items(section) + paths += _all_items(section) match = CONDITIONAL_INCLUDE_REGEXP.search(section) if match is None or self._repo is None: @@ -579,7 +589,7 @@ def _included_paths(self) -> List[Tuple[str, str]]: ) if self._repo.git_dir: if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value): - paths += self.items(section) + paths += _all_items(section) elif keyword == "onbranch": try: @@ -589,11 +599,11 @@ def _included_paths(self) -> List[Tuple[str, str]]: continue if fnmatch.fnmatchcase(branch_name, value): - paths += self.items(section) + paths += _all_items(section) elif keyword == "hasconfig:remote.*.url": for remote in self._repo.remotes: if fnmatch.fnmatchcase(remote.url, value): - paths += self.items(section) + paths += _all_items(section) break return paths diff --git a/test/test_config.py b/test/test_config.py index 56ac0f304..11ea52d16 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -246,6 +246,43 @@ def check_test_value(cr, value): with GitConfigParser(fpa, read_only=True) as cr: check_test_value(cr, tv) + @with_rw_directory + def test_multiple_include_paths_with_same_key(self, rw_dir): + """Test that multiple 'path' entries under [include] are all respected. + + Regression test for https://github.com/gitpython-developers/GitPython/issues/2099. + Git config allows multiple ``path`` values under ``[include]``, e.g.:: + + [include] + path = file1 + path = file2 + + Previously only one of these was included because _OMD.items() returns + only the last value for each key. + """ + # Create two config files to be included. + fp_inc1 = osp.join(rw_dir, "inc1.cfg") + fp_inc2 = osp.join(rw_dir, "inc2.cfg") + fp_main = osp.join(rw_dir, "main.cfg") + + with GitConfigParser(fp_inc1, read_only=False) as cw: + cw.set_value("user", "name", "from-inc1") + + with GitConfigParser(fp_inc2, read_only=False) as cw: + cw.set_value("core", "bar", "from-inc2") + + # Write a config with two path entries under a single [include] section. + # We write it manually because set_value would overwrite the key. + with open(fp_main, "w") as f: + f.write("[include]\n") + f.write(f"\tpath = {fp_inc1}\n") + f.write(f"\tpath = {fp_inc2}\n") + + with GitConfigParser(fp_main, read_only=True) as cr: + # Both included files should be loaded. + 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")', From 2f6e5441b323a7fbde672e3df30e564628d43371 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 12:32:24 -0400 Subject: [PATCH 54/69] Switch back from Alpine to Debian for WSL For #2107. Note that this does not affect the container workflow `alpine-test.yml` workflow (that is, it doesn't affect actually running the test suite in Alpine Linux, which continues to work), only WSL in Windows jobs in the main workflow `pythonpackage.yml`. --- .github/workflows/pythonpackage.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index ac764d9a7..6c5cf4552 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -54,8 +54,7 @@ jobs: uses: Vampire/setup-wsl@v6.0.0 with: wsl-version: 1 - distribution: Alpine - additional-packages: bash + distribution: Debian - name: Prepare this repo for tests run: | From 9648077f1913b41498b55f253a5188b4ba4b27dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:58:20 +0000 Subject: [PATCH 55/69] Bump git/ext/gitdb from `4c63ee6` to `5c1b303` Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `4c63ee6` to `5c1b303`. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a...5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe) --- updated-dependencies: - dependency-name: git/ext/gitdb dependency-version: 5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- git/ext/gitdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/ext/gitdb b/git/ext/gitdb index 4c63ee663..5c1b3036a 160000 --- a/git/ext/gitdb +++ b/git/ext/gitdb @@ -1 +1 @@ -Subproject commit 4c63ee6636a6a3370f58b05d0bd19fec2f16dd5a +Subproject commit 5c1b3036a6e34782e0ab6ce85e5ae64fe777fdbe From 98b78d2c1558e4d2fef1d52ecfd30c15f181c38b Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 13:27:16 -0400 Subject: [PATCH 56/69] Run `gc.collect()` twice in `test_rename` on Python 3.12 Recently, the conditional `gc.collect()` step for Python >= 3.12 in `TestSubmodule.test_rename` is often insufficient. This has mainly been seen in #2248. For example: https://github.com/gitpython-developers/GitPython/actions/runs/22864869684/job/66331124651?pr=2106#step:12:620 In principle, there can be situations with finalizers where a cycle is only collectable due to finalization that happened due to a previous collection. Therefore, there is occasionally a benefit to collecting twice. This does that, in the hope that it will help. --- test/test_submodule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_submodule.py b/test/test_submodule.py index 2bf0940c9..47647f2a1 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1011,6 +1011,7 @@ def test_rename(self, rwdir): # garbage collector detailed in https://github.com/python/cpython/issues/97922.) if sys.platform == "win32" and sys.version_info >= (3, 12): gc.collect() + gc.collect() # Some finalizer scenarios need two collections, at least in theory. new_path = "renamed/myname" assert sm.move(new_path).name == new_path From 3cfd22938581631cc22f80ea751eecea1747a1d8 Mon Sep 17 00:00:00 2001 From: Luca Weyrich Date: Wed, 25 Feb 2026 09:04:29 +0100 Subject: [PATCH 57/69] fix: ignore AutoInterrupt terminate errors during shutdown --- git/cmd.py | 8 ++++++-- test/test_autointerrupt.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 test/test_autointerrupt.py diff --git a/git/cmd.py b/git/cmd.py index 15d7820df..78a9f4c78 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -368,8 +368,12 @@ def _terminate(self) -> None: status = proc.wait() # Ensure the process goes away. self.status = self._status_code_if_terminate or status - except OSError as ex: - _logger.info("Ignored error after process had died: %r", ex) + except (OSError, AttributeError) as ex: + # On interpreter shutdown (notably on Windows), parts of the stdlib used by + # subprocess can already be torn down (e.g. `subprocess._winapi` becomes None), + # which can cause AttributeError during terminate(). In that case, we prefer + # to silently ignore to avoid noisy "Exception ignored in: __del__" messages. + _logger.info("Ignored error while terminating process: %r", ex) # END exception handling def __del__(self) -> None: diff --git a/test/test_autointerrupt.py b/test/test_autointerrupt.py new file mode 100644 index 000000000..56e101efb --- /dev/null +++ b/test/test_autointerrupt.py @@ -0,0 +1,35 @@ +import pytest + +from git.cmd import Git + + +class _DummyProc: + """Minimal stand-in for subprocess.Popen used to exercise AutoInterrupt. + + We deliberately raise AttributeError from terminate() to simulate interpreter + shutdown on Windows where subprocess internals (e.g. subprocess._winapi) may + already be torn down. + """ + + stdin = None + stdout = None + stderr = None + + def poll(self): + return None + + def terminate(self): + raise AttributeError("TerminateProcess") + + def wait(self): # pragma: no cover - should not be reached in this test + raise AssertionError("wait() should not be called if terminate() fails") + + +def test_autointerrupt_terminate_ignores_attributeerror(): + ai = Git.AutoInterrupt(_DummyProc(), args=["git", "rev-list"]) + + # Should not raise, even if terminate() triggers AttributeError. + ai._terminate() + + # Ensure the reference is cleared to avoid repeated attempts. + assert ai.proc is None From 357aad19912087b9819844acb3b904cf3c23b29e Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 13:16:43 -0400 Subject: [PATCH 58/69] Remove unnecessary `pytest` import in a test module --- test/test_autointerrupt.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_autointerrupt.py b/test/test_autointerrupt.py index 56e101efb..645ec402c 100644 --- a/test/test_autointerrupt.py +++ b/test/test_autointerrupt.py @@ -1,5 +1,3 @@ -import pytest - from git.cmd import Git From 078f3514f5b70052c0126e03713f1e543b26e8c3 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 14:29:43 -0400 Subject: [PATCH 59/69] Run the the `pre-commit` CI job on `ubuntu-slim` The `ubuntu-slim` runner is lighter weight, being a container rather than using a whole VM, and having only one vCPU, less RAM, and a 15 minute time limit. It's not suitable for most of our CI jobs in GitPython, but it should work well for our `pre-commit` checks. (If it doesn't, that's reason to suspect they might be better removed from `pre-commit` and run in a different way.) - https://github.blog/changelog/2026-01-22-1-vcpu-linux-runner-now-generally-available-in-github-actions/ - https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 956b38963..e32e946c8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ permissions: jobs: lint: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: actions/checkout@v6 From 35db8094038a817780f614a518663362aec11a4c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 14:38:42 -0400 Subject: [PATCH 60/69] Keep `pre-commit` hooks up to date using Dependabot - Add `pre-commit` as an ecosystem for Dependabot version updates, now that it is available as a beta ecosystem. Enable beta ecosystems to allow this. - Group the updates and use a monthly cadence to avoid getting swamped by frequent automated PRs. - It would be valuable in the future to Use a 7-day cooldown period rather than taking new versions immediately once released. (This may also be of value to developers who use `pre-commit` locally.) However, this doesn't do that, since the Dependabot ecosystem for `pre-commit` does not currently support `cooldown`. - Use a less busy style (less unnecessary quoting) than was being used in `dependabot.yml` before, since this new stanza is more elaborate than before. Apply that style to the existing stanzas for consistency. --- .github/dependabot.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2fe73ca77..16d5f11bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,20 @@ version: 2 +enable-beta-ecosystems: true updates: -- package-ecosystem: "github-actions" +- package-ecosystem: github-actions directory: "/" schedule: - interval: "weekly" + interval: weekly -- package-ecosystem: "gitsubmodule" +- package-ecosystem: gitsubmodule directory: "/" schedule: - interval: "weekly" + interval: weekly + +- package-ecosystem: pre-commit + directory: "/" + schedule: + interval: monthly + groups: + pre-commit: + patterns: ["*"] From a4bc27a7ae6b74df397de3f15fd3ee97fa06d28c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:48:14 +0000 Subject: [PATCH 61/69] Bump the pre-commit group with 5 updates Bumps the pre-commit group with 5 updates: | Package | From | To | | --- | --- | --- | | [https://github.com/codespell-project/codespell](https://github.com/codespell-project/codespell) | `v2.4.1` | `2.4.2` | | [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) | `v0.11.12` | `0.15.5` | | [https://github.com/shellcheck-py/shellcheck-py](https://github.com/shellcheck-py/shellcheck-py) | `v0.10.0.1` | `0.11.0.1` | | [https://github.com/pre-commit/pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks) | `v5.0.0` | `6.0.0` | | [https://github.com/abravalheri/validate-pyproject](https://github.com/abravalheri/validate-pyproject) | `v0.24.1` | `0.25` | Updates `https://github.com/codespell-project/codespell` from v2.4.1 to 2.4.2 - [Release notes](https://github.com/codespell-project/codespell/releases) - [Commits](https://github.com/codespell-project/codespell/compare/v2.4.1...v2.4.2) Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.11.12 to 0.15.5 - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.12...v0.15.5) Updates `https://github.com/shellcheck-py/shellcheck-py` from v0.10.0.1 to 0.11.0.1 - [Commits](https://github.com/shellcheck-py/shellcheck-py/compare/v0.10.0.1...v0.11.0.1) Updates `https://github.com/pre-commit/pre-commit-hooks` from v5.0.0 to 6.0.0 - [Release notes](https://github.com/pre-commit/pre-commit-hooks/releases) - [Changelog](https://github.com/pre-commit/pre-commit-hooks/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0) Updates `https://github.com/abravalheri/validate-pyproject` from v0.24.1 to 0.25 - [Release notes](https://github.com/abravalheri/validate-pyproject/releases) - [Changelog](https://github.com/abravalheri/validate-pyproject/blob/main/CHANGELOG.rst) - [Commits](https://github.com/abravalheri/validate-pyproject/compare/v0.24.1...v0.25) --- updated-dependencies: - dependency-name: https://github.com/codespell-project/codespell dependency-version: 2.4.2 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.5 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/shellcheck-py/shellcheck-py dependency-version: 0.11.0.1 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/pre-commit/pre-commit-hooks dependency-version: 6.0.0 dependency-type: direct:production dependency-group: pre-commit - dependency-name: https://github.com/abravalheri/validate-pyproject dependency-version: '0.25' dependency-type: direct:production dependency-group: pre-commit ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 737b56d45..3bd9cbce9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ repos: - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell additional_dependencies: [tomli] exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.12 + rev: v0.15.5 hooks: - id: ruff-check args: ["--fix"] @@ -16,14 +16,14 @@ repos: exclude: ^git/ext/ - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.10.0.1 + rev: v0.11.0.1 hooks: - id: shellcheck args: [--color] exclude: ^test/fixtures/polyglot$|^git/ext/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer exclude: ^test/fixtures/|COPYING|LICENSE @@ -33,6 +33,6 @@ repos: - id: check-merge-conflict - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.24.1 + rev: v0.25 hooks: - id: validate-pyproject From d1ab2e40b3bd03f84dcf440ed920bf7ab512366f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 14:17:27 -0400 Subject: [PATCH 62/69] Test free-threaded interpreter on macOS As discussed in #2005 and #2011, we had not been doing this before. Conditions have changed in two relevant ways: - The free-threaded interpreter has been around longer and it sees more use. - The macOS runners are very fast now. The specific motivations for doing this now are: - In view of the condition described in #2109 and how the change there seems to have helped with it, there's some reason to think *patch* versions of Python sometimes affect GitPython in ways it makes possibly unfounded assumptions about the effect of garbage collection. This mainly affects Windows and it is not specific to free-threaded builds. However, in principle we could also see assumptions violated in tests we think always work on Unix-like operating systems, due to differences in how garbage collection works in free-threaded interpreters. Therefore, the assumption that this only needs to be tested occasionally is not as well founded I assumed when I suggested testing it only on GNU/Linux. - We may add 3.14 jobs to CI soon, and it's useful to be able to see how both free-threaded interpreters work on CI, as well as to confirm for at least a short while that they are continuing to work as expected. This macOS free-threaded interpreter CI jobs could be disabled once more if necessary, or if they're found to make CI complete slower in PRs by even a small amount so long as they don't seem to be surfacing anything. --- .github/workflows/pythonpackage.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6c5cf4552..6c1f7a67a 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,8 +17,6 @@ jobs: exclude: - os-type: macos python-version: "3.7" # Not available for the ARM-based macOS runners. - - os-type: macos - python-version: "3.13t" - os-type: windows python-version: "3.13" # FIXME: Fix and enable Python 3.13 on Windows (#1955). - os-type: windows From 53c0a8800bc4297c2b50c14e8ada45bdaedfe2ab Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 15:15:28 -0400 Subject: [PATCH 63/69] Test Python 3.14 on Ubuntu and macOS on CI The status of 3.14 is now effectively the same as the status of 3.13 when we started testing it in #1990. This doesn't enable it on Windows yet, for the same reason that we still have not yet enabled regular CI tests of 3.13 on Windows. It is hoped that 3.13 and 3.14 can be gotten fully working on Windows (rather than just mostly working, we think) soon; these exclusions are meant to be temporary. Both the usual GIL interpreter and the free-threaded (nogil) intepreters are tested. See the immediately preceding commit on the tradeoffs involved in doing so. --- .github/workflows/pythonpackage.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6c1f7a67a..3d2cb9b63 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -13,14 +13,18 @@ jobs: strategy: matrix: os-type: [ubuntu, macos, windows] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t"] exclude: - os-type: macos python-version: "3.7" # Not available for the ARM-based macOS runners. - os-type: windows - python-version: "3.13" # FIXME: Fix and enable Python 3.13 on Windows (#1955). + python-version: "3.13" # FIXME: Fix and enable Python 3.13 and 3.14 on Windows (#1955). - os-type: windows python-version: "3.13t" + - os-type: windows + python-version: "3.14" + - os-type: windows + python-version: "3.14t" include: - os-ver: latest - os-type: ubuntu From d1ca2af85b96062a9d5979edf895ddf3968cfa49 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 17:42:21 -0400 Subject: [PATCH 64/69] Upgrade Sphinx to ~7.4.7 Except on Python 3.8, where 7.1.2 is the latest compatible version. (This would also apply to versions lower than 3.8, but we don't support building docs on any such versions, even though we still support installing and using GitPython on 3.7.) The reason for this change is that, starting in Python 3.14, the `ast` module no longer has a `Str` member. String literals are instead represented by `ast.Constant` (and the type of the value can be checked to see if it's a string). But versions of `sphinx` lower than 7.2.0 rely on `ast.Str` being present. This causes our documentation not to be able to build at all starting in 3.14. The most important part of the error is: Exception occurred: File "/opt/hostedtoolcache/Python/3.14.3/x64/lib/python3.14/site-packages/sphinx/pycode/__init__.py", line 141, in analyze raise PycodeError(f'parsing {self.srcname!r} failed: {exc!r}') from exc sphinx.errors.PycodeError: parsing '/home/runner/work/GitPython/GitPython/git/index/base.py' failed: AttributeError("module 'ast' has no attribute 'Str'") An example of code in `sphinx` 7.1.2 that will cause such an error is `sphinx.pycode.parser.visit_Expr` implementation, which starts: if (isinstance(self.previous, (ast.Assign, ast.AnnAssign)) and isinstance(node.value, ast.Str)): In `sphinx` 7.2.0, `sphinx.pycode.parser.visit_Expr` instead begins: if (isinstance(self.previous, (ast.Assign, ast.AnnAssign)) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)): This upgrades `sphinx` on all versions of Python where it *can* be installed at a version that has such changes -- rather than only on Python 3.14 -- for consistency, including consistency in possible minor variations in generated documentation that could otherwise arise from using different versions of `sphinx` unnecessarily. As for why this upgrades to 7.4.7 rather than only to 7.2.0, that's because they are both compatible with the same versions of Python, and as far as I know there's no reason to prefer an earlier version within that range. Although GitPython still supports being installed and run on Python 3.8 (and even on Python 3.7), it has been end-of-life (i.e., no longer supported by the Python Software Foundation) for quite some time now. That the version of Sphinx used to build documentation will now be different on Python 3.8 than other versions is a reason not to use Python 3.8 for this purpose, but probablly already not the most important reason. The change here is conceptually similar to, but much simpler than, the change in #1954, which upgraded Sphinx to 7.1.2 on all Python versions GitPython suppports other than Python 3.7. The subsequent change in #1956 of removing support for building the GitPython documentation on Python 3.7 may be paralleled for 3.8 shortly. --- doc/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/requirements.txt b/doc/requirements.txt index 81140d898..cbf34cc69 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,3 +1,4 @@ -sphinx >= 7.1.2, < 7.2 +sphinx >= 7.4.7, < 8 ; python_version >= "3.9" +sphinx >= 7.1.2, < 7.2 ; python_version < "3.9" sphinx_rtd_theme sphinx-autodoc-typehints From 8d979061850e8659a089e4249be84d906c409334 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 18:10:49 -0400 Subject: [PATCH 65/69] Don't support building documentation on Python 3.8 This discontinues supporting building documentation on Python 3.8. It does not affect installing or running GitPython on Python 3.8 (except when the `doc` extra is used, but this is only used for building documentation). The reason is that it is no longer possible to use the same version of Sphinx on Python 3.8 as on the most recent supported versions of Python, because Python 3.14 no longer has `ast.Str` (using `str.Constant` for string literals instead), which causes the oldest version of `sphinx` that runs on Python 3.14 to be `sphinx` 7.2.0, while the newest version that is installable on Python 3.8 is `sphinx` 7.1.2. The immediately preceding commit changes the requirements for the `doc` extra to specify a newer `sphinx` version for Python 3.9 and later. This can't be done on Python 3.8. Because there can be subtle differences in documentation generated with different `sphinx` versions, and because Python 3.8 has been end-of-life for some time, it is not really worth carrying conditional dependencies for the `sphinx` version in `doc/requirements.txt`. Note that, while it is probably not a very good idea to use GitPython (or anything) on Python 3.8 since it is end-of-life, this change does not stop supporting installing GitPython on that or any other version it has been supporting. Installing and using GitPython remains supported all the way back to Python 3.7 at this time. This only affects the `doc` extra and its requirements. This change is analogous to the change made in #1956, which followed up on the change in #1964 in the same way this change follows up on the change in the immediately preceding commit. --- .github/workflows/pythonpackage.yml | 7 ++++++- doc/requirements.txt | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 3d2cb9b63..8c84f7580 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -30,6 +30,11 @@ jobs: - os-type: ubuntu python-version: "3.7" os-ver: "22.04" + - build-docs: true # We ensure documentation builds, except on very old interpreters. + - python-version: "3.7" + build-docs: false + - python-version: "3.8" + build-docs: false - experimental: false fail-fast: false @@ -110,7 +115,7 @@ jobs: continue-on-error: false - name: Documentation - if: matrix.python-version != '3.7' + if: matrix.build-docs run: | pip install '.[doc]' make -C doc html diff --git a/doc/requirements.txt b/doc/requirements.txt index cbf34cc69..24472ba39 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,4 +1,3 @@ -sphinx >= 7.4.7, < 8 ; python_version >= "3.9" -sphinx >= 7.1.2, < 7.2 ; python_version < "3.9" +sphinx >= 7.4.7, < 8 sphinx_rtd_theme sphinx-autodoc-typehints From 4b25af2ae8aba951503c8c7055ffa5085bc18f3f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 9 Mar 2026 19:16:00 -0400 Subject: [PATCH 66/69] Go back to testing free-threaded interpreters only on GNU/Linux This effectively reverts d1ab2e4. It doesn't look like any problems arose, and contrary to my guess, the additional jobs do actually make the checks that we intend to be blocking for PRs take longer, even after all non-macOS checks have completed. --- .github/workflows/pythonpackage.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 8c84f7580..874e18a8f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,6 +17,10 @@ jobs: exclude: - os-type: macos python-version: "3.7" # Not available for the ARM-based macOS runners. + - os-type: macos + python-version: "3.13t" + - os-type: macos + python-version: "3.14t" - os-type: windows python-version: "3.13" # FIXME: Fix and enable Python 3.13 and 3.14 on Windows (#1955). - os-type: windows From 77b1135ef9156a389d83b0d39ad0e1614350c002 Mon Sep 17 00:00:00 2001 From: "GPT 5.4" Date: Fri, 20 Mar 2026 22:12:49 +0000 Subject: [PATCH 67/69] fix: resolve active_branch correctly for reftable refs reviewed-by: Sebastian Thiel --- git/repo/base.py | 12 ++++++++++-- test/test_repo.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 1f543cc57..16807b9fa 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1042,11 +1042,19 @@ def active_branch(self) -> Head: :raise TypeError: If HEAD is detached. + :raise ValueError: + If HEAD points to the ``.invalid`` ref Git uses to mark refs as + incompatible with older clients. + :return: :class:`~git.refs.head.Head` to the active branch """ - # reveal_type(self.head.reference) # => Reference - return self.head.reference + active_branch = self.head.reference + if active_branch.name == ".invalid": + raise ValueError( + "HEAD points to 'refs/heads/.invalid', which Git uses to mark refs as incompatible with older clients" + ) + return active_branch def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]: """Iterator for blame information for the given file at the given revision. diff --git a/test/test_repo.py b/test/test_repo.py index 2a92c2523..544b5c561 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -962,6 +962,46 @@ def test_empty_repo(self, rw_dir): assert "BAD MESSAGE" not in contents, "log is corrupt" + @with_rw_directory + def test_active_branch_raises_value_error_when_head_ref_is_invalid(self, rw_dir): + repo = Repo.init(rw_dir) + with open(osp.join(rw_dir, ".git", "HEAD"), "w") as f: + f.write("ref: refs/heads/.invalid\n") + + self.assertRaisesRegex( + ValueError, + r"refs/heads/\.invalid.*older clients", + lambda: repo.active_branch, + ) + + @with_rw_directory + def test_empty_repo_reftable_active_branch(self, rw_dir): + git = Git(rw_dir) + try: + git.init(ref_format="reftable") + except GitCommandError as err: + if err.status == 129: + pytest.skip("git init --ref-format is not supported by this git version") + raise + + repo = Repo(rw_dir) + self.assertEqual(repo.head.reference.name, ".invalid") + self.assertRaisesRegex( + ValueError, + r"refs/heads/\.invalid.*older clients", + lambda: repo.active_branch, + ) + + @with_rw_directory + def test_active_branch_raises_type_error_when_head_is_detached(self, rw_dir): + repo = Repo.init(rw_dir) + with open(osp.join(rw_dir, "a.txt"), "w") as f: + f.write("a") + repo.index.add(["a.txt"]) + repo.index.commit("initial commit") + repo.git.checkout(repo.head.commit.hexsha) + self.assertRaisesRegex(TypeError, "detached symbolic reference", lambda: repo.active_branch) + def test_merge_base(self): repo = self.rorepo c1 = "f6aa8d1" From 859ea95b6d6207dab3d406f28234e6507ff1f527 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:11:12 -0700 Subject: [PATCH 68/69] docs: warn about GitDB performance with large commits Add a warning note in the Object Database section of the tutorial about GitDB failing or becoming extremely slow when traversing trees in repositories with very large commits (thousands of changed files). Directs users to switch to GitCmdObjectDB instead. Closes #2065 --- doc/source/tutorial.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index fd3b14c57..d095d3be3 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -513,6 +513,12 @@ The GitDB is a pure-python implementation of the git object database. It is the repo = Repo("path/to/repo", odbt=GitDB) +.. warning:: + ``GitDB`` may fail or become extremely slow when traversing trees in + repositories with very large commits (thousands of changed files in a + single commit). If you encounter ``RecursionError`` or excessive + slowness during tree traversal, switch to ``GitCmdObjectDB`` instead. + GitCmdObjectDB ============== From 9863f501ef5c6aef9b60acc0b490d5cc675aef4e Mon Sep 17 00:00:00 2001 From: Uwe Schwaeke Date: Wed, 25 Mar 2026 16:03:50 +0100 Subject: [PATCH 69/69] cmd: fix kwarg formatting in docstring example Update the example to accurately reflect the output of `transform_kwarg`. When a key is longer than one letter and its value is a non-empty, non-boolean type, it is transformed into the `--key=value` format, rather than missing the double dashes or using spaces. Signed-off-by: Uwe Schwaeke --- git/cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/cmd.py b/git/cmd.py index 78a9f4c78..b529bcc10 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1572,7 +1572,7 @@ def _call_process( turns into:: - git rev-list max-count 10 --header master + git rev-list --max-count=10 --header=master :return: Same as :meth:`execute`. If no args are given, used :meth:`execute`'s