From 94151aa2ca9f16491a0cf2344b4daa8bf7b41d70 Mon Sep 17 00:00:00 2001
From: Andrej730 <azhilenkov@gmail.com>
Date: Wed, 19 Mar 2025 13:17:46 +0500
Subject: [PATCH 01/72] Use property decorator to support typing

---
 git/refs/symbolic.py | 41 ++++++++++++++++++++++++-----------------
 git/repo/base.py     | 40 +++++++++++++++++++++-------------------
 2 files changed, 45 insertions(+), 36 deletions(-)

diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py
index 510850b2e..1b90a3115 100644
--- a/git/refs/symbolic.py
+++ b/git/refs/symbolic.py
@@ -39,7 +39,6 @@
 if TYPE_CHECKING:
     from git.config import GitConfigParser
     from git.objects.commit import Actor
-    from git.refs import Head, TagReference, RemoteReference, Reference
     from git.refs.log import RefLogEntry
     from git.repo import Repo
 
@@ -387,17 +386,23 @@ def set_object(
         # set the commit on our reference
         return self._get_reference().set_object(object, logmsg)
 
-    commit = property(
-        _get_commit,
-        set_commit,  # type: ignore[arg-type]
-        doc="Query or set commits directly",
-    )
+    @property
+    def commit(self) -> "Commit":
+        """Query or set commits directly"""
+        return self._get_commit()
+
+    @commit.setter
+    def commit(self, commit: Union[Commit, "SymbolicReference", str]) -> "SymbolicReference":
+        return self.set_commit(commit)
+
+    @property
+    def object(self) -> AnyGitObject:
+        """Return the object our ref currently refers to"""
+        return self._get_object()
 
-    object = property(
-        _get_object,
-        set_object,  # type: ignore[arg-type]
-        doc="Return the object our ref currently refers to",
-    )
+    @object.setter
+    def object(self, object: Union[AnyGitObject, "SymbolicReference", str]) -> "SymbolicReference":
+        return self.set_object(object)
 
     def _get_reference(self) -> "SymbolicReference":
         """
@@ -496,12 +501,14 @@ def set_reference(
         return self
 
     # Aliased reference
-    reference: Union["Head", "TagReference", "RemoteReference", "Reference"]
-    reference = property(  # type: ignore[assignment]
-        _get_reference,
-        set_reference,  # type: ignore[arg-type]
-        doc="Returns the Reference we point to",
-    )
+    @property
+    def reference(self) -> "SymbolicReference":
+        return self._get_reference()
+
+    @reference.setter
+    def reference(self, ref: Union[AnyGitObject, "SymbolicReference", str]) -> "SymbolicReference":
+        return self.set_reference(ref)
+
     ref = reference
 
     def is_valid(self) -> bool:
diff --git a/git/repo/base.py b/git/repo/base.py
index db89cdf41..cbf54f222 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -354,21 +354,19 @@ def __ne__(self, rhs: object) -> bool:
     def __hash__(self) -> int:
         return hash(self.git_dir)
 
-    # Description property
-    def _get_description(self) -> str:
+    @property
+    def description(self) -> str:
+        """The project's description"""
         filename = osp.join(self.git_dir, "description")
         with open(filename, "rb") as fp:
             return fp.read().rstrip().decode(defenc)
 
-    def _set_description(self, descr: str) -> None:
+    @description.setter
+    def description(self, descr: str) -> None:
         filename = osp.join(self.git_dir, "description")
         with open(filename, "wb") as fp:
             fp.write((descr + "\n").encode(defenc))
 
-    description = property(_get_description, _set_description, doc="the project's description")
-    del _get_description
-    del _set_description
-
     @property
     def working_tree_dir(self) -> Optional[PathLike]:
         """
@@ -885,13 +883,14 @@ def _set_daemon_export(self, value: object) -> None:
         elif not value and fileexists:
             os.unlink(filename)
 
-    daemon_export = property(
-        _get_daemon_export,
-        _set_daemon_export,
-        doc="If True, git-daemon may export this repository",
-    )
-    del _get_daemon_export
-    del _set_daemon_export
+    @property
+    def daemon_export(self) -> bool:
+        """If True, git-daemon may export this repository"""
+        return self._get_daemon_export()
+
+    @daemon_export.setter
+    def daemon_export(self, value: object) -> None:
+        self._set_daemon_export(value)
 
     def _get_alternates(self) -> List[str]:
         """The list of alternates for this repo from which objects can be retrieved.
@@ -929,11 +928,14 @@ def _set_alternates(self, alts: List[str]) -> None:
             with open(alternates_path, "wb") as f:
                 f.write("\n".join(alts).encode(defenc))
 
-    alternates = property(
-        _get_alternates,
-        _set_alternates,
-        doc="Retrieve a list of alternates paths or set a list paths to be used as alternates",
-    )
+    @property
+    def alternates(self) -> List[str]:
+        """Retrieve a list of alternates paths or set a list paths to be used as alternates"""
+        return self._get_alternates()
+
+    @alternates.setter
+    def alternates(self, alts: List[str]) -> None:
+        self._set_alternates(alts)
 
     def is_dirty(
         self,

From 3d979a6c307b2a03f08d4bdbc7fb3716d8c17c94 Mon Sep 17 00:00:00 2001
From: Yuki Kobayashi <drsuaimqjgar@gmail.com>
Date: Tue, 18 Mar 2025 08:22:04 +0000
Subject: [PATCH 02/72] Fix some incorrect sphinx markups in the docstrings

Fixed some markups so
[the api reference](https://gitpython.readthedocs.io/en/stable/reference.html)
is rendered correctly.
---
 git/index/base.py     | 10 +++++-----
 git/objects/base.py   |  4 ++--
 git/objects/commit.py |  4 ++--
 git/repo/base.py      |  2 +-
 4 files changed, 10 insertions(+), 10 deletions(-)

diff --git a/git/index/base.py b/git/index/base.py
index 65b1f9308..a95762dca 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -508,7 +508,7 @@ def iter_blobs(
 
         :param predicate:
             Function(t) returning ``True`` if tuple(stage, Blob) should be yielded by
-            the iterator. A default filter, the `~git.index.typ.BlobFilter`, allows you
+            the iterator. A default filter, the :class:`~git.index.typ.BlobFilter`, allows you
             to yield blobs only if they match a given list of paths.
         """
         for entry in self.entries.values():
@@ -770,7 +770,7 @@ def add(
             - path string
 
                 Strings denote a relative or absolute path into the repository pointing
-                to an existing file, e.g., ``CHANGES``, `lib/myfile.ext``,
+                to an existing file, e.g., ``CHANGES``, ``lib/myfile.ext``,
                 ``/home/gitrepo/lib/myfile.ext``.
 
                 Absolute paths must start with working tree directory of this index's
@@ -789,7 +789,7 @@ def add(
 
                 They are added at stage 0.
 
-            - :class:~`git.objects.blob.Blob` or
+            - :class:`~git.objects.blob.Blob` or
               :class:`~git.objects.submodule.base.Submodule` object
 
                 Blobs are added as they are assuming a valid mode is set.
@@ -815,7 +815,7 @@ def add(
 
             - :class:`~git.index.typ.BaseIndexEntry` or type
 
-                Handling equals the one of :class:~`git.objects.blob.Blob` objects, but
+                Handling equals the one of :class:`~git.objects.blob.Blob` objects, but
                 the stage may be explicitly set. Please note that Index Entries require
                 binary sha's.
 
@@ -998,7 +998,7 @@ def remove(
 
                 The path string may include globs, such as ``*.c``.
 
-            - :class:~`git.objects.blob.Blob` object
+            - :class:`~git.objects.blob.Blob` object
 
                 Only the path portion is used in this case.
 
diff --git a/git/objects/base.py b/git/objects/base.py
index eeaebc09b..faf600c6b 100644
--- a/git/objects/base.py
+++ b/git/objects/base.py
@@ -122,7 +122,7 @@ def new(cls, repo: "Repo", id: Union[str, "Reference"]) -> AnyGitObject:
         :return:
             New :class:`Object` instance of a type appropriate to the object type behind
             `id`. The id of the newly created object will be a binsha even though the
-            input id may have been a `~git.refs.reference.Reference` or rev-spec.
+            input id may have been a :class:`~git.refs.reference.Reference` or rev-spec.
 
         :param id:
             :class:`~git.refs.reference.Reference`, rev-spec, or hexsha.
@@ -218,7 +218,7 @@ class IndexObject(Object):
     """Base for all objects that can be part of the index file.
 
     The classes representing git object types that can be part of the index file are
-    :class:`~git.objects.tree.Tree and :class:`~git.objects.blob.Blob`. In addition,
+    :class:`~git.objects.tree.Tree` and :class:`~git.objects.blob.Blob`. In addition,
     :class:`~git.objects.submodule.base.Submodule`, which is not really a git object
     type but can be part of an index file, is also a subclass.
     """
diff --git a/git/objects/commit.py b/git/objects/commit.py
index 0ceb46609..fbe0ee9c0 100644
--- a/git/objects/commit.py
+++ b/git/objects/commit.py
@@ -289,7 +289,7 @@ def name_rev(self) -> str:
         """
         :return:
             String describing the commits hex sha based on the closest
-            `~git.refs.reference.Reference`.
+            :class:`~git.refs.reference.Reference`.
 
         :note:
             Mostly useful for UI purposes.
@@ -349,7 +349,7 @@ def iter_items(
         return cls._iter_from_process_or_stream(repo, proc)
 
     def iter_parents(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> Iterator["Commit"]:
-        R"""Iterate _all_ parents of this commit.
+        R"""Iterate *all* parents of this commit.
 
         :param paths:
             Optional path or list of paths limiting the :class:`Commit`\s to those that
diff --git a/git/repo/base.py b/git/repo/base.py
index cbf54f222..7e918df8c 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -512,7 +512,7 @@ def create_submodule(self, *args: Any, **kwargs: Any) -> Submodule:
     def iter_submodules(self, *args: Any, **kwargs: Any) -> Iterator[Submodule]:
         """An iterator yielding Submodule instances.
 
-        See the `~git.objects.util.Traversable` interface for a description of `args`
+        See the :class:`~git.objects.util.Traversable` interface for a description of `args`
         and `kwargs`.
 
         :return:

From 1abb399b90994f61b81c1aa4be608a85b07b73c7 Mon Sep 17 00:00:00 2001
From: Nathan Goldbaum <nathan.goldbaum@gmail.com>
Date: Tue, 25 Mar 2025 09:20:48 -0600
Subject: [PATCH 03/72] replace quansight-labs/setup-python with
 actions/setup-python

---
 .github/workflows/pythonpackage.yml | 9 ---------
 1 file changed, 9 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 5bedb6107..61088237d 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -44,20 +44,11 @@ jobs:
         fetch-depth: 0
 
     - name: Set up Python ${{ matrix.python-version }}
-      if: |-
-        !endsWith(matrix.python-version, 't')
       uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
         allow-prereleases: ${{ matrix.experimental }}
 
-    - name: Set up Python ${{ matrix.python-version }} (free-threaded)
-      if: endsWith(matrix.python-version, 't')
-      uses: Quansight-Labs/setup-python@v5
-      with:
-        python-version: ${{ matrix.python-version }}
-        allow-prereleases: ${{ matrix.experimental }}
-
     - name: Set up WSL (Windows)
       if: matrix.os-type == 'windows'
       uses: Vampire/setup-wsl@v5.0.0

From f2483a6151a99b7326b657fd945ec75f891e6462 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 May 2025 13:53:04 +0000
Subject: [PATCH 04/72] Bump Vampire/setup-wsl from 5.0.0 to 5.0.1

Bumps [Vampire/setup-wsl](https://github.com/vampire/setup-wsl) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/vampire/setup-wsl/releases)
- [Commits](https://github.com/vampire/setup-wsl/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: Vampire/setup-wsl
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 .github/workflows/pythonpackage.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 61088237d..9fd660c6b 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -51,7 +51,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: matrix.os-type == 'windows'
-      uses: Vampire/setup-wsl@v5.0.0
+      uses: Vampire/setup-wsl@v5.0.1
       with:
         wsl-version: 1
         distribution: Alpine

From ec0087227ed37cf1df441e4f0f73daa25895c4b5 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Wed, 21 May 2025 15:48:34 -0400
Subject: [PATCH 05/72] add tests for is_cygwin_git

---
 test/test_util.py | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/test/test_util.py b/test/test_util.py
index dad2f3dcd..ed57dcbae 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -34,6 +34,7 @@
     LockFile,
     cygpath,
     decygpath,
+    is_cygwin_git,
     get_user_id,
     remove_password_if_present,
     rmtree,
@@ -349,6 +350,24 @@ def test_decygpath(self, wpath, cpath):
         assert wcpath == wpath.replace("/", "\\"), cpath
 
 
+class TestIsCygwinGit:
+    """Tests for :func:`is_cygwin_git`"""
+
+    def test_on_path_executable(self):
+        match sys.platform:
+            case "cygwin":
+                assert is_cygwin_git("git")
+            case _:
+                assert not is_cygwin_git("git")
+
+    def test_none_executable(self):
+        assert not is_cygwin_git(None)
+
+    def test_with_missing_uname(self):
+        """Test for handling when `uname` isn't in the same directory as `git`"""
+        assert not is_cygwin_git("/bogus_path/git")
+
+
 class _Member:
     """A member of an IterableList."""
 

From de5e57caf26e5f6772ac02a3944e58e0aba177f3 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Wed, 21 May 2025 15:49:07 -0400
Subject: [PATCH 06/72] check for the existence/execute bit on the uname
 command before trying to run it

---
 git/util.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/git/util.py b/git/util.py
index 9e8ac821d..f1a94c469 100644
--- a/git/util.py
+++ b/git/util.py
@@ -463,7 +463,13 @@ def _is_cygwin_git(git_executable: str) -> bool:
                 git_dir = osp.dirname(res[0]) if res else ""
 
             # Just a name given, not a real path.
+            # Let's see if the same path has uname
             uname_cmd = osp.join(git_dir, "uname")
+            if not (osp.isfile(uname_cmd) and os.access(uname_cmd, os.X_OK)):
+                _logger.debug(f"File {uname_cmd} either does not exist or is not executable.")
+                _is_cygwin_cache[git_executable] = is_cygwin
+                return is_cygwin
+
             process = subprocess.Popen([uname_cmd], stdout=subprocess.PIPE, universal_newlines=True)
             uname_out, _ = process.communicate()
             # retcode = process.poll()

From 428be1a504e2b30c195786d5ca6708f4c39d90a7 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Wed, 21 May 2025 15:51:29 -0400
Subject: [PATCH 07/72] adding self to authors

---
 AUTHORS | 1 +
 1 file changed, 1 insertion(+)

diff --git a/AUTHORS b/AUTHORS
index 45b14c961..b57113edd 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -55,5 +55,6 @@ Contributors are:
 -Eliah Kagan <eliah.kagan _at_ gmail.com>
 -Ethan Lin <et.repositories _at_ gmail.com>
 -Jonas Scharpf <jonas.scharpf _at_ checkmk.com>
+-Gordon Marx
 
 Portions derived from other open source works and are clearly marked.

From 58ff723e6757af3c508e2a9424d287e5016ac230 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 08:54:42 -0400
Subject: [PATCH 08/72] Revert "check for the existence/execute bit on the
 uname command before trying to run it"

This reverts commit de5e57caf26e5f6772ac02a3944e58e0aba177f3.
---
 git/util.py | 6 ------
 1 file changed, 6 deletions(-)

diff --git a/git/util.py b/git/util.py
index f1a94c469..9e8ac821d 100644
--- a/git/util.py
+++ b/git/util.py
@@ -463,13 +463,7 @@ def _is_cygwin_git(git_executable: str) -> bool:
                 git_dir = osp.dirname(res[0]) if res else ""
 
             # Just a name given, not a real path.
-            # Let's see if the same path has uname
             uname_cmd = osp.join(git_dir, "uname")
-            if not (osp.isfile(uname_cmd) and os.access(uname_cmd, os.X_OK)):
-                _logger.debug(f"File {uname_cmd} either does not exist or is not executable.")
-                _is_cygwin_cache[git_executable] = is_cygwin
-                return is_cygwin
-
             process = subprocess.Popen([uname_cmd], stdout=subprocess.PIPE, universal_newlines=True)
             uname_out, _ = process.communicate()
             # retcode = process.poll()

From 71879840b8a72890f63fcadf7a92694106610ef0 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 09:05:13 -0400
Subject: [PATCH 09/72] use pathlib.Path instead of os.path.isfile

---
 git/util.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/git/util.py b/git/util.py
index 9e8ac821d..3b6917fc4 100644
--- a/git/util.py
+++ b/git/util.py
@@ -464,6 +464,12 @@ 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).exists() 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
+
             process = subprocess.Popen([uname_cmd], stdout=subprocess.PIPE, universal_newlines=True)
             uname_out, _ = process.communicate()
             # retcode = process.poll()

From 3f5a942be8293bb8c663631756885f877afc22e7 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 09:06:02 -0400
Subject: [PATCH 10/72] don't keep checking if sys.platform isn't 'cygwin'

---
 git/util.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/util.py b/git/util.py
index 3b6917fc4..989f9196c 100644
--- a/git/util.py
+++ b/git/util.py
@@ -490,7 +490,7 @@ def is_cygwin_git(git_executable: PathLike) -> bool: ...
 
 
 def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool:
-    if sys.platform == "win32":  # TODO: See if we can use `sys.platform != "cygwin"`.
+    if sys.platform != "cygwin":
         return False
     elif git_executable is None:
         return False

From 226f4ffcaf2deed4598b9ff53902a12f6483c069 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 09:15:50 -0400
Subject: [PATCH 11/72] check that uname_cmd points to a file; if uname_cmd
 were a directory, it could both exist and have os.X_OK but not work

---
 git/util.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/util.py b/git/util.py
index 989f9196c..32a808c60 100644
--- a/git/util.py
+++ b/git/util.py
@@ -465,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).exists() and os.access(uname_cmd, os.X_OK)):
+            if not (pathlib.Path(uname_cmd).isfile() 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

From 22d6284b099e99716da101198ce113c96e35423a Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 09:24:37 -0400
Subject: [PATCH 12/72] don't use match-case, since it's a 3.10 feature

---
 test/test_util.py | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/test/test_util.py b/test/test_util.py
index ed57dcbae..de80ca3af 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -354,11 +354,10 @@ class TestIsCygwinGit:
     """Tests for :func:`is_cygwin_git`"""
 
     def test_on_path_executable(self):
-        match sys.platform:
-            case "cygwin":
-                assert is_cygwin_git("git")
-            case _:
-                assert not is_cygwin_git("git")
+        if sys.platform == "cygwin":
+            assert is_cygwin_git("git")
+        else:
+            assert not is_cygwin_git("git")
 
     def test_none_executable(self):
         assert not is_cygwin_git(None)

From b1289eeb3676e63f4261518655ee90808e9d3d1f Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 10:56:26 -0400
Subject: [PATCH 13/72] it's is_file(), not isfile()

---
 git/util.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/git/util.py b/git/util.py
index 32a808c60..4071fdc14 100644
--- a/git/util.py
+++ b/git/util.py
@@ -465,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).isfile() and os.access(uname_cmd, os.X_OK)):
+            if not (pathlib.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
@@ -490,6 +490,7 @@ def is_cygwin_git(git_executable: PathLike) -> bool: ...
 
 
 def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool:
+    _logger.debug(f"{sys.platform=}, {git_executable=}")
     if sys.platform != "cygwin":
         return False
     elif git_executable is None:

From cffa264bdc1dd09a13a85cc27417b85628273e14 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 11:11:48 -0400
Subject: [PATCH 14/72] turns out f-strings before 3.8 don't support
 {variable=} notation, take that out

---
 git/util.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/util.py b/git/util.py
index 4071fdc14..66175ea33 100644
--- a/git/util.py
+++ b/git/util.py
@@ -490,7 +490,7 @@ def is_cygwin_git(git_executable: PathLike) -> bool: ...
 
 
 def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool:
-    _logger.debug(f"{sys.platform=}, {git_executable=}")
+    _logger.debug(f"sys.platform = {sys.platform}, git_executable = {git_executable}")
     if sys.platform != "cygwin":
         return False
     elif git_executable is None:

From 8dc75521bd42db19d41e8d92ce5204dfe1a594ac Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Thu, 22 May 2025 15:48:54 -0400
Subject: [PATCH 15/72] remove type assertions from util.py

---
 git/util.py | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/git/util.py b/git/util.py
index 9e8ac821d..0d09c3f09 100644
--- a/git/util.py
+++ b/git/util.py
@@ -1200,8 +1200,6 @@ def __getattr__(self, attr: str) -> T_IterableObj:
         return list.__getattribute__(self, attr)
 
     def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_IterableObj:  # type: ignore[override]
-        assert isinstance(index, (int, str, slice)), "Index of IterableList should be an int or str"
-
         if isinstance(index, int):
             return list.__getitem__(self, index)
         elif isinstance(index, slice):
@@ -1214,8 +1212,6 @@ def __getitem__(self, index: Union[SupportsIndex, int, slice, str]) -> T_Iterabl
         # END handle getattr
 
     def __delitem__(self, index: Union[SupportsIndex, int, slice, str]) -> None:
-        assert isinstance(index, (int, str)), "Index of IterableList should be an int or str"
-
         delindex = cast(int, index)
         if not isinstance(index, int):
             delindex = -1

From 36a893bbd47a08df80e0266f6ce5182915511833 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Fri, 23 May 2025 07:24:43 -0400
Subject: [PATCH 16/72] add self to AUTHORS, add Emacs tempfiles to .gitignore

---
 .gitignore | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/.gitignore b/.gitignore
index d85569405..eab294a65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@ __pycache__/
 # Transient editor files
 *.swp
 *~
+\#*#
+.#*#
 
 # Editor configuration
 nbproject

From c441316b7714207eaf2c13be5104d15ec69943bd Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Tue, 27 May 2025 10:58:24 -0400
Subject: [PATCH 17/72] use `strings` instead of `uname` to detect cygwin

---
 git/util.py | 23 ++++++++++++-----------
 1 file changed, 12 insertions(+), 11 deletions(-)

diff --git a/git/util.py b/git/util.py
index 66175ea33..53475eeb3 100644
--- a/git/util.py
+++ b/git/util.py
@@ -457,23 +457,24 @@ def _is_cygwin_git(git_executable: str) -> bool:
     if is_cygwin is None:
         is_cygwin = False
         try:
-            git_dir = osp.dirname(git_executable)
+            git_cmd = pathlib.Path(git_executable)
+            git_dir = git_cmd.parent
+
             if not git_dir:
                 res = py_where(git_executable)
-                git_dir = osp.dirname(res[0]) if res else ""
+                git_dir = pathlib.Path(res[0]).parent if res else ""
 
-            # Just a name given, not a real path.
-            uname_cmd = osp.join(git_dir, "uname")
+            # If it's a cygwin git, it'll have cygwin in the output of `strings git`
+            strings_cmd = pathlib.Path(git_dir, "strings")
 
-            if not (pathlib.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
+            if not (pathlib.Path(strings_cmd).is_file() and os.access(strings_cmd, os.X_OK)):
+                _logger.debug(f"Failed checking if running in CYGWIN: {strings_cmd} is not an executable")
+                _is_cygwin_cache[git_executable] = False
                 return is_cygwin
 
-            process = subprocess.Popen([uname_cmd], stdout=subprocess.PIPE, universal_newlines=True)
-            uname_out, _ = process.communicate()
-            # retcode = process.poll()
-            is_cygwin = "CYGWIN" in uname_out
+            process = subprocess.Popen([strings_cmd, git_cmd], stdout=subprocess.PIPE, text=True)
+            strings_output, _ = process.communicate()
+            is_cygwin = any(x for x in strings_output if "cygwin" in x.lower())
         except Exception as ex:
             _logger.debug("Failed checking if running in CYGWIN due to: %r", ex)
         _is_cygwin_cache[git_executable] = is_cygwin

From 0df08181f1012f0e591f998dcc57fd594e754d98 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Tue, 27 May 2025 11:32:23 -0400
Subject: [PATCH 18/72] debug printing

---
 git/util.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/git/util.py b/git/util.py
index 53475eeb3..81761f15c 100644
--- a/git/util.py
+++ b/git/util.py
@@ -475,6 +475,8 @@ def _is_cygwin_git(git_executable: str) -> bool:
             process = subprocess.Popen([strings_cmd, git_cmd], stdout=subprocess.PIPE, text=True)
             strings_output, _ = process.communicate()
             is_cygwin = any(x for x in strings_output if "cygwin" in x.lower())
+            if not is_cygwin:
+                _logger.debug(f"is not cygwin: {strings_output}")
         except Exception as ex:
             _logger.debug("Failed checking if running in CYGWIN due to: %r", ex)
         _is_cygwin_cache[git_executable] = is_cygwin

From 58f77105f5163408a9bf323518b96646ab413561 Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Wed, 28 May 2025 11:15:06 -0400
Subject: [PATCH 19/72] Revert "debug printing"

This reverts commit 0df08181f1012f0e591f998dcc57fd594e754d98.
---
 git/util.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/git/util.py b/git/util.py
index 81761f15c..53475eeb3 100644
--- a/git/util.py
+++ b/git/util.py
@@ -475,8 +475,6 @@ def _is_cygwin_git(git_executable: str) -> bool:
             process = subprocess.Popen([strings_cmd, git_cmd], stdout=subprocess.PIPE, text=True)
             strings_output, _ = process.communicate()
             is_cygwin = any(x for x in strings_output if "cygwin" in x.lower())
-            if not is_cygwin:
-                _logger.debug(f"is not cygwin: {strings_output}")
         except Exception as ex:
             _logger.debug("Failed checking if running in CYGWIN due to: %r", ex)
         _is_cygwin_cache[git_executable] = is_cygwin

From 1731c1e377aca056d1a7acf2dd6df0e68a4e9e7e Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Wed, 28 May 2025 11:15:23 -0400
Subject: [PATCH 20/72] Revert "use `strings` instead of `uname` to detect
 cygwin"

This reverts commit c441316b7714207eaf2c13be5104d15ec69943bd.
---
 git/util.py | 23 +++++++++++------------
 1 file changed, 11 insertions(+), 12 deletions(-)

diff --git a/git/util.py b/git/util.py
index 53475eeb3..66175ea33 100644
--- a/git/util.py
+++ b/git/util.py
@@ -457,24 +457,23 @@ def _is_cygwin_git(git_executable: str) -> bool:
     if is_cygwin is None:
         is_cygwin = False
         try:
-            git_cmd = pathlib.Path(git_executable)
-            git_dir = git_cmd.parent
-
+            git_dir = osp.dirname(git_executable)
             if not git_dir:
                 res = py_where(git_executable)
-                git_dir = pathlib.Path(res[0]).parent if res else ""
+                git_dir = osp.dirname(res[0]) if res else ""
 
-            # If it's a cygwin git, it'll have cygwin in the output of `strings git`
-            strings_cmd = pathlib.Path(git_dir, "strings")
+            # Just a name given, not a real path.
+            uname_cmd = osp.join(git_dir, "uname")
 
-            if not (pathlib.Path(strings_cmd).is_file() and os.access(strings_cmd, os.X_OK)):
-                _logger.debug(f"Failed checking if running in CYGWIN: {strings_cmd} is not an executable")
-                _is_cygwin_cache[git_executable] = False
+            if not (pathlib.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
 
-            process = subprocess.Popen([strings_cmd, git_cmd], stdout=subprocess.PIPE, text=True)
-            strings_output, _ = process.communicate()
-            is_cygwin = any(x for x in strings_output if "cygwin" in x.lower())
+            process = subprocess.Popen([uname_cmd], stdout=subprocess.PIPE, universal_newlines=True)
+            uname_out, _ = process.communicate()
+            # retcode = process.poll()
+            is_cygwin = "CYGWIN" in uname_out
         except Exception as ex:
             _logger.debug("Failed checking if running in CYGWIN due to: %r", ex)
         _is_cygwin_cache[git_executable] = is_cygwin

From f3ab5d3810b1e8c1f0365d26eea3e91808d8e4af Mon Sep 17 00:00:00 2001
From: Gordon Marx <gcmarx@gmail.com>
Date: Wed, 28 May 2025 11:21:28 -0400
Subject: [PATCH 21/72] incorporate review feedback from @EliahKagan

---
 git/util.py       | 3 ++-
 test/test_util.py | 1 +
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/git/util.py b/git/util.py
index 66175ea33..a10609185 100644
--- a/git/util.py
+++ b/git/util.py
@@ -490,7 +490,8 @@ def is_cygwin_git(git_executable: PathLike) -> bool: ...
 
 
 def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool:
-    _logger.debug(f"sys.platform = {sys.platform}, git_executable = {git_executable}")
+    # TODO: when py3.7 support is dropped, use the new interpolation f"{variable=}"
+    _logger.debug(f"sys.platform={sys.platform!r}, git_executable={git_executable!r}")
     if sys.platform != "cygwin":
         return False
     elif git_executable is None:
diff --git a/test/test_util.py b/test/test_util.py
index de80ca3af..000830f41 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -354,6 +354,7 @@ class TestIsCygwinGit:
     """Tests for :func:`is_cygwin_git`"""
 
     def test_on_path_executable(self):
+        # Currently we assume tests run on Cygwin use Cygwin git. See #533 and #1455 for background.
         if sys.platform == "cygwin":
             assert is_cygwin_git("git")
         else:

From b7ce712c631c0b59566af43e7a4b00cc1dbeba3b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 30 May 2025 12:36:37 -0400
Subject: [PATCH 22/72] Use newer ruff style

This updates `ruff` in `.pre-commit-config.yaml` from 0.6.0 to
0.11.12, changes its `id` from the legacy `ruff` alias to
`ruff-check` (which is better distinguished from `ruff-format`,
which we also have a hook for), and applies the few style changes
it newly recommends throughout the code. The style changes seem to
make things slightly clearer overall.

This also updates some other pre-commit hooks, but those don't
require any changes to the code.

Currently the `ruff` dependency in `requirements-dev.txt` doesn't
specify a version, so no change is needed there. This update may
be seen as bringing the `pre-commit` version in line with what
users will usually have locally with `pip install -e ".[test]"`.

The `pre-commit` hooks are how linting is currently done on CI, so
this is updating `ruff` for CI. That's the most significant effect
of this change. (`pre-commit` is run for linting on CI probably
much more often than it is used locally, to manage pre-commit
hooks or otherwise, in GitPython development.)
---
 .pre-commit-config.yaml | 10 +++++-----
 git/cmd.py              |  4 ++--
 git/refs/log.py         |  2 +-
 git/repo/fun.py         |  2 +-
 test/test_git.py        |  2 +-
 test/test_quick_doc.py  |  2 +-
 6 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 424cc5f37..737b56d45 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,15 +1,15 @@
 repos:
 - repo: https://github.com/codespell-project/codespell
-  rev: v2.3.0
+  rev: v2.4.1
   hooks:
   - id: codespell
     additional_dependencies: [tomli]
     exclude: ^test/fixtures/
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.6.0
+  rev: v0.11.12
   hooks:
-  - id: ruff
+  - id: ruff-check
     args: ["--fix"]
     exclude: ^git/ext/
   - id: ruff-format
@@ -23,7 +23,7 @@ repos:
     exclude: ^test/fixtures/polyglot$|^git/ext/
 
 - repo: https://github.com/pre-commit/pre-commit-hooks
-  rev: v4.6.0
+  rev: v5.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.19
+  rev: v0.24.1
   hooks:
   - id: validate-pyproject
diff --git a/git/cmd.py b/git/cmd.py
index 2048a43fa..7f46edc8f 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -207,7 +207,7 @@ def pump_stream(
                 )
             if stderr_handler:
                 error_str: Union[str, bytes] = (
-                    "error: process killed because it timed out." f" kill_after_timeout={kill_after_timeout} seconds"
+                    f"error: process killed because it timed out. kill_after_timeout={kill_after_timeout} seconds"
                 )
                 if not decode_streams and isinstance(p_stderr, BinaryIO):
                     # Assume stderr_handler needs binary input.
@@ -1319,7 +1319,7 @@ def communicate() -> Tuple[AnyStr, AnyStr]:
                 out, err = proc.communicate()
                 watchdog.cancel()
                 if kill_check.is_set():
-                    err = 'Timeout: the command "%s" did not complete in %d ' "secs." % (
+                    err = 'Timeout: the command "%s" did not complete in %d secs.' % (
                         " ".join(redacted_command),
                         timeout,
                     )
diff --git a/git/refs/log.py b/git/refs/log.py
index 17e3a94b3..8f2f2cd38 100644
--- a/git/refs/log.py
+++ b/git/refs/log.py
@@ -126,7 +126,7 @@ def from_line(cls, line: bytes) -> "RefLogEntry":
         elif len(fields) == 2:
             info, msg = fields
         else:
-            raise ValueError("Line must have up to two TAB-separated fields." " Got %s" % repr(line_str))
+            raise ValueError("Line must have up to two TAB-separated fields. Got %s" % repr(line_str))
         # END handle first split
 
         oldhexsha = info[:40]
diff --git a/git/repo/fun.py b/git/repo/fun.py
index 125ba5936..1c995c6c6 100644
--- a/git/repo/fun.py
+++ b/git/repo/fun.py
@@ -405,7 +405,7 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject:
             # END end handle tag
         except (IndexError, AttributeError) as e:
             raise BadName(
-                f"Invalid revision spec '{rev}' - not enough " f"parent commits to reach '{token}{int(num)}'"
+                f"Invalid revision spec '{rev}' - not enough parent commits to reach '{token}{int(num)}'"
             ) from e
         # END exception handling
     # END parse loop
diff --git a/test/test_git.py b/test/test_git.py
index 274511f8d..5bcf89bdd 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -747,7 +747,7 @@ def test_environment(self, rw_dir):
 
         path = osp.join(rw_dir, "failing-script.sh")
         with open(path, "wt") as stream:
-            stream.write("#!/usr/bin/env sh\n" "echo FOO\n")
+            stream.write("#!/usr/bin/env sh\necho FOO\n")
         os.chmod(path, 0o777)
 
         rw_repo = Repo.init(osp.join(rw_dir, "repo"))
diff --git a/test/test_quick_doc.py b/test/test_quick_doc.py
index 4ef75f4aa..98658e02f 100644
--- a/test/test_quick_doc.py
+++ b/test/test_quick_doc.py
@@ -173,7 +173,7 @@ def test_cloned_repo_object(self, local_dir):
         # [15-test_cloned_repo_object]
         def print_files_from_git(root, level=0):
             for entry in root:
-                print(f'{"-" * 4 * level}| {entry.path}, {entry.type}')
+                print(f"{'-' * 4 * level}| {entry.path}, {entry.type}")
                 if entry.type == "tree":
                     print_files_from_git(entry, level + 1)
 

From 0c0fc7ee97c14088ef1705d756e04dadd9cfdd8a Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 30 May 2025 14:37:03 -0400
Subject: [PATCH 23/72] Temporarily remove CodeQL workflow file

So GitHub can regenerate a fresh new one based on current defaults.
---
 .github/workflows/codeql.yml | 80 ------------------------------------
 1 file changed, 80 deletions(-)
 delete mode 100644 .github/workflows/codeql.yml

diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index ae5241898..000000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,80 +0,0 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
-name: "CodeQL"
-
-on:
-  push:
-  pull_request:
-  schedule:
-    - cron: '27 10 * * 3'
-
-jobs:
-  analyze:
-    name: Analyze
-    # Runner size impacts CodeQL analysis time. To learn more, please see:
-    #   - https://gh.io/recommended-hardware-resources-for-running-codeql
-    #   - https://gh.io/supported-runners-and-hardware-resources
-    #   - https://gh.io/using-larger-runners
-    # Consider using larger runners for possible analysis time improvements.
-    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
-    timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
-    permissions:
-      actions: read
-      contents: read
-      security-events: write
-
-    strategy:
-      fail-fast: false
-      matrix:
-        language: [ 'python' ]
-        # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
-        # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
-        # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
-        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
-
-    steps:
-    - name: Checkout repository
-      uses: actions/checkout@v4
-
-    # Initializes the CodeQL tools for scanning.
-    - name: Initialize CodeQL
-      uses: github/codeql-action/init@v3
-      with:
-        languages: ${{ matrix.language }}
-        setup-python-dependencies: false
-        # If you wish to specify custom queries, you can do so here or in a config file.
-        # By default, queries listed here will override any specified in a config file.
-        # Prefix the list here with "+" to use these queries and those in the config file.
-
-        # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
-        # queries: security-extended,security-and-quality
-
-
-    # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
-    # If this step fails, then you should remove it and run the build manually (see below)
-    - name: Autobuild
-      uses: github/codeql-action/autobuild@v3
-
-    # ℹ️ Command-line programs to run using the OS shell.
-    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
-
-    #   If the Autobuild fails above, remove it and uncomment the following three lines.
-    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
-
-    # - run: |
-    #     echo "Run, Build Application using script"
-    #     ./location_of_script_within_repo/buildscript.sh
-
-    - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v3
-      with:
-        category: "/language:${{matrix.language}}"

From 847ead6948efa5d33a8798395220ed6b719a56f2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 30 May 2025 14:40:38 -0400
Subject: [PATCH 24/72] Recreate `codeq.yml` with current defaults

This adds CodeQL scanning of GitHub Actions, while continuing to
scan Python as well.

This will subsequently be customized slightly to restore some
elements of the preivous custom workflow that we may prefer.
---
 .github/workflows/codeql.yml | 100 +++++++++++++++++++++++++++++++++++
 1 file changed, 100 insertions(+)
 create mode 100644 .github/workflows/codeql.yml

diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..450166503
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,100 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL Advanced"
+
+on:
+  push:
+    branches: [ "main" ]
+  pull_request:
+    branches: [ "main" ]
+  schedule:
+    - cron: '36 18 * * 0'
+
+jobs:
+  analyze:
+    name: Analyze (${{ matrix.language }})
+    # Runner size impacts CodeQL analysis time. To learn more, please see:
+    #   - https://gh.io/recommended-hardware-resources-for-running-codeql
+    #   - https://gh.io/supported-runners-and-hardware-resources
+    #   - https://gh.io/using-larger-runners (GitHub.com only)
+    # Consider using larger runners or machines with greater resources for possible analysis time improvements.
+    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+    permissions:
+      # required for all workflows
+      security-events: write
+
+      # required to fetch internal or private CodeQL packs
+      packages: read
+
+      # only required for workflows in private repositories
+      actions: read
+      contents: read
+
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+        - language: actions
+          build-mode: none
+        - language: python
+          build-mode: none
+        # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
+        # Use `c-cpp` to analyze code written in C, C++ or both
+        # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
+        # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+        # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
+        # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
+        # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
+        # 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
+
+    # Add any setup steps before running the `github/codeql-action/init` action.
+    # This includes steps like installing compilers or runtimes (`actions/setup-node`
+    # or others). This is typically only required for manual builds.
+    # - name: Setup runtime (example)
+    #   uses: actions/setup-example@v1
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v3
+      with:
+        languages: ${{ matrix.language }}
+        build-mode: ${{ matrix.build-mode }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+
+        # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+        # queries: security-extended,security-and-quality
+
+    # If the analyze step fails for one of the languages you are analyzing with
+    # "We were unable to automatically build your code", modify the matrix above
+    # to set the build mode to "manual" for that language. Then modify this step
+    # to build your code.
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+    - if: matrix.build-mode == 'manual'
+      shell: bash
+      run: |
+        echo 'If you are using a "manual" build mode for one or more of the' \
+          'languages you are analyzing, replace this with the commands to build' \
+          'your code, for example:'
+        echo '  make bootstrap'
+        echo '  make release'
+        exit 1
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v3
+      with:
+        category: "/language:${{matrix.language}}"

From d7ce09fa75a7a86ed089ecff330ee86333df5200 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 30 May 2025 14:51:21 -0400
Subject: [PATCH 25/72] Keep running CodeQL on all branches, etc.

This restores three aspects of the previous `codeql.yml`:

- Run it on all branches, not just `main`.

- Run it on the previous schedule rather than the new one, since
  there's no reason to change the schedule (though there's no
  reason to be attached to the old schedule either).

- Use "CodeQL" rather than "CodeQL Advanced" as the workflow
  `name`, since this takes up less horizontal space when reading
  the reports from the checks.

Of these, only the first is really significant.
---
 .github/workflows/codeql.yml | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 450166503..3d273f9c7 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -9,15 +9,13 @@
 # the `language` matrix defined below to confirm you have the correct set of
 # supported CodeQL languages.
 #
-name: "CodeQL Advanced"
+name: "CodeQL"
 
 on:
   push:
-    branches: [ "main" ]
   pull_request:
-    branches: [ "main" ]
   schedule:
-    - cron: '36 18 * * 0'
+    - cron: '27 10 * * 3'
 
 jobs:
   analyze:

From 89dbd4a85ae78c272e5714e827f70783df04f924 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 30 May 2025 14:58:09 -0400
Subject: [PATCH 26/72] Remove unnecessary permissions from `codeql.yml`

This is another change back to the way we had it before, but the
removals are based specifically on the guidance in the default
workflow comments about why each permission was given by default.
---
 .github/workflows/codeql.yml | 8 --------
 1 file changed, 8 deletions(-)

diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 3d273f9c7..2bee952af 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -27,16 +27,8 @@ jobs:
     # Consider using larger runners or machines with greater resources for possible analysis time improvements.
     runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
     permissions:
-      # required for all workflows
       security-events: write
 
-      # required to fetch internal or private CodeQL packs
-      packages: read
-
-      # only required for workflows in private repositories
-      actions: read
-      contents: read
-
     strategy:
       fail-fast: false
       matrix:

From a9833d635dd5201cd94cc9d061590e41e24ea0cc Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 30 May 2025 15:38:00 -0400
Subject: [PATCH 27/72] Specify explicit `contents: read` workflow permissions

Three CI workflows that need only `contents: read` permissions and
no other permissions did not have explicit permissions set, and
would therefore be given default permissions configured for the
repository, which might be more expansive than the workflows need.

It is recommended to set explicit workflow permissions [1]. This
does that, specifying permissions as `pythonpackage.yml` already
did, and closing three `actions/missing-workflow-permissions`
CodeQL alerts (new since #2032 enabled scanning of GHA workflows).

[1]: https://codeql.github.com/codeql-query-help/actions/actions-missing-workflow-permissions/
---
 .github/workflows/alpine-test.yml | 3 +++
 .github/workflows/cygwin-test.yml | 3 +++
 .github/workflows/lint.yml        | 3 +++
 3 files changed, 9 insertions(+)

diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml
index bd09a939b..513c65bb8 100644
--- a/.github/workflows/alpine-test.yml
+++ b/.github/workflows/alpine-test.yml
@@ -2,6 +2,9 @@ name: test-alpine
 
 on: [push, pull_request, workflow_dispatch]
 
+permissions:
+  contents: read
+
 jobs:
   test:
     runs-on: ubuntu-latest
diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 278777907..572a9197e 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -2,6 +2,9 @@ name: test-cygwin
 
 on: [push, pull_request, workflow_dispatch]
 
+permissions:
+  contents: read
+
 jobs:
   test:
     runs-on: windows-latest
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index a0e81a993..ceba0dd85 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -2,6 +2,9 @@ name: Lint
 
 on: [push, pull_request, workflow_dispatch]
 
+permissions:
+  contents: read
+
 jobs:
   lint:
     runs-on: ubuntu-latest

From 00b46127e54c104ac86333150708acdccce98cb7 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 3 Jun 2025 03:22:44 +0000
Subject: [PATCH 28/72] Bump git/ext/gitdb from `f36c0cc` to `7e02fbd`

Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `f36c0cc` to `7e02fbd`.
- [Release notes](https://github.com/gitpython-developers/gitdb/releases)
- [Commits](https://github.com/gitpython-developers/gitdb/compare/f36c0cc42ea2f529291e441073f74e920988d4d2...7e02fbde5fcfcd52f541995fbcde22e85535adef)

---
updated-dependencies:
- dependency-name: git/ext/gitdb
  dependency-version: 7e02fbde5fcfcd52f541995fbcde22e85535adef
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 git/ext/gitdb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/ext/gitdb b/git/ext/gitdb
index f36c0cc42..335c0f661 160000
--- a/git/ext/gitdb
+++ b/git/ext/gitdb
@@ -1 +1 @@
-Subproject commit f36c0cc42ea2f529291e441073f74e920988d4d2
+Subproject commit 335c0f66173eecdc7b2597c2b6c3d1fde795df30

From 67468a2396f56f792ac4c139f43c98745e93a2b5 Mon Sep 17 00:00:00 2001
From: betaboon <betaboon@0x80.ninja>
Date: Fri, 6 Jun 2025 22:41:28 +0200
Subject: [PATCH 29/72] Fix GitConfigParser not removing quotes from values

---
 git/config.py                        | 2 ++
 test/fixtures/git_config_with_quotes | 3 +++
 test/test_config.py                  | 8 +++++++-
 3 files changed, 12 insertions(+), 1 deletion(-)
 create mode 100644 test/fixtures/git_config_with_quotes

diff --git a/git/config.py b/git/config.py
index de3508360..2df99a753 100644
--- a/git/config.py
+++ b/git/config.py
@@ -508,6 +508,8 @@ def string_decode(v: str) -> str:
                     if len(optval) > 1 and optval[0] == '"' and optval[-1] != '"':
                         is_multi_line = True
                         optval = string_decode(optval[1:])
+                    elif len(optval) > 1 and optval[0] == '"' and optval[-1] == '"':
+                        optval = optval[1:-1].strip()
                     # END handle multi-line
                     # Preserves multiple values for duplicate optnames.
                     cursect.add(optname, optval)
diff --git a/test/fixtures/git_config_with_quotes b/test/fixtures/git_config_with_quotes
new file mode 100644
index 000000000..40e6710d9
--- /dev/null
+++ b/test/fixtures/git_config_with_quotes
@@ -0,0 +1,3 @@
+[user]
+  name = "Cody Veal"
+  email = "cveal05@gmail.com"
diff --git a/test/test_config.py b/test/test_config.py
index 92997422d..886d5b136 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -391,7 +391,7 @@ def test_complex_aliases(self):
         with GitConfigParser(file_obj, read_only=False) as w_config:
             self.assertEqual(
                 w_config.get("alias", "rbi"),
-                '"!g() { git rebase -i origin/${1:-master} ; } ; g"',
+                "!g() { git rebase -i origin/${1:-master} ; } ; g",
             )
         self.assertEqual(
             file_obj.getvalue(),
@@ -406,6 +406,12 @@ def test_empty_config_value(self):
         with self.assertRaises(cp.NoOptionError):
             cr.get_value("color", "ui")
 
+    def test_config_with_quotes(self):
+        cr = GitConfigParser(fixture_path("git_config_with_quotes"), read_only=True)
+
+        self.assertEqual(cr.get("user", "name"), "Cody Veal")
+        self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com")
+
     def test_get_values_works_without_requiring_any_other_calls_first(self):
         file_obj = self._to_memcache(fixture_path("git_config_multiple"))
         cr = GitConfigParser(file_obj, read_only=True)

From 5f303202ee0666f5298ff39a5a129162b69ff790 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 01:08:07 -0400
Subject: [PATCH 30/72] Test a quoted config var with meaningful edge
 whitespace

#2035 fixed issue #1923 by removing separate double quotation marks
appearing on a single-line configuration variable when parsing a
configuration file. However, it also stripped leading and trailing
whitespace from the string obtained by removing the quotes.

This adds a test case of a plausible scenario where such whitespace
needs to be preserved and where a user would almost certainly expect
it to preserve: setting a value like `# ` for `core.commentString`,
in order to be able to easily create commit messages like this one,
that contain a line that begins with a literal `#`, while still
letting `#` in the more common case that it is followed by a space
be interpreted as a comment.

The effect of  `git config --local core.commentString '# '` is to
add a `commentString = "# "` line in the `[core]` section of
`.git/config`. The changes in #2035 allow us to correctly parse
more quoted strings than before, and almost allow us to parse this,
but not quite, because of the `strip()` operation that turns `# `
into `#`.
---
 test/fixtures/git_config_with_quotes_whitespace | 2 ++
 test/test_config.py                             | 4 ++++
 2 files changed, 6 insertions(+)
 create mode 100644 test/fixtures/git_config_with_quotes_whitespace

diff --git a/test/fixtures/git_config_with_quotes_whitespace b/test/fixtures/git_config_with_quotes_whitespace
new file mode 100644
index 000000000..c6014cc61
--- /dev/null
+++ b/test/fixtures/git_config_with_quotes_whitespace
@@ -0,0 +1,2 @@
+[core]
+  commentString = "# "
diff --git a/test/test_config.py b/test/test_config.py
index 886d5b136..671f34046 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -412,6 +412,10 @@ def test_config_with_quotes(self):
         self.assertEqual(cr.get("user", "name"), "Cody Veal")
         self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com")
 
+    def test_config_with_quotes_with_literal_whitespace(self):
+        cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace"), read_only=True)
+        self.assertEqual(cr.get("core", "commentString"), "# ")
+
     def test_get_values_works_without_requiring_any_other_calls_first(self):
         file_obj = self._to_memcache(fixture_path("git_config_multiple"))
         cr = GitConfigParser(file_obj, read_only=True)

From bd2b930503ad5d97322aa81d7610ab743d915f84 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 00:13:46 -0400
Subject: [PATCH 31/72] Preserve quoted leading and trailing single-line
 whitespace

At least in a single line, whitespace in a double-quoted value in a
configuration file, like `name = " abc def "`, would presumably be
intended. This removes the `strip()` call that is applied to text
`ConfigParser` obtained by removing the double quotes around it.

This slightly refines the changes in #2035 by dropping the `strip()`
call while continuing to remove opening and closing double quotes.
---
 git/config.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/config.py b/git/config.py
index 2df99a753..1d58db53a 100644
--- a/git/config.py
+++ b/git/config.py
@@ -509,7 +509,7 @@ def string_decode(v: str) -> str:
                         is_multi_line = True
                         optval = string_decode(optval[1:])
                     elif len(optval) > 1 and optval[0] == '"' and optval[-1] == '"':
-                        optval = optval[1:-1].strip()
+                        optval = optval[1:-1]
                     # END handle multi-line
                     # Preserves multiple values for duplicate optnames.
                     cursect.add(optname, optval)

From 5a8a4059d646fd313e81365ef240562556d8bd3f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 6 Jun 2025 23:16:45 -0400
Subject: [PATCH 32/72] Refactor Git.{AutoInterrupt,CatFileContentStream}
 nesting

This makes `Git.AutoInterrupt` and `Git.CatFileContentStream`
transparent aliases to top-level nonpublic `_AutoInterrupt` and
`_CatFileContentStream` classes in the `cmd` module.

This does not change the "public" interface. It also does not
change metadata relevant to documentation: the `__name__` and
`__qualname__` attributes are set explicitly to the values they had
before when these classes were defined nested, so that Sphinx
continues to document them (and to do so in full) in `Git` and as
`Git.AutoInterrupt` and `Git.CatFileContentStream`.

The purpose of this is to increase readability. The `Git` class is
big and complex, with a number of long members and various forms of
nesting. Since these two classes can be understood even without
reading the code of the `Git` class, moving the definitions out of
the `Git` class into top-level nonpublic classes will hopefully
increase readability and help with maintenance.
---
 git/cmd.py | 440 +++++++++++++++++++++++++++--------------------------
 1 file changed, 226 insertions(+), 214 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 7f46edc8f..a6195880d 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -308,6 +308,230 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc
 
 ## -- End Utilities -- @}
 
+
+class _AutoInterrupt:
+    """Process wrapper that terminates the wrapped process on finalization.
+
+    This kills/interrupts the stored process instance once this instance goes out of
+    scope. It is used to prevent processes piling up in case iterators stop reading.
+
+    All attributes are wired through to the contained process object.
+
+    The wait method is overridden to perform automatic status code checking and possibly
+    raise.
+    """
+
+    __slots__ = ("proc", "args", "status")
+
+    # If this is non-zero it will override any status code during _terminate, used
+    # to prevent race conditions in testing.
+    _status_code_if_terminate: int = 0
+
+    def __init__(self, proc: Union[None, subprocess.Popen], args: Any) -> None:
+        self.proc = proc
+        self.args = args
+        self.status: Union[int, None] = None
+
+    def _terminate(self) -> None:
+        """Terminate the underlying process."""
+        if self.proc is None:
+            return
+
+        proc = self.proc
+        self.proc = None
+        if proc.stdin:
+            proc.stdin.close()
+        if proc.stdout:
+            proc.stdout.close()
+        if proc.stderr:
+            proc.stderr.close()
+        # Did the process finish already so we have a return code?
+        try:
+            if proc.poll() is not None:
+                self.status = self._status_code_if_terminate or proc.poll()
+                return
+        except OSError as ex:
+            _logger.info("Ignored error after process had died: %r", ex)
+
+        # It can be that nothing really exists anymore...
+        if os is None or getattr(os, "kill", None) is None:
+            return
+
+        # Try to kill it.
+        try:
+            proc.terminate()
+            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)
+        # END exception handling
+
+    def __del__(self) -> None:
+        self._terminate()
+
+    def __getattr__(self, attr: str) -> Any:
+        return getattr(self.proc, attr)
+
+    # TODO: Bad choice to mimic `proc.wait()` but with different args.
+    def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
+        """Wait for the process and return its status code.
+
+        :param stderr:
+            Previously read value of stderr, in case stderr is already closed.
+
+        :warn:
+            May deadlock if output or error pipes are used and not handled separately.
+
+        :raise git.exc.GitCommandError:
+            If the return status is not 0.
+        """
+        if stderr is None:
+            stderr_b = b""
+        stderr_b = force_bytes(data=stderr, encoding="utf-8")
+        status: Union[int, None]
+        if self.proc is not None:
+            status = self.proc.wait()
+            p_stderr = self.proc.stderr
+        else:  # Assume the underlying proc was killed earlier or never existed.
+            status = self.status
+            p_stderr = None
+
+        def read_all_from_possibly_closed_stream(stream: Union[IO[bytes], None]) -> bytes:
+            if stream:
+                try:
+                    return stderr_b + force_bytes(stream.read())
+                except (OSError, ValueError):
+                    return stderr_b or b""
+            else:
+                return stderr_b or b""
+
+        # END status handling
+
+        if status != 0:
+            errstr = read_all_from_possibly_closed_stream(p_stderr)
+            _logger.debug("AutoInterrupt wait stderr: %r" % (errstr,))
+            raise GitCommandError(remove_password_if_present(self.args), status, errstr)
+        return status
+
+
+_AutoInterrupt.__name__ = "AutoInterrupt"
+_AutoInterrupt.__qualname__ = "Git.AutoInterrupt"
+
+
+class _CatFileContentStream:
+    """Object representing a sized read-only stream returning the contents of
+    an object.
+
+    This behaves like a stream, but counts the data read and simulates an empty stream
+    once our sized content region is empty.
+
+    If not all data are read to the end of the object's lifetime, we read the rest to
+    ensure the underlying stream continues to work.
+    """
+
+    __slots__ = ("_stream", "_nbr", "_size")
+
+    def __init__(self, size: int, stream: IO[bytes]) -> None:
+        self._stream = stream
+        self._size = size
+        self._nbr = 0  # Number of bytes read.
+
+        # Special case: If the object is empty, has null bytes, get the final
+        # newline right away.
+        if size == 0:
+            stream.read(1)
+        # END handle empty streams
+
+    def read(self, size: int = -1) -> bytes:
+        bytes_left = self._size - self._nbr
+        if bytes_left == 0:
+            return b""
+        if size > -1:
+            # Ensure we don't try to read past our limit.
+            size = min(bytes_left, size)
+        else:
+            # They try to read all, make sure it's not more than what remains.
+            size = bytes_left
+        # END check early depletion
+        data = self._stream.read(size)
+        self._nbr += len(data)
+
+        # Check for depletion, read our final byte to make the stream usable by
+        # others.
+        if self._size - self._nbr == 0:
+            self._stream.read(1)  # final newline
+        # END finish reading
+        return data
+
+    def readline(self, size: int = -1) -> bytes:
+        if self._nbr == self._size:
+            return b""
+
+        # Clamp size to lowest allowed value.
+        bytes_left = self._size - self._nbr
+        if size > -1:
+            size = min(bytes_left, size)
+        else:
+            size = bytes_left
+        # END handle size
+
+        data = self._stream.readline(size)
+        self._nbr += len(data)
+
+        # Handle final byte.
+        if self._size - self._nbr == 0:
+            self._stream.read(1)
+        # END finish reading
+
+        return data
+
+    def readlines(self, size: int = -1) -> List[bytes]:
+        if self._nbr == self._size:
+            return []
+
+        # Leave all additional logic to our readline method, we just check the size.
+        out = []
+        nbr = 0
+        while True:
+            line = self.readline()
+            if not line:
+                break
+            out.append(line)
+            if size > -1:
+                nbr += len(line)
+                if nbr > size:
+                    break
+            # END handle size constraint
+        # END readline loop
+        return out
+
+    # skipcq: PYL-E0301
+    def __iter__(self) -> "Git.CatFileContentStream":
+        return self
+
+    def __next__(self) -> bytes:
+        line = self.readline()
+        if not line:
+            raise StopIteration
+
+        return line
+
+    next = __next__
+
+    def __del__(self) -> None:
+        bytes_left = self._size - self._nbr
+        if bytes_left:
+            # Read and discard - seeking is impossible within a stream.
+            # This includes any terminating newline.
+            self._stream.read(bytes_left + 1)
+        # END handle incomplete read
+
+
+_CatFileContentStream.__name__ = "CatFileContentStream"
+_CatFileContentStream.__qualname__ = "Git.CatFileContentStream"
+
+
 _USE_SHELL_DEFAULT_MESSAGE = (
     "Git.USE_SHELL is deprecated, because only its default value of False is safe. "
     "It will be removed in a future release."
@@ -728,221 +952,9 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
                         f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it."
                     )
 
-    class AutoInterrupt:
-        """Process wrapper that terminates the wrapped process on finalization.
-
-        This kills/interrupts the stored process instance once this instance goes out of
-        scope. It is used to prevent processes piling up in case iterators stop reading.
-
-        All attributes are wired through to the contained process object.
-
-        The wait method is overridden to perform automatic status code checking and
-        possibly raise.
-        """
-
-        __slots__ = ("proc", "args", "status")
-
-        # If this is non-zero it will override any status code during _terminate, used
-        # to prevent race conditions in testing.
-        _status_code_if_terminate: int = 0
-
-        def __init__(self, proc: Union[None, subprocess.Popen], args: Any) -> None:
-            self.proc = proc
-            self.args = args
-            self.status: Union[int, None] = None
-
-        def _terminate(self) -> None:
-            """Terminate the underlying process."""
-            if self.proc is None:
-                return
-
-            proc = self.proc
-            self.proc = None
-            if proc.stdin:
-                proc.stdin.close()
-            if proc.stdout:
-                proc.stdout.close()
-            if proc.stderr:
-                proc.stderr.close()
-            # Did the process finish already so we have a return code?
-            try:
-                if proc.poll() is not None:
-                    self.status = self._status_code_if_terminate or proc.poll()
-                    return
-            except OSError as ex:
-                _logger.info("Ignored error after process had died: %r", ex)
-
-            # It can be that nothing really exists anymore...
-            if os is None or getattr(os, "kill", None) is None:
-                return
-
-            # Try to kill it.
-            try:
-                proc.terminate()
-                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)
-            # END exception handling
-
-        def __del__(self) -> None:
-            self._terminate()
-
-        def __getattr__(self, attr: str) -> Any:
-            return getattr(self.proc, attr)
-
-        # TODO: Bad choice to mimic `proc.wait()` but with different args.
-        def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
-            """Wait for the process and return its status code.
-
-            :param stderr:
-                Previously read value of stderr, in case stderr is already closed.
-
-            :warn:
-                May deadlock if output or error pipes are used and not handled
-                separately.
-
-            :raise git.exc.GitCommandError:
-                If the return status is not 0.
-            """
-            if stderr is None:
-                stderr_b = b""
-            stderr_b = force_bytes(data=stderr, encoding="utf-8")
-            status: Union[int, None]
-            if self.proc is not None:
-                status = self.proc.wait()
-                p_stderr = self.proc.stderr
-            else:  # Assume the underlying proc was killed earlier or never existed.
-                status = self.status
-                p_stderr = None
-
-            def read_all_from_possibly_closed_stream(stream: Union[IO[bytes], None]) -> bytes:
-                if stream:
-                    try:
-                        return stderr_b + force_bytes(stream.read())
-                    except (OSError, ValueError):
-                        return stderr_b or b""
-                else:
-                    return stderr_b or b""
-
-            # END status handling
-
-            if status != 0:
-                errstr = read_all_from_possibly_closed_stream(p_stderr)
-                _logger.debug("AutoInterrupt wait stderr: %r" % (errstr,))
-                raise GitCommandError(remove_password_if_present(self.args), status, errstr)
-            return status
-
-    # END auto interrupt
-
-    class CatFileContentStream:
-        """Object representing a sized read-only stream returning the contents of
-        an object.
-
-        This behaves like a stream, but counts the data read and simulates an empty
-        stream once our sized content region is empty.
-
-        If not all data are read to the end of the object's lifetime, we read the
-        rest to ensure the underlying stream continues to work.
-        """
-
-        __slots__ = ("_stream", "_nbr", "_size")
-
-        def __init__(self, size: int, stream: IO[bytes]) -> None:
-            self._stream = stream
-            self._size = size
-            self._nbr = 0  # Number of bytes read.
-
-            # Special case: If the object is empty, has null bytes, get the final
-            # newline right away.
-            if size == 0:
-                stream.read(1)
-            # END handle empty streams
-
-        def read(self, size: int = -1) -> bytes:
-            bytes_left = self._size - self._nbr
-            if bytes_left == 0:
-                return b""
-            if size > -1:
-                # Ensure we don't try to read past our limit.
-                size = min(bytes_left, size)
-            else:
-                # They try to read all, make sure it's not more than what remains.
-                size = bytes_left
-            # END check early depletion
-            data = self._stream.read(size)
-            self._nbr += len(data)
-
-            # Check for depletion, read our final byte to make the stream usable by
-            # others.
-            if self._size - self._nbr == 0:
-                self._stream.read(1)  # final newline
-            # END finish reading
-            return data
-
-        def readline(self, size: int = -1) -> bytes:
-            if self._nbr == self._size:
-                return b""
-
-            # Clamp size to lowest allowed value.
-            bytes_left = self._size - self._nbr
-            if size > -1:
-                size = min(bytes_left, size)
-            else:
-                size = bytes_left
-            # END handle size
-
-            data = self._stream.readline(size)
-            self._nbr += len(data)
-
-            # Handle final byte.
-            if self._size - self._nbr == 0:
-                self._stream.read(1)
-            # END finish reading
-
-            return data
-
-        def readlines(self, size: int = -1) -> List[bytes]:
-            if self._nbr == self._size:
-                return []
-
-            # Leave all additional logic to our readline method, we just check the size.
-            out = []
-            nbr = 0
-            while True:
-                line = self.readline()
-                if not line:
-                    break
-                out.append(line)
-                if size > -1:
-                    nbr += len(line)
-                    if nbr > size:
-                        break
-                # END handle size constraint
-            # END readline loop
-            return out
-
-        # skipcq: PYL-E0301
-        def __iter__(self) -> "Git.CatFileContentStream":
-            return self
-
-        def __next__(self) -> bytes:
-            line = self.readline()
-            if not line:
-                raise StopIteration
-
-            return line
-
-        next = __next__
+    AutoInterrupt = _AutoInterrupt
 
-        def __del__(self) -> None:
-            bytes_left = self._size - self._nbr
-            if bytes_left:
-                # Read and discard - seeking is impossible within a stream.
-                # This includes any terminating newline.
-                self._stream.read(bytes_left + 1)
-            # END handle incomplete read
+    CatFileContentStream = _CatFileContentStream
 
     def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
         """Initialize this instance with:

From c6d16d01c54d45be20de40dedae4e9471b96c8ab Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 11:35:08 -0400
Subject: [PATCH 33/72] Start on fixing AutoInterrupt/CatFileContentStream
 aliases

This uses `TypeAlias` from the `typing` module, to make it so the
assignment statments introduced in #2037 (to set `Git.AutoInterrupt`
and `Git.CatFileContentStream` to nonpublic module-level
implementations `_AutoInterrupt` and `_CatFileContentStream`) are
treated by `mypy` as type aliases rather than as class variables.

For details on the problem this partially fixes, see #2038 and:
https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases

The fix won't work in this form, however, because it attempts to
import `TypeAlias` unconditionally from the standard-library
`typing` module, which only gained it in Python 3.10.
---
 git/cmd.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index a6195880d..7015d376f 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -55,6 +55,7 @@
     TYPE_CHECKING,
     TextIO,
     Tuple,
+    TypeAlias,
     Union,
     cast,
     overload,
@@ -952,9 +953,9 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
                         f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it."
                     )
 
-    AutoInterrupt = _AutoInterrupt
+    AutoInterrupt: TypeAlias = _AutoInterrupt
 
-    CatFileContentStream = _CatFileContentStream
+    CatFileContentStream: TypeAlias = _CatFileContentStream
 
     def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
         """Initialize this instance with:

From c6c081230f4b96ca9efce4c9e2478396eaf348c9 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 11:52:36 -0400
Subject: [PATCH 34/72] Import `TypeAlias` from `typing_extensions` where
 needed

The standard library `typing` module introduced `TypeAlias` in
Python 3.10. This uses it from `typing_extensions` where neederd,
by making three changes:

- Change the version lower bound for `typing-extensions` from
  3.7.4.3 to 3.10.0.2, since 3.7.4.3 doesn't offer `TypeAlias`.

  (The reason not to go higher, to major version 4, is that
  it no longer supports versions of Python lower than 3.9, but
  we currently support Python 3.7 and Python 3.8.)

- Require the `typing-extensions` dependency when using Python
  versions lower than 3.10, rather than only lower than 3.7 as
  before.

- Conditionally import `TypeAlias` (in the `git.cmd` module) from
  either `typing` or `type_extensions` depending on the Python
  version, using a pattern that `mypy` and other type checkers
  recognize statically.

Together with the preceding commit, this fixes #2038. (This is
approach (2) described there.)
---
 git/cmd.py       | 6 +++++-
 requirements.txt | 2 +-
 2 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 7015d376f..6deded04b 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -55,12 +55,16 @@
     TYPE_CHECKING,
     TextIO,
     Tuple,
-    TypeAlias,
     Union,
     cast,
     overload,
 )
 
+if sys.version_info >= (3, 10):
+    from typing import TypeAlias
+else:
+    from typing_extensions import TypeAlias
+
 from git.types import Literal, PathLike, TBD
 
 if TYPE_CHECKING:
diff --git a/requirements.txt b/requirements.txt
index 7159416a9..61d8403b0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
 gitdb>=4.0.1,<5
-typing-extensions>=3.7.4.3;python_version<"3.8"
+typing-extensions>=3.10.0.2;python_version<"3.10"

From 8905e9e8f0244414fbff90c19b144d02b74af3b3 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 12:33:56 -0400
Subject: [PATCH 35/72] Fix CI `mypy` command on free-threaded Python

When the version is represented as `3.13t`, the `--python-version`
option needs an operand of `3.13`, not `3.13t`.

(This, and the fix here, will automatically apply to later threaded
Pythons, such as 3.14t, too.)
---
 .github/workflows/pythonpackage.yml | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 9fd660c6b..c4fbef48e 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -97,10 +97,11 @@ jobs:
 
     - name: Check types with mypy
       run: |
-        mypy --python-version=${{ matrix.python-version }}
+        mypy --python-version="${PYTHON_VERSION%t}"
       env:
         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.
       continue-on-error: true

From b45ae86aaaab25ccd07ee183382a0e78a92f65a2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 13:10:47 -0400
Subject: [PATCH 36/72] Add an explanatory comment for omitting "t"

Since the `${var%pattern}` syntax may not be immediately obvious.
---
 .github/workflows/pythonpackage.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index c4fbef48e..e7cb06cc0 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -97,7 +97,7 @@ jobs:
 
     - name: Check types with mypy
       run: |
-        mypy --python-version="${PYTHON_VERSION%t}"
+        mypy --python-version="${PYTHON_VERSION%t}"  # Version only, with no "t" for free-threaded.
       env:
         MYPY_FORCE_COLOR: "1"
         TERM: "xterm-256color"  # For color: https://github.com/python/mypy/issues/13817

From ba58f40d385e5f371c468470752a4d5aa0b16789 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 13:37:22 -0400
Subject: [PATCH 37/72] Split Cygwin CI into two test jobs

One job is for all tests except the `performance` tests, while the
other job is for only the `performance` tests.

The idea is to decrease the total time it takes for all CI jobs to
complete in most cases, by splitting the long-running (currently
usually about 13 minute) Cygwin job into two less-long jobs.
---
 .github/workflows/cygwin-test.yml | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 572a9197e..d73f0522e 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -10,6 +10,14 @@ jobs:
     runs-on: windows-latest
 
     strategy:
+      matrix:
+        selection: [fast, perf]
+        include:
+        - selection: fast
+          additional-pytest-args: --ignore=test/performance
+        - selection: perf
+          additional-pytest-args: test/performance/
+
       fail-fast: false
 
     env:
@@ -87,4 +95,4 @@ jobs:
 
     - name: Test with pytest
       run: |
-        pytest --color=yes -p no:sugar --instafail -vv
+        pytest --color=yes -p no:sugar --instafail -vv ${{ matrix.additional-pytest-args }}

From 414de64b095282ce39b9dd34876167e607b331cf Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 14:13:25 -0400
Subject: [PATCH 38/72] Show differing `pytest` arguments in step name

In the "Test with pytest" step of the Cygwin test jobs.

This is to distinguish the newly split jobs from each other more
clearly when glancing at their steps for a run.
---
 .github/workflows/cygwin-test.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index d73f0522e..3c1eb3dc0 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -93,6 +93,6 @@ jobs:
         python --version
         python -c 'import os, sys; print(f"sys.platform={sys.platform!r}, os.name={os.name!r}")'
 
-    - name: Test with pytest
+    - name: Test with pytest (${{ matrix.additional-pytest-args }})
       run: |
         pytest --color=yes -p no:sugar --instafail -vv ${{ matrix.additional-pytest-args }}

From a6c623eecf12eb525ad115b0be75c7449c4f8efe Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 14:18:14 -0400
Subject: [PATCH 39/72] Small stylistic consistency tweak

This removes an unnecessary trailing slash that I had not used
consistently anyway.
---
 .github/workflows/cygwin-test.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 3c1eb3dc0..7c3eeedca 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -16,7 +16,7 @@ jobs:
         - selection: fast
           additional-pytest-args: --ignore=test/performance
         - selection: perf
-          additional-pytest-args: test/performance/
+          additional-pytest-args: test/performance
 
       fail-fast: false
 

From 31e1c035e1af514d5d2a9ed6630f71663df7b265 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 15:01:21 -0400
Subject: [PATCH 40/72] Use static notation for `setuptools` in installation
 test

The `VirtualEnvironment` test helper is used in multiple places,
but it is only told to install/upgrade `pip` when used from
`test_installation`. It implements the functionality it would
ideally obtain by `upgrade_deps`, since the `upgrade_deps`
parameter is only avaiilable in `venv` when using Python 3.9 and
later.

When `pip` is installed, `upgrade_deps` would install `setuptools`
when using Python 3.11 or lower, but not when using Python 3.12 or
higher. `VirtualEnvironment` does the same. (The reason for this is
not just to avoid needlessly departing from what `upgrade_deps`
would do. Rather, it should not generally be necessary to have
`setuptools` installed for package management since Python 3.12,
and if it were necessary then this would a bug we would want to
detect while running tests.)

Previously this conditional specification of `setuptools` was done
by building different lists of package arguments to pass to `pip`,
by checking `sys.version_info` to decide whether to append the
string `setuptools`.

This commit changes how it is done, to use a static list of package
arguments instead. (The Python intepreter path continues to be
obtained dynamically, but all its positional arguments, including
those specifying packages, are now string literals.) The
conditional `setuptools` requirement is now expressed statically
using notation recognized by `pip`, as the string
`setuptools; python_version<"3.12"`.
---
 test/lib/helper.py | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/test/lib/helper.py b/test/lib/helper.py
index 5d91447ea..241d27341 100644
--- a/test/lib/helper.py
+++ b/test/lib/helper.py
@@ -415,9 +415,15 @@ def __init__(self, env_dir, *, with_pip):
 
         if with_pip:
             # The upgrade_deps parameter to venv.create is 3.9+ only, so do it this way.
-            command = [self.python, "-m", "pip", "install", "--upgrade", "pip"]
-            if sys.version_info < (3, 12):
-                command.append("setuptools")
+            command = [
+                self.python,
+                "-m",
+                "pip",
+                "install",
+                "--upgrade",
+                "pip",
+                'setuptools; python_version<"3.12"',
+            ]
             subprocess.check_output(command)
 
     @property

From 727f4e9e54362d0356066a26d0c22028a465cccd Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 15:21:00 -0400
Subject: [PATCH 41/72] Use static notation for `setuptools` in CI `pip`
 commands

`setuptools` may potentially be needed for installation to work
fully as desired prior to Python 3.12, so in those versions it is
installed automatically in any virtual environment that is created
with `pip` available. This is a behavior of the `venv` module that
is not specific to CI.

However, on CI we upgrade packages that are preinstalled in the
virtual environment, or that we may otherwise wish to be present.
This took the form of unconditionally installing/upgrading the
`pip` and `wheel` packages, but conditionally upgrading the
`setuptools` package only if we find that it is already installed.

This commit changes the behavior to statically specify the same
list of package specifications to `pip` in all environments and in
all versions of Python, but to use the static notation recognized
by `pip` to indicate that `setuptools` is to be instaled/upgraded
only if the Python version is strictly less than Python 3.12.

This seems to be more readable. It also avoids using unquoted shell
parameter expansion in a way that is potentially confusing (for
example, if we were running our CI script steps through ShellCheck,
then it would automatically balk at that construction). It is also
more consistent with how `test_installation` sets up its
environment (especially since 31e1c03, but actually even before
that, because it was still conditioning `setuptools` on the Python
version rather than whether it was already installed). Finally,
this behavior is what the preexisting comment already described.

This also adjusts the shell quoting style slightly in other related
commands (in the same workflows) that pass package specifications
to `pip`, for consistency.

(For now, `".[test]"` rather than `.[test]` remains written in the
readme because it works in `cmd.exe` as well as other shells, but
it may be changed there in the future too.)
---
 .github/workflows/alpine-test.yml   | 4 ++--
 .github/workflows/cygwin-test.yml   | 4 ++--
 .github/workflows/pythonpackage.yml | 6 +++---
 3 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml
index 513c65bb8..a3361798d 100644
--- a/.github/workflows/alpine-test.yml
+++ b/.github/workflows/alpine-test.yml
@@ -55,12 +55,12 @@ jobs:
       run: |
         # Get the latest pip, wheel, and prior to Python 3.12, setuptools.
         . .venv/bin/activate
-        python -m pip install -U pip $(pip freeze --all | grep -ow ^setuptools) wheel
+        python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel
 
     - name: Install project and test dependencies
       run: |
         . .venv/bin/activate
-        pip install ".[test]"
+        pip install '.[test]'
 
     - name: Show version and platform information
       run: |
diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 7c3eeedca..6943db09c 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -79,11 +79,11 @@ jobs:
     - name: Update PyPA packages
       run: |
         # Get the latest pip, wheel, and prior to Python 3.12, setuptools.
-        python -m pip install -U pip $(pip freeze --all | grep -ow ^setuptools) wheel
+        python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel
 
     - name: Install project and test dependencies
       run: |
-        pip install ".[test]"
+        pip install '.[test]'
 
     - name: Show version and platform information
       run: |
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index e7cb06cc0..c56d45df7 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -72,11 +72,11 @@ jobs:
     - name: Update PyPA packages
       run: |
         # Get the latest pip, wheel, and prior to Python 3.12, setuptools.
-        python -m pip install -U pip $(pip freeze --all | grep -ow ^setuptools) wheel
+        python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel
 
     - name: Install project and test dependencies
       run: |
-        pip install ".[test]"
+        pip install '.[test]'
 
     - name: Show version and platform information
       run: |
@@ -114,5 +114,5 @@ jobs:
     - name: Documentation
       if: matrix.python-version != '3.7'
       run: |
-        pip install ".[doc]"
+        pip install '.[doc]'
         make -C doc html

From 2b85cd1d7ccbbc596cf0d3149bdc854498ebe35e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 16:35:08 -0400
Subject: [PATCH 42/72] Fix ambiguous `_safer_popen_windows` comment

This fixes some ambiguous wording in a comment in
`_safer_popen_wording`, where it was unclear if the secondary
problem -- where it would be possible to run a wrong `cmd.exe`-type
shell -- would happen under two separate circumstances, or only
when both circumstances occurred together. This adjusts its wording
to make clear that it is the latter.

This also fixes a minor typo in another `_safer_popen_windows`
comment.

This might be viewed as building on the improvements in b9d9e56
(#1859), but the changes here are to comments only.
---
 git/cmd.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 6deded04b..71096197c 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -273,12 +273,12 @@ def _safer_popen_windows(
         if shell:
             # The original may be immutable, or the caller may reuse it. Mutate a copy.
             env = {} if env is None else dict(env)
-            env["NoDefaultCurrentDirectoryInExePath"] = "1"  # The "1" can be an value.
+            env["NoDefaultCurrentDirectoryInExePath"] = "1"  # The "1" can be any value.
 
         # When not using a shell, the current process does the search in a
         # CreateProcessW API call, so the variable must be set in our environment. With
         # a shell, that's unnecessary if https://github.com/python/cpython/issues/101283
-        # is patched. In Python versions where it is unpatched, and in the rare case the
+        # is patched. In Python versions where it is unpatched, in the rare case the
         # ComSpec environment variable is unset, the search for the shell itself is
         # unsafe. Setting NoDefaultCurrentDirectoryInExePath in all cases, as done here,
         # is simpler and protects against that. (As above, the "1" can be any value.)

From 253099fe91744801c39b41527e126c85c17ec003 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 18:28:17 -0400
Subject: [PATCH 43/72] Clarify `USE_SHELL` warning helper signature

This is a minor refactor of how `_warn_use_shell` can be, and is,
invoked.

The `_warn_use_shell` helper function in `git.cmd` takes a single
`bool`-valued argument `extra_danger`, which is conceptually
associated with having a `True` value of `USE_SHELL`, but the
association is not necessarily obvious. Specifically:

- For the warning given when reading `USE_SHELL` on the `Git` class
  or through an instance, `extra_danger` is always `False`. This is
  so even if the `USE_SHELL` value is currently `True`, because the
  danger that arises from `True` occurs internally.

- For the warning given when writing `USE_SHELL`, which can only be
  done on the `Git` class and not on or through an instance,
  `extra_danger` is the value set for the attribute. This is
  because setting `USE_SHELL` to `True` incurs the danger described
  in #1896.

When reading the code, which passed `extra_danger` positionally,
the meaning of the parameter may not always have been obvious.

This makes the `extra_danger` parameter keyword-only, and passes
it by keyword in all invocations, so that its meaning is clearer.
---
 git/cmd.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 71096197c..15d7820df 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -550,7 +550,7 @@ def __del__(self) -> None:
 )
 
 
-def _warn_use_shell(extra_danger: bool) -> None:
+def _warn_use_shell(*, extra_danger: bool) -> None:
     warnings.warn(
         _USE_SHELL_DANGER_MESSAGE if extra_danger else _USE_SHELL_DEFAULT_MESSAGE,
         DeprecationWarning,
@@ -566,12 +566,12 @@ class _GitMeta(type):
 
     def __getattribute(cls, name: str) -> Any:
         if name == "USE_SHELL":
-            _warn_use_shell(False)
+            _warn_use_shell(extra_danger=False)
         return super().__getattribute__(name)
 
     def __setattr(cls, name: str, value: Any) -> Any:
         if name == "USE_SHELL":
-            _warn_use_shell(value)
+            _warn_use_shell(extra_danger=value)
         super().__setattr__(name, value)
 
     if not TYPE_CHECKING:
@@ -988,7 +988,7 @@ def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
 
     def __getattribute__(self, name: str) -> Any:
         if name == "USE_SHELL":
-            _warn_use_shell(False)
+            _warn_use_shell(extra_danger=False)
         return super().__getattribute__(name)
 
     def __getattr__(self, name: str) -> Any:

From e5a2db58d4d7fee765bd1146647bf2c09b026ce8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 19:24:31 -0400
Subject: [PATCH 44/72] Test `ConfigParser` with whitespace outside the value

In both the:

- Unquoted case, where extra whitespace is at the edges of what can
  be parsed as the value.

- Quoted case, where extra whitespace is next to but outside of the
  quotes.

The case where the whitespace is at the edges of the quoted value
and *inside* the quotes, and thus part of the value, is already
covered in #2036. (That is merely renamed here, to distinguish it.)
---
 test/fixtures/git_config_with_extra_whitespace         |  2 ++
 ...espace => git_config_with_quotes_whitespace_inside} |  0
 .../fixtures/git_config_with_quotes_whitespace_outside |  2 ++
 test/test_config.py                                    | 10 +++++++++-
 4 files changed, 13 insertions(+), 1 deletion(-)
 create mode 100644 test/fixtures/git_config_with_extra_whitespace
 rename test/fixtures/{git_config_with_quotes_whitespace => git_config_with_quotes_whitespace_inside} (100%)
 create mode 100644 test/fixtures/git_config_with_quotes_whitespace_outside

diff --git a/test/fixtures/git_config_with_extra_whitespace b/test/fixtures/git_config_with_extra_whitespace
new file mode 100644
index 000000000..0f727cb5d
--- /dev/null
+++ b/test/fixtures/git_config_with_extra_whitespace
@@ -0,0 +1,2 @@
+[init]
+  defaultBranch =  trunk  
diff --git a/test/fixtures/git_config_with_quotes_whitespace b/test/fixtures/git_config_with_quotes_whitespace_inside
similarity index 100%
rename from test/fixtures/git_config_with_quotes_whitespace
rename to test/fixtures/git_config_with_quotes_whitespace_inside
diff --git a/test/fixtures/git_config_with_quotes_whitespace_outside b/test/fixtures/git_config_with_quotes_whitespace_outside
new file mode 100644
index 000000000..4b1615a51
--- /dev/null
+++ b/test/fixtures/git_config_with_quotes_whitespace_outside
@@ -0,0 +1,2 @@
+[init]
+  defaultBranch =  "trunk"  
diff --git a/test/test_config.py b/test/test_config.py
index 671f34046..879b98365 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -398,6 +398,10 @@ def test_complex_aliases(self):
             self._to_memcache(fixture_path(".gitconfig")).getvalue(),
         )
 
+    def test_config_with_extra_whitespace(self):
+        cr = GitConfigParser(fixture_path("git_config_with_extra_whitespace"), read_only=True)
+        self.assertEqual(cr.get("init", "defaultBranch"), "trunk")
+
     def test_empty_config_value(self):
         cr = GitConfigParser(fixture_path("git_config_with_empty_value"), read_only=True)
 
@@ -413,9 +417,13 @@ def test_config_with_quotes(self):
         self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com")
 
     def test_config_with_quotes_with_literal_whitespace(self):
-        cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace"), read_only=True)
+        cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_inside"), read_only=True)
         self.assertEqual(cr.get("core", "commentString"), "# ")
 
+    def test_config_with_quotes_with_whitespace_outside_value(self):
+        cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_outside"), read_only=True)
+        self.assertEqual(cr.get("init", "defaultBranch"), "trunk")
+
     def test_get_values_works_without_requiring_any_other_calls_first(self):
         file_obj = self._to_memcache(fixture_path("git_config_multiple"))
         cr = GitConfigParser(file_obj, read_only=True)

From 4ebe407493363a814f590a97f3734364e6c5c1e1 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 22:33:35 -0400
Subject: [PATCH 45/72] Test that ConfigParser treats empty quotes as an empty
 value

The ConfigParser has supported this for a long time, but it is now
done redundantly since #2035. This adds a test for it, both to make
clearer that it is intended to work and to allow verifying that it
continues to hold once the legacy special-casing for it is removed.
---
 test/fixtures/git_config_with_empty_quotes | 2 ++
 test/test_config.py                        | 4 ++++
 2 files changed, 6 insertions(+)
 create mode 100644 test/fixtures/git_config_with_empty_quotes

diff --git a/test/fixtures/git_config_with_empty_quotes b/test/fixtures/git_config_with_empty_quotes
new file mode 100644
index 000000000..f11fe4248
--- /dev/null
+++ b/test/fixtures/git_config_with_empty_quotes
@@ -0,0 +1,2 @@
+[core]
+  filemode = ""
diff --git a/test/test_config.py b/test/test_config.py
index 879b98365..76b918a54 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -416,6 +416,10 @@ def test_config_with_quotes(self):
         self.assertEqual(cr.get("user", "name"), "Cody Veal")
         self.assertEqual(cr.get("user", "email"), "cveal05@gmail.com")
 
+    def test_config_with_empty_quotes(self):
+        cr = GitConfigParser(fixture_path("git_config_with_empty_quotes"), read_only=True)
+        self.assertEqual(cr.get("core", "filemode"), "", "quotes can form a literal empty string as value")
+
     def test_config_with_quotes_with_literal_whitespace(self):
         cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_inside"), read_only=True)
         self.assertEqual(cr.get("core", "commentString"), "# ")

From 2f225244286567b72c865fc7c0219c7dc043575f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 22:59:24 -0400
Subject: [PATCH 46/72] Remove explicit empty `""` handling in ConfigParser

Because literal `""` is a special case of `"..."` as parsed
since #2035.
---
 git/config.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/git/config.py b/git/config.py
index 1d58db53a..cdcc32e7b 100644
--- a/git/config.py
+++ b/git/config.py
@@ -501,9 +501,6 @@ def string_decode(v: str) -> str:
                         if pos != -1 and optval[pos - 1].isspace():
                             optval = optval[:pos]
                     optval = optval.strip()
-                    if optval == '""':
-                        optval = ""
-                    # END handle empty string
                     optname = self.optionxform(optname.rstrip())
                     if len(optval) > 1 and optval[0] == '"' and optval[-1] != '"':
                         is_multi_line = True

From c8e4aa0f30d06ea2437539a5d56eede1ffa11432 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 23:15:05 -0400
Subject: [PATCH 47/72] Refactor quote parsing, to prepare for more checks

This refactors ConfigParser double-quote parsing near the single
line double-quoted value parsing code, so that:

- Code that parses the name is less intermixed with code that
  parses the value.

- Conditional logic is less duplicated.

- The `END` comment notation appears next to the code it describes.

- The final `else` can be turned into one or more `elif` followed
  by `else` to cover different cases of `"..."` differently. (But
  those are not added here. This commit is purely a refactoring.)

(The `pass` suite when `len(optval) < 2 or optval[0] != '"'` is
awkward and not really justified right now, but it looks like it
may be able to help with readabilty and help keep nesting down
when new `elif` cases are added.)
---
 git/config.py | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/git/config.py b/git/config.py
index cdcc32e7b..afec498f2 100644
--- a/git/config.py
+++ b/git/config.py
@@ -496,18 +496,23 @@ def string_decode(v: str) -> str:
                 if mo:
                     # We might just have handled the last line, which could contain a quotation we want to remove.
                     optname, vi, optval = mo.group("option", "vi", "value")
+                    optname = self.optionxform(optname.rstrip())
+
                     if vi in ("=", ":") and ";" in optval and not optval.strip().startswith('"'):
                         pos = optval.find(";")
                         if pos != -1 and optval[pos - 1].isspace():
                             optval = optval[:pos]
                     optval = optval.strip()
-                    optname = self.optionxform(optname.rstrip())
-                    if len(optval) > 1 and optval[0] == '"' and optval[-1] != '"':
+
+                    if len(optval) < 2 or optval[0] != '"':
+                        pass  # Nothing to treat as opening quotation.
+                    elif optval[-1] != '"':
                         is_multi_line = True
                         optval = string_decode(optval[1:])
-                    elif len(optval) > 1 and optval[0] == '"' and optval[-1] == '"':
-                        optval = optval[1:-1]
                     # END handle multi-line
+                    else:
+                        optval = optval[1:-1]
+
                     # Preserves multiple values for duplicate optnames.
                     cursect.add(optname, optval)
                 else:

From 7bcea08873ca4052ab743f9e2f7cff42e7fe62d8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 7 Jun 2025 23:59:46 -0400
Subject: [PATCH 48/72] Add tests of ConfigParser with `"..."` with `"` or `\`
 inside

These are cases where just removing the outer quotes without doing
anything to the text inside does not give the correct result, and
where keeping the quotes may be preferable, in that it was the
long-standing behavior of `GitConfigParser`.

That this was the long-standing behavior may justify bringing it
back when the `"`-`"`-enclosed text contains such characters, but
it does not justify preserving it indefinitely: it will still be
better to parse the escape sequences, at least in the type case
that all of them in a value's representation are well-formed.
---
 test/fixtures/git_config_with_quotes_escapes |  9 +++++++++
 test/test_config.py                          | 20 ++++++++++++++++++++
 2 files changed, 29 insertions(+)
 create mode 100644 test/fixtures/git_config_with_quotes_escapes

diff --git a/test/fixtures/git_config_with_quotes_escapes b/test/fixtures/git_config_with_quotes_escapes
new file mode 100644
index 000000000..33332c221
--- /dev/null
+++ b/test/fixtures/git_config_with_quotes_escapes
@@ -0,0 +1,9 @@
+[custom]
+  hasnewline = "first\nsecond"
+  hasbackslash = "foo\\bar"
+  hasquote = "ab\"cd"
+  hastrailingbackslash = "word\\"
+  hasunrecognized = "p\qrs"
+  hasunescapedquotes = "ab"cd"e"
+  ordinary = "hello world"
+  unquoted = good evening
diff --git a/test/test_config.py b/test/test_config.py
index 76b918a54..8e1007d9e 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -428,6 +428,26 @@ def test_config_with_quotes_with_whitespace_outside_value(self):
         cr = GitConfigParser(fixture_path("git_config_with_quotes_whitespace_outside"), read_only=True)
         self.assertEqual(cr.get("init", "defaultBranch"), "trunk")
 
+    def test_config_with_quotes_containing_escapes(self):
+        """For now just suppress quote removal. But it would be good to interpret most of these."""
+        cr = GitConfigParser(fixture_path("git_config_with_quotes_escapes"), read_only=True)
+
+        # These can eventually be supported by substituting the represented character.
+        self.assertEqual(cr.get("custom", "hasnewline"), R'"first\nsecond"')
+        self.assertEqual(cr.get("custom", "hasbackslash"), R'"foo\\bar"')
+        self.assertEqual(cr.get("custom", "hasquote"), R'"ab\"cd"')
+        self.assertEqual(cr.get("custom", "hastrailingbackslash"), R'"word\\"')
+        self.assertEqual(cr.get("custom", "hasunrecognized"), R'"p\qrs"')
+
+        # It is less obvious whether and what to eventually do with this.
+        self.assertEqual(cr.get("custom", "hasunescapedquotes"), '"ab"cd"e"')
+
+        # Cases where quote removal is clearly safe should happen even after those.
+        self.assertEqual(cr.get("custom", "ordinary"), "hello world")
+
+        # Cases without quotes should still parse correctly even after those, too.
+        self.assertEqual(cr.get("custom", "unquoted"), "good evening")
+
     def test_get_values_works_without_requiring_any_other_calls_first(self):
         file_obj = self._to_memcache(fixture_path("git_config_multiple"))
         cr = GitConfigParser(file_obj, read_only=True)

From f2b80410e96a256aed044fba0387eab0440a1525 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 8 Jun 2025 00:09:49 -0400
Subject: [PATCH 49/72] Don't remove quotes if `\` or `"` are present inside

This is for single line quoting in the ConfigParser.

This leaves the changes in #2035 (as adjusted in #2036) intact for
the cases where it addressed #1923: when the `...` in `"..."`
(appearing in the value position on a single `{name} = {value}"`
line) has no occurrences of `\` or `"`, quote removal is enough.

But when `\` or `"` does appear, this suppresses quote removal.
This is with the idea that, while it would be better to interpret
such lines as Git does, we do not yet do that, so it is preferable
to return the same results we have in the past (which some programs
may already be handling themselves).

This should make the test introduced in the preceding commit pass.
But it will be even better to support more syntax, at least
well-formed escapes. As noted in the test, both the test and the
code under test can be adjusted for that.

(See comments in #2035 for context.)
---
 git/config.py | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/git/config.py b/git/config.py
index afec498f2..5fc099a27 100644
--- a/git/config.py
+++ b/git/config.py
@@ -505,13 +505,16 @@ def string_decode(v: str) -> str:
                     optval = optval.strip()
 
                     if len(optval) < 2 or optval[0] != '"':
-                        pass  # Nothing to treat as opening quotation.
+                        # Does not open quoting.
+                        pass
                     elif optval[-1] != '"':
+                        # Opens quoting and does not close: appears to start multi-line quoting.
                         is_multi_line = True
                         optval = string_decode(optval[1:])
-                    # END handle multi-line
-                    else:
+                    elif optval.find("\\", 1, -1) == -1 and optval.find('"', 1, -1) == -1:
+                        # Opens and closes quoting. Single line, and all we need is quote removal.
                         optval = optval[1:-1]
+                    # TODO: Handle other quoted content, especially well-formed backslash escapes.
 
                     # Preserves multiple values for duplicate optnames.
                     cursect.add(optname, optval)

From 1b79d449c90cc6fca8220246e2de159d24666837 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 8 Jun 2025 14:33:58 -0400
Subject: [PATCH 50/72] Improve line-ending consistency in requirements files

`requirements-dev.txt`, but none of the others, was tracked with
Windows-style (CRLF) line endings. This appears to have been the
case since it was introduced in a1b7634 (as `dev-requirements.txt`)
and not to be intentional.

This only changes how it is stored in the repository. This does not
change `.gitattributes` (it is not forced to have LF line endings
if automatic line-ending conversions are configured in Git).
---
 requirements-dev.txt | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/requirements-dev.txt b/requirements-dev.txt
index f626644af..01cb2d040 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,8 +1,8 @@
--r requirements.txt
--r test-requirements.txt
-
-# For additional local testing/linting - to be added elsewhere eventually.
-ruff
-shellcheck
-pytest-icdiff
-# pytest-profiling
+-r requirements.txt
+-r test-requirements.txt
+
+# For additional local testing/linting - to be added elsewhere eventually.
+ruff
+shellcheck
+pytest-icdiff
+# pytest-profiling

From 6f4f7f5137d63facb61eae2955e6c0801c71b7b5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 8 Jun 2025 15:05:03 -0400
Subject: [PATCH 51/72] Update Ruff configuration

This resolves two warnings about Ruff configuration, by:

- No longer setting `ignore-init-module-imports = true` explicitly,
  which was deprecated since `ruff` 0.4.4. We primarily use `ruff`
  via `pre-commit`, for which this deprecation has applied since we
  upgraded the version in `.pre-commit-config.yaml` from 0.4.3 to
  0.6.0 in d1582d1 (#1953).

  We continue to list `F401` ("Module imported but unused") as not
  automatically fixable, to avoid inadvertently removing imports
  that may be needed.

  See also:
  https://docs.astral.sh/ruff/settings/#lint_ignore-init-module-imports

- Rename the rule `TCH004` to `TC004`, since `TCH004` is the old
  name that may eventually be removed and that is deprecated since
  0.8.0. We upgraded `ruff` in `.pre-commit-config.yml` again in
  b7ce712 (#2031), from 0.6.0 to 0.11.12, at which point this
  deprecation applied.

  See also https://astral.sh/blog/ruff-v0.8.0.

These changes make those configuration-related warnings go away,
and no new diagnostics (errors/warnings) are produced when running
`ruff check` or `pre-commit run --all-files`. No F401-related
diagnostics are triggered when testing with explicit
`ignore-init-module-imports = false`, in preview mode or otherwise.

In addition, this commit makes two changes that are not needed to
resolve warnings:

- Stop excluding `E203` ("Whitespace before ':'"). That diagnostic
  is no longer failing with the current code here in the current
  version of `ruff`, and code changes that would cause it to fail
  would likely be accidentally mis-st

- Add the version lower bound `>=0.8` for `ruff` in
  `requirements-dev.txt`. That file is rarely used, as noted in
  a8a73ff7 (#1871), but as long as we have it, there may be a
  benefit to excluding dependency versions for which our
  configuration is no longer compatible. This is the only change in
  this commit outside of `pyproject.toml`.
---
 pyproject.toml       | 10 ++++------
 requirements-dev.txt |  2 +-
 2 files changed, 5 insertions(+), 7 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 090972eed..0097e9951 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -60,16 +60,14 @@ lint.select = [
     # "UP",  # See: https://docs.astral.sh/ruff/rules/#pyupgrade-up
 ]
 lint.extend-select = [
-    # "A",     # See: https://pypi.org/project/flake8-builtins
-    "B",       # See: https://pypi.org/project/flake8-bugbear
-    "C4",      # See: https://pypi.org/project/flake8-comprehensions
-    "TCH004",  # See: https://docs.astral.sh/ruff/rules/runtime-import-in-type-checking-block/
+    # "A",    # See: https://pypi.org/project/flake8-builtins
+    "B",      # See: https://pypi.org/project/flake8-bugbear
+    "C4",     # See: https://pypi.org/project/flake8-comprehensions
+    "TC004",  # See: https://docs.astral.sh/ruff/rules/runtime-import-in-type-checking-block/
 ]
 lint.ignore = [
-    "E203",  # Whitespace before ':'
     "E731",  # Do not assign a `lambda` expression, use a `def`
 ]
-lint.ignore-init-module-imports = true
 lint.unfixable = [
     "F401",  # Module imported but unused
 ]
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 01cb2d040..066b192b8 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,7 +2,7 @@
 -r test-requirements.txt
 
 # For additional local testing/linting - to be added elsewhere eventually.
-ruff
+ruff >=0.8
 shellcheck
 pytest-icdiff
 # pytest-profiling

From a36b8a5a726ee5a17cfd492e49c0b0b2a05b2136 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 8 Jun 2025 16:25:17 -0400
Subject: [PATCH 52/72] Always use a `def` instead of assigning a `lambda`

This stops listing Ruff rule `E731` ("Do not assign a `lambda`
expression, use a `def`") as ignored, and fixes all occurrences of
it:

- Spacing is manually adjusted so that readability is not harmed,
  while still satisfying the current formatting conventions.

- Although the affected test modules do not currently use type
  annotations, the non-test modules do. Some of the lambdas already
  had type annotations, by annotating the variable itself with an
  expression formed by subscripting `Callable`. This change
  preserves them, converting them to paramter and return type
  annotations in the resulting `def`. Where such type annotations
  were absent (in lambdas in non-test modules), or partly absent,
  all missing annotations are added to the `def`.

- Unused paramters are prefixed with a `_`.

- `IndexFile.checkout` assigned a lambda to `make_exc`, whose body
  was somewhat difficult to read. Separately from converting it to
  a `def`, this refactors the expression in the `return` statement
  to use code like `(x, *ys)` in place of `(x,) + tuple(ys)`.

This change does not appear to have introduced (nor fixed) any
`mypy` errors.

This only affects lambdas that were assigned directly to variables.
Other lambda expressions remain unchanged.
---
 git/index/base.py   | 19 +++++++++++++++----
 git/objects/tree.py |  4 +++-
 pyproject.toml      |  2 +-
 test/test_fun.py    |  6 +++++-
 test/test_index.py  |  5 ++++-
 test/test_tree.py   | 10 ++++++++--
 6 files changed, 36 insertions(+), 10 deletions(-)

diff --git a/git/index/base.py b/git/index/base.py
index a95762dca..7cc9d3ade 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -530,7 +530,10 @@ def unmerged_blobs(self) -> Dict[PathLike, List[Tuple[StageType, Blob]]]:
             stage. That is, a file removed on the 'other' branch whose entries are at
             stage 3 will not have a stage 3 entry.
         """
-        is_unmerged_blob = lambda t: t[0] != 0
+
+        def is_unmerged_blob(t: Tuple[StageType, Blob]) -> bool:
+            return t[0] != 0
+
         path_map: Dict[PathLike, List[Tuple[StageType, Blob]]] = {}
         for stage, blob in self.iter_blobs(is_unmerged_blob):
             path_map.setdefault(blob.path, []).append((stage, blob))
@@ -690,12 +693,17 @@ def _store_path(self, filepath: PathLike, fprogress: Callable) -> BaseIndexEntry
             This must be ensured in the calling code.
         """
         st = os.lstat(filepath)  # Handles non-symlinks as well.
+
         if S_ISLNK(st.st_mode):
             # In PY3, readlink is a string, but we need bytes.
             # In PY2, it was just OS encoded bytes, we assumed UTF-8.
-            open_stream: Callable[[], BinaryIO] = lambda: BytesIO(force_bytes(os.readlink(filepath), encoding=defenc))
+            def open_stream() -> BinaryIO:
+                return BytesIO(force_bytes(os.readlink(filepath), encoding=defenc))
         else:
-            open_stream = lambda: open(filepath, "rb")
+
+            def open_stream() -> BinaryIO:
+                return open(filepath, "rb")
+
         with open_stream() as stream:
             fprogress(filepath, False, filepath)
             istream = self.repo.odb.store(IStream(Blob.type, st.st_size, stream))
@@ -1336,8 +1344,11 @@ def handle_stderr(proc: "Popen[bytes]", iter_checked_out_files: Iterable[PathLik
             kwargs["as_process"] = True
             kwargs["istream"] = subprocess.PIPE
             proc = self.repo.git.checkout_index(args, **kwargs)
+
             # FIXME: Reading from GIL!
-            make_exc = lambda: GitCommandError(("git-checkout-index",) + tuple(args), 128, proc.stderr.read())
+            def make_exc() -> GitCommandError:
+                return GitCommandError(("git-checkout-index", *args), 128, proc.stderr.read())
+
             checked_out_files: List[PathLike] = []
 
             for path in paths:
diff --git a/git/objects/tree.py b/git/objects/tree.py
index 09184a781..1845d0d0d 100644
--- a/git/objects/tree.py
+++ b/git/objects/tree.py
@@ -50,7 +50,9 @@
 
 # --------------------------------------------------------
 
-cmp: Callable[[str, str], int] = lambda a, b: (a > b) - (a < b)
+
+def cmp(a: str, b: str) -> int:
+    return (a > b) - (a < b)
 
 
 class TreeModifier:
diff --git a/pyproject.toml b/pyproject.toml
index 0097e9951..58ed81f17 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,7 +66,7 @@ lint.extend-select = [
     "TC004",  # See: https://docs.astral.sh/ruff/rules/runtime-import-in-type-checking-block/
 ]
 lint.ignore = [
-    "E731",  # Do not assign a `lambda` expression, use a `def`
+    # If it becomes necessary to ignore any rules, list them here.
 ]
 lint.unfixable = [
     "F401",  # Module imported but unused
diff --git a/test/test_fun.py b/test/test_fun.py
index b8593b400..a456b8aab 100644
--- a/test/test_fun.py
+++ b/test/test_fun.py
@@ -243,6 +243,7 @@ def test_tree_traversal(self):
         B_old = self.rorepo.tree("1f66cfbbce58b4b552b041707a12d437cc5f400a")  # old base tree
 
         # Two very different trees.
+
         entries = traverse_trees_recursive(odb, [B_old.binsha, H.binsha], "")
         self._assert_tree_entries(entries, 2)
 
@@ -251,7 +252,10 @@ def test_tree_traversal(self):
         self._assert_tree_entries(oentries, 2)
 
         # Single tree.
-        is_no_tree = lambda i, d: i.type != "tree"
+
+        def is_no_tree(i, _d):
+            return i.type != "tree"
+
         entries = traverse_trees_recursive(odb, [B.binsha], "")
         assert len(entries) == len(list(B.traverse(predicate=is_no_tree)))
         self._assert_tree_entries(entries, 1)
diff --git a/test/test_index.py b/test/test_index.py
index c42032e70..cf3b90fa6 100644
--- a/test/test_index.py
+++ b/test/test_index.py
@@ -330,7 +330,10 @@ def test_index_file_from_tree(self, rw_repo):
         assert len([e for e in three_way_index.entries.values() if e.stage != 0])
 
         # ITERATE BLOBS
-        merge_required = lambda t: t[0] != 0
+
+        def merge_required(t):
+            return t[0] != 0
+
         merge_blobs = list(three_way_index.iter_blobs(merge_required))
         assert merge_blobs
         assert merge_blobs[0][0] in (1, 2, 3)
diff --git a/test/test_tree.py b/test/test_tree.py
index 73158113d..7ba93bd36 100644
--- a/test/test_tree.py
+++ b/test/test_tree.py
@@ -126,12 +126,18 @@ def test_traverse(self):
         assert len(list(root)) == len(list(root.traverse(depth=1)))
 
         # Only choose trees.
-        trees_only = lambda i, d: i.type == "tree"
+
+        def trees_only(i, _d):
+            return i.type == "tree"
+
         trees = list(root.traverse(predicate=trees_only))
         assert len(trees) == len([i for i in root.traverse() if trees_only(i, 0)])
 
         # Test prune.
-        lib_folder = lambda t, d: t.path == "lib"
+
+        def lib_folder(t, _d):
+            return t.path == "lib"
+
         pruned_trees = list(root.traverse(predicate=trees_only, prune=lib_folder))
         assert len(pruned_trees) < len(trees)
 

From 1d808917168ee292a9e4c5fb575a79133d253bb3 Mon Sep 17 00:00:00 2001
From: David <david0@users.noreply.github.com>
Date: Wed, 11 Jun 2025 14:05:31 +0200
Subject: [PATCH 53/72] fix updating submodules with relative urls

This fixes running repo.update_submodules(init=True) on repositories
that are using relative for the modules.

Fixes #730
---
 git/objects/submodule/base.py |  5 +++++
 test/test_submodule.py        | 16 ++++++++++++++++
 2 files changed, 21 insertions(+)

diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py
index fa60bcdaf..0e55b8fa9 100644
--- a/git/objects/submodule/base.py
+++ b/git/objects/submodule/base.py
@@ -353,6 +353,11 @@ def _clone_repo(
                 os.makedirs(module_abspath_dir)
             module_checkout_path = osp.join(str(repo.working_tree_dir), path)
 
+        if url.startswith("../"):
+            remote_name = repo.active_branch.tracking_branch().remote_name
+            repo_remote_url = repo.remote(remote_name).url
+            url = os.path.join(repo_remote_url, url)
+
         clone = git.Repo.clone_from(
             url,
             module_checkout_path,
diff --git a/test/test_submodule.py b/test/test_submodule.py
index d88f9dab0..f44f086c2 100644
--- a/test/test_submodule.py
+++ b/test/test_submodule.py
@@ -753,6 +753,22 @@ def test_add_empty_repo(self, rwdir):
             )
         # END for each checkout mode
 
+    @with_rw_directory
+    @_patch_git_config("protocol.file.allow", "always")
+    def test_update_submodule_with_relative_path(self, rwdir):
+        repo_path = osp.join(rwdir, "parent")
+        repo = git.Repo.init(repo_path)
+        module_repo_path = osp.join(rwdir, "module")
+        module_repo = git.Repo.init(module_repo_path)
+        module_repo.git.commit(m="test", allow_empty=True)
+        repo.git.submodule("add", "../module", "module")
+        repo.index.commit("add submodule")
+
+        cloned_repo_path = osp.join(rwdir, "cloned_repo")
+        cloned_repo = git.Repo.clone_from(repo_path, cloned_repo_path)
+
+        cloned_repo.submodule_update(init=True, recursive=True)
+
     @with_rw_directory
     @_patch_git_config("protocol.file.allow", "always")
     def test_list_only_valid_submodules(self, rwdir):

From 088d090ed3016aca68f3376736bf9f2b18d0fe7e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 12 Jun 2025 02:39:11 -0400
Subject: [PATCH 54/72] Run `cat_file.py` fixture without site customizations

This fixes a new `TestGit::test_handle_process_output` test failure
on Cygwin where a `CoverageWarning` was printed to stderr in the
Python interpreter subprocess running the `cat_file.py` fixture.

We usually run the test suite with `pytest-cov` enabled. This is
configured in `pyproject.toml` to happen by default. `pytest-cov`
uses the `coverage` module, but it adds some more functionality.
This includes instrumenting subprocesses, which is achieved by
installing its `pytest-cov.pth` file into `site-packages` to be run
by all Python interpreter instances. This causes interpeters to
check for environment variables such as `COV_CORE_SOURCE` and
to conditionally initialize `pytest_cov`. For details, see:
https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html

`coverage` 7.9.0 was recently released. One of the changes is to
start issuing a warning if it can't import the C tracer core. See:
https://github.com/nedbat/coveragepy/releases/tag/7.9.0

If this warning is issued in the `cat_file.py` subprocess used in
`test_handle_process_output`, it causes the test to fail, because
the subprocess writes two more lines to its standard error stream,
which cause the line count to come out as two more than expected:

    /cygdrive/d/a/GitPython/GitPython/.venv/lib/python3.9/site-packages/coverage/core.py:96: CoverageWarning: Couldn't import C tracer: No module named 'coverage.tracer' (no-ctracer)
    warn(f"Couldn't import C tracer: {IMPORT_ERROR}", slug="no-ctracer", once=True)

On most platforms, there is no failure, because the condition the
warnings describe does not occur, so there are no warnings. But on
Cygwin it does occur, resulting in a new test failure, showing

    >       self.assertEqual(len(actual_lines[2]), expected_line_count, repr(actual_lines[2]))
    E       AssertionError: 5004 != 5002 : ["/cygdrive/d/a/GitPython/GitPython/.venv/lib/python3.9/site-packages/coverage/core.py:96: CoverageWarning: Couldn't import C tracer: No module named 'coverage.tracer' (no-ctracer)\n", '  warn(f"Couldn\'t import C tracer: {IMPORT_ERROR}", slug="no-ctracer", once=True)\n', 'From github.com:jantman/gitpython_issue_301\n', ' = [up to date]      master     -> origin/master\n', ' = [up to date]      testcommit1 -> origin/testcommit1\n', ' = [up to date]      testcommit10 -> origin/testcommit10\n', ...

where the first two elements of the list are from the lines of the
warning message, and the others are as expected. (The above is a
highly abridged extract, with the `...` at the end standing for
many more list items obtained through the `cat_file.py` fixture.)

This new failure is triggered specifically by the new `coverage`
package version. It is not due to any recent changes in GitPython.
It can be observed by rerunning CI checks that have previously
passed, or in:
https://github.com/EliahKagan/GitPython/actions/runs/15598239952/job/43940156308#step:14:355

There is more than one possible way to fix this, including fixing
the underlying condition being warned about on Cygwin, or
sanitizing environment variables for the subprocess.

The approach taken here instead is based on the idea that the
`cat_file.py` fixture is very simple, and that it is conceptually
just a standalone Python script that doesn't do anything meant to
depend on the current Python environment.

Accordingly, this passes the `-S` option to the interpreter for the
`cat_file.py` subprocess, so that interpreter refrains from loading
the `site` module. This includes, among other simplifying effects,
that the subprocess performs no `.pth` customizations.
---
 test/test_git.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test/test_git.py b/test/test_git.py
index 5bcf89bdd..4a54d0d9b 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -773,6 +773,7 @@ def stderr_handler(line):
 
         cmdline = [
             sys.executable,
+            "-S",  # Keep any `CoverageWarning` messages out of the subprocess stderr.
             fixture_path("cat_file.py"),
             str(fixture_path("issue-301_stderr")),
         ]

From 66955cc76a9b33716baa6bdcc575d0446cf9175e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 14 Jun 2025 14:42:31 -0400
Subject: [PATCH 55/72] Minor CI clarifications

- In the Cygwin CI workflow, move `runs-on` below `strategy`, for
  greater consistency with other workflows.

- In the Cygwin CI jobs, use `pwsh` rather than `bash` for the
  `git config` command run outside of Cygwin, since `pwsh` is the
  default shell for such commands, it's the shell the Cygwin setup
  action uses, and it avoids creating the wrong impression that
  `bash` is needed.

- Use "virtual environment" instead of "virtualenv" in some step
  names to avoid possible confusion with the `virtualenv` pacakge.

- Remove comments in the PyPA package upgrade steps, which are
  more self-documenting since 727f4e9 (#2043).
---
 .github/workflows/alpine-test.yml   | 3 +--
 .github/workflows/cygwin-test.yml   | 9 ++++-----
 .github/workflows/pythonpackage.yml | 1 -
 3 files changed, 5 insertions(+), 8 deletions(-)

diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml
index a3361798d..ceba11fb8 100644
--- a/.github/workflows/alpine-test.yml
+++ b/.github/workflows/alpine-test.yml
@@ -47,13 +47,12 @@ jobs:
         # and cause subsequent tests to fail
         cat test/fixtures/.gitconfig >> ~/.gitconfig
 
-    - name: Set up virtualenv
+    - name: Set up virtual environment
       run: |
         python -m venv .venv
 
     - name: Update PyPA packages
       run: |
-        # Get the latest pip, wheel, and prior to Python 3.12, setuptools.
         . .venv/bin/activate
         python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel
 
diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 6943db09c..28a41a362 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -7,8 +7,6 @@ permissions:
 
 jobs:
   test:
-    runs-on: windows-latest
-
     strategy:
       matrix:
         selection: [fast, perf]
@@ -20,6 +18,8 @@ jobs:
 
       fail-fast: false
 
+    runs-on: windows-latest
+
     env:
       CHERE_INVOKING: "1"
       CYGWIN_NOWINPATH: "1"
@@ -32,7 +32,7 @@ jobs:
     - name: Force LF line endings
       run: |
         git config --global core.autocrlf false  # Affects the non-Cygwin git.
-      shell: bash  # Use Git Bash instead of Cygwin Bash for this step.
+      shell: pwsh  # Do this outside Cygwin, to affect actions/checkout.
 
     - uses: actions/checkout@v4
       with:
@@ -67,7 +67,7 @@ jobs:
         # and cause subsequent tests to fail
         cat test/fixtures/.gitconfig >> ~/.gitconfig
 
-    - name: Set up virtualenv
+    - name: Set up virtual environment
       run: |
         python3.9 -m venv --without-pip .venv
         echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV"
@@ -78,7 +78,6 @@ jobs:
 
     - name: Update PyPA packages
       run: |
-        # Get the latest pip, wheel, and prior to Python 3.12, setuptools.
         python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel
 
     - name: Install project and test dependencies
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index c56d45df7..f3d977760 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -71,7 +71,6 @@ jobs:
 
     - name: Update PyPA packages
       run: |
-        # Get the latest pip, wheel, and prior to Python 3.12, setuptools.
         python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel
 
     - name: Install project and test dependencies

From 8e24edfb4688180287c970b023516c2b71946778 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 14 Jun 2025 14:57:40 -0400
Subject: [PATCH 56/72] Use *-wheel packages as a better fix for #2004

This installs the `python-pip-wheel`, `python-setuptools-wheel`,
and `python-wheel-wheel` packages on Cygwini CI, which provide
`.whl` files for `pip`, `setuptools`, and `wheel`.

By making those wheels available, this fixes #2004 better than the
previous workaround, allowing `ensurepip` to run without the error:

    Traceback (most recent call last):
      File "/usr/lib/python3.9/runpy.py", line 188, in _run_module_as_main
        mod_name, mod_spec, code = _get_module_details(mod_name, _Error)
      File "/usr/lib/python3.9/runpy.py", line 147, in _get_module_details
        return _get_module_details(pkg_main_name, error)
      File "/usr/lib/python3.9/runpy.py", line 111, in _get_module_details
        __import__(pkg_name)
      File "/usr/lib/python3.9/ensurepip/__init__.py", line [30](https://github.com/EliahKagan/GitPython/actions/runs/13454947366/job/37596811693#step:10:31), in <module>
        _SETUPTOOLS_VERSION = _get_most_recent_wheel_version("setuptools")
      File "/usr/lib/python3.9/ensurepip/__init__.py", line 27, in _get_most_recent_wheel_version
        return str(max(_wheels[pkg], key=distutils.version.LooseVersion))
    ValueError: max() arg is an empty sequence

This change takes the place of the main changes in #2007 and #2009.
In particular, it should allow `test_installation` to pass again.

This also delists non-wheel Cygwin packages such as `python39-pip`,
which are not needed (or at least no longer needed).

(The python-{pip,setuptools,wheel}-wheel packages are, as their
names suggest, intentionally not specific to Python 3.9. However,
this technique will not necessarily carry over to Python 3.12,
depending on what versions are supplied and other factors. This may
be relevant when another attempt like #1988 is made to test/support
Python 3.12 on Cygwin. At least for now, though, this seems
worthwhile for fixing the Cygwin 3.9 environment, making it more
similar to working local Cygwin environments and letting the
workflow be more usable as guidance to how to set up a local Cygwin
environment for GitPython development, and letting the installation
test pass automatically.)
---
 .github/workflows/cygwin-test.yml | 8 ++------
 test/test_installation.py         | 8 --------
 2 files changed, 2 insertions(+), 14 deletions(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 28a41a362..2d0378490 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -41,7 +41,7 @@ jobs:
     - name: Install Cygwin
       uses: cygwin/cygwin-install-action@v5
       with:
-        packages: python39 python39-pip python39-virtualenv git wget
+        packages: git python39 python-pip-wheel python-setuptools-wheel python-wheel-wheel
         add-to-path: false  # No need to change $PATH outside the Cygwin environment.
 
     - name: Arrange for verbose output
@@ -69,13 +69,9 @@ jobs:
 
     - name: Set up virtual environment
       run: |
-        python3.9 -m venv --without-pip .venv
+        python3.9 -m venv .venv
         echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV"
 
-    - name: Bootstrap pip in virtualenv
-      run: |
-        wget -qO- https://bootstrap.pypa.io/get-pip.py | python
-
     - name: Update PyPA packages
       run: |
         python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel
diff --git a/test/test_installation.py b/test/test_installation.py
index a35826bd0..ae6472e98 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -4,19 +4,11 @@
 import ast
 import os
 import subprocess
-import sys
-
-import pytest
 
 from test.lib import TestBase, VirtualEnvironment, with_rw_directory
 
 
 class TestInstallation(TestBase):
-    @pytest.mark.xfail(
-        sys.platform == "cygwin" and "CI" in os.environ,
-        reason="Trouble with pip on Cygwin CI, see issue #2004",
-        raises=subprocess.CalledProcessError,
-    )
     @with_rw_directory
     def test_installation(self, rw_dir):
         venv = self._set_up_venv(rw_dir)

From 953d1616e7385ec6aac1e7a6212c689874c9409c Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 15 Jun 2025 19:04:15 -0400
Subject: [PATCH 57/72] Comment what `TestInstallation._set_up_venv` does

So that the meaning of the `venv.sources` accesses in
`test_installation` is more readily clear.
---
 test/test_installation.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/test/test_installation.py b/test/test_installation.py
index ae6472e98..8231b3512 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -64,10 +64,14 @@ def test_installation(self, rw_dir):
 
     @staticmethod
     def _set_up_venv(rw_dir):
+        # Initialize the virtual environment.
         venv = VirtualEnvironment(rw_dir, with_pip=True)
+
+        # Make its src directory a symlink to our own top-level source tree.
         os.symlink(
             os.path.dirname(os.path.dirname(__file__)),
             venv.sources,
             target_is_directory=True,
         )
+
         return venv

From 84632c78512e070a7aaa9ff1dd046c77293a58a4 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 15 Jun 2025 19:06:58 -0400
Subject: [PATCH 58/72] Extract `subprocess.run` logic repeated in
 `test_installation`

This creates a function (technically, a callable `partial` object)
for `test_installation` to use instead of repeating `subproces.run`
keyword arguments all the time.

This relates directly to steps in `_set_up_venv`, and it's makes
about as much sense to do it there as in `test_installation`, so it
is placed (and described) in `_set_up_venv`.
---
 test/test_installation.py | 36 ++++++++++++++----------------------
 1 file changed, 14 insertions(+), 22 deletions(-)

diff --git a/test/test_installation.py b/test/test_installation.py
index 8231b3512..b428f413a 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -2,6 +2,7 @@
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
 import ast
+import functools
 import os
 import subprocess
 
@@ -11,35 +12,23 @@
 class TestInstallation(TestBase):
     @with_rw_directory
     def test_installation(self, rw_dir):
-        venv = self._set_up_venv(rw_dir)
+        venv, run = self._set_up_venv(rw_dir)
 
-        result = subprocess.run(
-            [venv.pip, "install", "."],
-            stdout=subprocess.PIPE,
-            cwd=venv.sources,
-        )
+        result = run([venv.pip, "install", "."])
         self.assertEqual(
             0,
             result.returncode,
             msg=result.stderr or result.stdout or "Can't install project",
         )
 
-        result = subprocess.run(
-            [venv.python, "-c", "import git"],
-            stdout=subprocess.PIPE,
-            cwd=venv.sources,
-        )
+        result = run([venv.python, "-c", "import git"])
         self.assertEqual(
             0,
             result.returncode,
             msg=result.stderr or result.stdout or "Self-test failed",
         )
 
-        result = subprocess.run(
-            [venv.python, "-c", "import gitdb; import smmap"],
-            stdout=subprocess.PIPE,
-            cwd=venv.sources,
-        )
+        result = run([venv.python, "-c", "import gitdb; import smmap"])
         self.assertEqual(
             0,
             result.returncode,
@@ -49,11 +38,7 @@ def test_installation(self, rw_dir):
         # Even IF gitdb or any other dependency is supplied during development by
         # inserting its location into PYTHONPATH or otherwise patched into sys.path,
         # make sure it is not wrongly inserted as the *first* entry.
-        result = subprocess.run(
-            [venv.python, "-c", "import sys; import git; print(sys.path)"],
-            stdout=subprocess.PIPE,
-            cwd=venv.sources,
-        )
+        result = run([venv.python, "-c", "import sys; import git; print(sys.path)"])
         syspath = result.stdout.decode("utf-8").splitlines()[0]
         syspath = ast.literal_eval(syspath)
         self.assertEqual(
@@ -74,4 +59,11 @@ def _set_up_venv(rw_dir):
             target_is_directory=True,
         )
 
-        return venv
+        # Create a convenience function to run commands in it.
+        run = functools.partial(
+            subprocess.run,
+            stdout=subprocess.PIPE,
+            cwd=venv.sources,
+        )
+
+        return venv, run

From a2ba4804a6544642b400dced6eac0f4f1ad28750 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 15 Jun 2025 19:11:16 -0400
Subject: [PATCH 59/72] Have `test_installation` test that operations produce
 no warnings

By setting the `PYTHONWARNINGS` environment variable to `error` in
each of the subprocess invocations.

This is strictly stronger than passing `-Werror` for the `python`
commands, because it automatically applies to subprocesses (unless
they are created with a sanitized environment or otherwise with one
in which `PYTHONWARNINGS` has been customized), and because it
works for `pip` automatically.

Importantly, this will cause warnings internal to Python
subprocesses created by `pip` to be treated as errors. It should
thus surface any warnings coming from the `setuptools` backend.
---
 test/test_installation.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test/test_installation.py b/test/test_installation.py
index b428f413a..5ba8a82b0 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -64,6 +64,7 @@ def _set_up_venv(rw_dir):
             subprocess.run,
             stdout=subprocess.PIPE,
             cwd=venv.sources,
+            env={**os.environ, "PYTHONWARNINGS": "error"},
         )
 
         return venv, run

From a0e08fe90135149de203a3adfb21a5a254cb1bdb Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 15 Jun 2025 19:48:39 -0400
Subject: [PATCH 60/72] Show more information when `test_installation` fails

Previously, it attempted to show stderr unless empty, first falling
back to stdout unless empty, then falling back to the prewritten
summary identifying the specific assertion.

This now has the `test_installation` assertions capture stderr as
well as stdout, handle standard streams as text rather than binary,
and show more information when failing, always distinguishing where
the information came from: the summary, then labeled captured
stdout (empty or not), then labeled captured stderr (empty or not).

That applies to all but the last assertion, which does not try to
show information differently when it fails, but is simplified to do
the right thing now that `subprocess.run` is using text streams.
(This subtly changes its semantics, but overall it should be as
effective as before at finding the `sys.path` woe it anticipates.)
---
 test/test_installation.py | 29 +++++++++++++----------------
 1 file changed, 13 insertions(+), 16 deletions(-)

diff --git a/test/test_installation.py b/test/test_installation.py
index 5ba8a82b0..39797c134 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -15,31 +15,19 @@ def test_installation(self, rw_dir):
         venv, run = self._set_up_venv(rw_dir)
 
         result = run([venv.pip, "install", "."])
-        self.assertEqual(
-            0,
-            result.returncode,
-            msg=result.stderr or result.stdout or "Can't install project",
-        )
+        self._check_result(result, "Can't install project")
 
         result = run([venv.python, "-c", "import git"])
-        self.assertEqual(
-            0,
-            result.returncode,
-            msg=result.stderr or result.stdout or "Self-test failed",
-        )
+        self._check_result(result, "Self-test failed")
 
         result = run([venv.python, "-c", "import gitdb; import smmap"])
-        self.assertEqual(
-            0,
-            result.returncode,
-            msg=result.stderr or result.stdout or "Dependencies not installed",
-        )
+        self._check_result(result, "Dependencies not installed")
 
         # Even IF gitdb or any other dependency is supplied during development by
         # inserting its location into PYTHONPATH or otherwise patched into sys.path,
         # make sure it is not wrongly inserted as the *first* entry.
         result = run([venv.python, "-c", "import sys; import git; print(sys.path)"])
-        syspath = result.stdout.decode("utf-8").splitlines()[0]
+        syspath = result.stdout.splitlines()[0]
         syspath = ast.literal_eval(syspath)
         self.assertEqual(
             "",
@@ -63,8 +51,17 @@ def _set_up_venv(rw_dir):
         run = functools.partial(
             subprocess.run,
             stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            universal_newlines=True,
             cwd=venv.sources,
             env={**os.environ, "PYTHONWARNINGS": "error"},
         )
 
         return venv, run
+
+    def _check_result(self, result, failure_summary):
+        self.assertEqual(
+            0,
+            result.returncode,
+            f"{failure_summary}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}",
+        )

From 6826b594e2ef43b0972f29f335e7be51c9574303 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 15 Jun 2025 20:05:56 -0400
Subject: [PATCH 61/72] Improve failure message whitespace clarity

---
 test/test_installation.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/test/test_installation.py b/test/test_installation.py
index 39797c134..da0b86ed2 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -63,5 +63,11 @@ def _check_result(self, result, failure_summary):
         self.assertEqual(
             0,
             result.returncode,
-            f"{failure_summary}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}",
+            self._prepare_failure_message(result, failure_summary),
         )
+
+    @staticmethod
+    def _prepare_failure_message(result, failure_summary):
+        stdout = result.stdout.rstrip()
+        stderr = result.stderr.rstrip()
+        return f"{failure_summary}\n\nstdout:\n{stdout}\n\nstderr:\n{stderr}"

From d0868bd5d6a43c3d95a3eaddab1389a0ef76d8f0 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 15 Jun 2025 20:25:55 -0400
Subject: [PATCH 62/72] Remove deprecated license classifier

GitPython project metadata specify the project's license primarily
through the value of `license`, currently passed as an argument to
`setuptools.setup`, which holds the string `"BSD-3-Clause"`. This
is an SPDX license identifier readily understood by both humans and
machines.

The PyPI trove classifier "License :: OSI Approved :: BSD License"
has also been specified in `setup.py`. However, this is not ideal,
because:

1. It does not identify a specific license. There are multiple
   "BSD" licenses in use, with BSD-2-Clause and BSD-3-Clause both
   in very wide use.

2. It is no longer recommended to use a trove classifier to
   indicate a license. The use of license classifiers (even
   unambiguous ones) has been deprecated. See:

   - https://packaging.python.org/en/latest/specifications/core-metadata/#classifier-multiple-use
   - https://peps.python.org/pep-0639/#deprecate-license-classifiers

This commit removes the classifier. The license itself is of course
unchanged, as is the `license` value of `"BSD-3-Clause"`.

(An expected effect of this change is that, starting in the next
release of GitPython, PyPI may show "License: BSD-3-Clause" instead
of the current text "License: BSD License (BSD-3-Clause)".)

This change fixes a warning issued by a subprocess of `pip` when
installing the package. The warning, until this change, could be
observed by running `pip install . -v` or `pip install -e . -v` and
examining the verbose output, or by running `pip install .` or
`pip install -e .` with the `PYTHONWARNINGS` environment variable
set to `error`:

    SetuptoolsDeprecationWarning: License classifiers are deprecated.
      !!

              ********************************************************************************
              Please consider removing the following classifiers in favor of a SPDX license expression:

              License :: OSI Approved :: BSD License

              See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license for details.
              ********************************************************************************

      !!

(In preceding commits, `test_installation` has been augmented to
set that environment variable, surfacing the error. This change
should allow that test to pass, unless it finds other problems.)
---
 setup.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/setup.py b/setup.py
index f28fedb85..a7b1eab00 100755
--- a/setup.py
+++ b/setup.py
@@ -95,7 +95,6 @@ def _stamp_version(filename: str) -> None:
         # "Development Status :: 7 - Inactive",
         "Environment :: Console",
         "Intended Audience :: Developers",
-        "License :: OSI Approved :: BSD License",
         "Operating System :: OS Independent",
         "Operating System :: POSIX",
         "Operating System :: Microsoft :: Windows",

From b21c32a302dcd91d52636e5f91e89ae129654bd2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 15 Jun 2025 23:37:27 -0400
Subject: [PATCH 63/72] Pass assertion `msg` as a keyword argument in
 `test_installation`

It was originally made explicit like this, but it ended up becoming
position in my last few commits. This restores that clearer aspect
of how it was written before, while keeping all the other changes.
---
 test/test_installation.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/test_installation.py b/test/test_installation.py
index da0b86ed2..7c82bd403 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -63,7 +63,7 @@ def _check_result(self, result, failure_summary):
         self.assertEqual(
             0,
             result.returncode,
-            self._prepare_failure_message(result, failure_summary),
+            msg=self._prepare_failure_message(result, failure_summary),
         )
 
     @staticmethod

From ac3437df0c5b5ad597915db15276dcb326d3d970 Mon Sep 17 00:00:00 2001
From: Tom Bedor <tombedor@gmail.com>
Date: Tue, 17 Jun 2025 09:03:52 -0700
Subject: [PATCH 64/72] Add clearer error version for unsupported index error

---
 git/index/fun.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/index/fun.py b/git/index/fun.py
index 59cce6ae6..d03ec6759 100644
--- a/git/index/fun.py
+++ b/git/index/fun.py
@@ -207,7 +207,7 @@ def read_header(stream: IO[bytes]) -> Tuple[int, int]:
     version, num_entries = unpacked
 
     # TODO: Handle version 3: extended data, see read-cache.c.
-    assert version in (1, 2)
+    assert version in (1, 2), "Unsupported git index version %i, only 1 and 2 are supported" % version
     return version, num_entries
 
 

From bb5c22629aba914a00b8f33a10522324ca7bb049 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 7 Jul 2025 15:20:34 +0000
Subject: [PATCH 65/72] Bump Vampire/setup-wsl from 5.0.1 to 6.0.0

Bumps [Vampire/setup-wsl](https://github.com/vampire/setup-wsl) from 5.0.1 to 6.0.0.
- [Release notes](https://github.com/vampire/setup-wsl/releases)
- [Commits](https://github.com/vampire/setup-wsl/compare/v5.0.1...v6.0.0)

---
updated-dependencies:
- dependency-name: Vampire/setup-wsl
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 .github/workflows/pythonpackage.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index f3d977760..4457a341f 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -51,7 +51,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: matrix.os-type == 'windows'
-      uses: Vampire/setup-wsl@v5.0.1
+      uses: Vampire/setup-wsl@v6.0.0
       with:
         wsl-version: 1
         distribution: Alpine

From 496392b9bf781904421cbd171c0c5395a6fe330c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 7 Jul 2025 15:21:27 +0000
Subject: [PATCH 66/72] Bump cygwin/cygwin-install-action from 5 to 6

Bumps [cygwin/cygwin-install-action](https://github.com/cygwin/cygwin-install-action) from 5 to 6.
- [Release notes](https://github.com/cygwin/cygwin-install-action/releases)
- [Commits](https://github.com/cygwin/cygwin-install-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: cygwin/cygwin-install-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 .github/workflows/cygwin-test.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 2d0378490..cc9e1edf0 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -39,7 +39,7 @@ jobs:
         fetch-depth: 0
 
     - name: Install Cygwin
-      uses: cygwin/cygwin-install-action@v5
+      uses: cygwin/cygwin-install-action@v6
       with:
         packages: git python39 python-pip-wheel python-setuptools-wheel python-wheel-wheel
         add-to-path: false  # No need to change $PATH outside the Cygwin environment.

From a4aadb0c04bd13af824c14dcc39f88345aa5c440 Mon Sep 17 00:00:00 2001
From: Niklas Mertsch <niklasmertsch@web.de>
Date: Sun, 20 Jul 2025 14:17:49 +0200
Subject: [PATCH 67/72] Fix name collision

---
 git/config.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/git/config.py b/git/config.py
index 5fc099a27..345290a39 100644
--- a/git/config.py
+++ b/git/config.py
@@ -87,15 +87,15 @@ def __new__(cls, name: str, bases: Tuple, clsdict: Dict[str, Any]) -> "MetaParse
             mutating_methods = clsdict[kmm]
             for base in bases:
                 methods = (t for t in inspect.getmembers(base, inspect.isroutine) if not t[0].startswith("_"))
-                for name, method in methods:
-                    if name in clsdict:
+                for method_name, method in methods:
+                    if method_name in clsdict:
                         continue
                     method_with_values = needs_values(method)
-                    if name in mutating_methods:
+                    if method_name in mutating_methods:
                         method_with_values = set_dirty_and_flush_changes(method_with_values)
                     # END mutating methods handling
 
-                    clsdict[name] = method_with_values
+                    clsdict[method_name] = method_with_values
                 # END for each name/method pair
             # END for each base
         # END if mutating methods configuration is set

From 80fd2c16211738156e65258381a17cdc429ddd08 Mon Sep 17 00:00:00 2001
From: Niklas Mertsch <niklasmertsch@web.de>
Date: Sun, 20 Jul 2025 14:17:58 +0200
Subject: [PATCH 68/72] Don't treat sphinx warnings as errors

Workaround for python/cpython#100520 (rst syntax error in configparser docstrings), which was fixed in CPython 3.10+.
Docutils raises warnings about the invalid docstrings, and `-W` instructs sphinx to treat this as errors. We can't control or silence these warnings, so we accept them and don't treat them as errors.

See the discussion in gitpython-developers/GitPython#2060 for details.
---
 doc/Makefile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/doc/Makefile b/doc/Makefile
index ddeadbd7e..7e0d325fe 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -3,7 +3,7 @@
 
 # You can set these variables from the command line.
 BUILDDIR      = build
-SPHINXOPTS    = -W
+SPHINXOPTS    =
 SPHINXBUILD   = sphinx-build
 PAPER         =
 

From ec2e2c8b894512e7a2364774d77cdd9db73f0566 Mon Sep 17 00:00:00 2001
From: Tom Webber <t.c.w.webber@gmail.com>
Date: Tue, 22 Jul 2025 12:23:18 +0200
Subject: [PATCH 69/72] Allow relative path url in submodules for
 submodule_update

---
 git/objects/submodule/base.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py
index 0e55b8fa9..5031a2e71 100644
--- a/git/objects/submodule/base.py
+++ b/git/objects/submodule/base.py
@@ -11,6 +11,7 @@
 import stat
 import sys
 import uuid
+import urllib
 
 import git
 from git.cmd import Git
@@ -799,9 +800,13 @@ def update(
                     + "Cloning url '%s' to '%s' in submodule %r" % (self.url, checkout_module_abspath, self.name),
                 )
                 if not dry_run:
+                    if self.url.startswith("."):
+                        url = urllib.parse.urljoin(self.repo.remotes.origin.url + "/", self.url)
+                    else:
+                        url = self.url
                     mrepo = self._clone_repo(
                         self.repo,
-                        self.url,
+                        url,
                         self.path,
                         self.name,
                         n=True,

From 1ee1e781929074afd66bff1eae007bbee41d117e Mon Sep 17 00:00:00 2001
From: Tom Webber <t.c.w.webber@gmail.com>
Date: Wed, 23 Jul 2025 07:12:27 +0200
Subject: [PATCH 70/72] Add test case for cloning submodules with relative path

---
 test/test_submodule.py | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/test/test_submodule.py b/test/test_submodule.py
index f44f086c2..4a248eb60 100644
--- a/test/test_submodule.py
+++ b/test/test_submodule.py
@@ -1350,3 +1350,23 @@ def test_submodule_update_unsafe_options_allowed(self, rw_repo):
             for unsafe_option in unsafe_options:
                 with self.assertRaises(GitCommandError):
                     submodule.update(clone_multi_options=[unsafe_option], allow_unsafe_options=True)
+
+    @with_rw_directory
+    @_patch_git_config("protocol.file.allow", "always")
+    def test_submodule_update_relative_url(self, rwdir):
+        parent_path = osp.join(rwdir, "parent")
+        parent_repo = git.Repo.init(parent_path)
+        submodule_path = osp.join(rwdir, "module")
+        submodule_repo = git.Repo.init(submodule_path)
+        submodule_repo.git.commit(m="initial commit", allow_empty=True)
+
+        parent_repo.git.submodule("add", "../module", "module")
+        parent_repo.index.commit("add submodule with relative URL")
+
+        cloned_path = osp.join(rwdir, "cloned_repo")
+        cloned_repo = git.Repo.clone_from(parent_path, cloned_path)
+
+        cloned_repo.submodule_update(init=True, recursive=True)
+
+        has_module = any(sm.name == "module" for sm in cloned_repo.submodules)
+        assert has_module, "Relative submodule was not updated properly"

From 6ba2c0a2f9ee7feffd7e079621c4845820180c9a Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Thu, 24 Jul 2025 05:37:18 +0200
Subject: [PATCH 71/72] Prepare a new release

---
 VERSION                | 2 +-
 doc/source/changes.rst | 6 ++++++
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/VERSION b/VERSION
index e6af1c454..3c91929a4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.1.44
+3.1.45
diff --git a/doc/source/changes.rst b/doc/source/changes.rst
index 00a3c660e..151059ed2 100644
--- a/doc/source/changes.rst
+++ b/doc/source/changes.rst
@@ -2,6 +2,12 @@
 Changelog
 =========
 
+3.1.45
+======
+
+See the following for all changes.
+https://github.com/gitpython-developers/GitPython/releases/tag/3.1.45
+
 3.1.44
 ======
 

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 72/72] 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] <support@github.com>
---
 .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
 
