From 1e5a9449573bfe70987dcaf44e120895131e9c66 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 17 Mar 2024 19:25:34 -0400
Subject: [PATCH 001/332] Add a script to validate refactored imports

This script can be removed after imports are refactored and checked
to see that module contents are the same as before or otherwise
non-broken.

This script assumes that module contents are the same as the
contents of their dictionaries, and that all modules in the project
get imported as a consequence of importing the top-level module.
These are both the case currently for GitPython, but they do not
hold for all projects, and may not hold for GitPython at some point
in the future.
---
 .gitignore  |  4 ++++
 modattrs.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 57 insertions(+)
 create mode 100755 modattrs.py

diff --git a/.gitignore b/.gitignore
index 7765293d8..1be4f3201 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,7 @@ output.txt
 
 # Finder metadata
 .DS_Store
+
+# Output files for modattrs.py (these entries will be removed soon)
+a
+b
diff --git a/modattrs.py b/modattrs.py
new file mode 100755
index 000000000..245f68912
--- /dev/null
+++ b/modattrs.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+
+"""Script to get the names and "stabilized" reprs of module attributes in GitPython.
+
+Run with :envvar:`PYTHONHASHSEED` set to ``0`` for fully comparable results. These are
+only still meaningful for comparing if the same platform and Python version are used.
+
+The output of this script should probably not be committed, because within the reprs of
+objects found in modules, it may contain sensitive information, such as API keys stored
+in environment variables. The "sanitization" performed here is only for common forms of
+whitespace that clash with the output format.
+"""
+
+# fmt: off
+
+__all__ = ["git", "main"]
+
+import itertools
+import re
+import sys
+
+import git
+
+
+def main():
+    # This assumes `import git` causes all of them to be loaded.
+    gitpython_modules = sorted(
+        (module_name, module)
+        for module_name, module in sys.modules.items()
+        if re.match(r"git(?:\.|$)", module_name)
+    )
+
+    # We will print two blank lines between successive module reports.
+    separators = itertools.chain(("",), itertools.repeat("\n\n"))
+
+    # Report each module's contents.
+    for (module_name, module), separator in zip(gitpython_modules, separators):
+        print(f"{separator}{module_name}:")
+
+        attributes = sorted(
+            (name, value)
+            for name, value in module.__dict__.items()
+            if name != "__all__"  # Because we are deliberately adding these.
+        )
+
+        for name, value in attributes:
+            sanitized_repr = re.sub(r"[\r\n\v\f]", "?", repr(value))
+            normalized_repr = re.sub(r" at 0x[0-9a-fA-F]+", " at 0x...", sanitized_repr)
+            print(f"    {name}:  {normalized_repr}")
+
+
+if __name__ == "__main__":
+    main()

From 5b2771d23af2156fb7e12ee6406bffbabcb9e95d Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 10:59:37 -0400
Subject: [PATCH 002/332] Add regression tests of the git.util aliasing
 situation

Although this situation is not inherently desirable, for backward
compatibility it cannot change at this time. It may be possible to
change it in the next major version of GitPython, but even then it
should not be changed accidentally, which can easily happen while
refactoring imports.

This tests the highest-risk accidental change (of those that are
currently known) of the kind that the temporary modattrs.py script
exists to help safeguard against. That script will be removed when
the immediately forthcoming import refactoring is complete, whereas
these test cases can be kept.

For information about the specific situation this helps ensure
isn't changed accidentally, see the new test cases' docstrings, as
well as the next commit (which will test modattrs.py and these test
cases by performing an incomplete change that would be a bug until
completed).

This commit adds three test cases. The first tests the unintuitive
aspect of the current situation:

- test_git_util_attribute_is_git_index_util

The other two test the intuitive aspects of it, i.e., they test
that changes (perhaps in an attempt to preserve the aspect needed
for backward compatibility) do not make `git.util` unusual in new
(and themselves incompatible) ways:

- test_git_index_util_attribute_is_git_index_util
- test_separate_git_util_module_exists

The latter tests should also clarify, for readers of the tests, the
limited nature of the condition the first test asserts.
---
 test/test_imports.py | 32 ++++++++++++++++++++++++++++++++
 1 file changed, 32 insertions(+)
 create mode 100644 test/test_imports.py

diff --git a/test/test_imports.py b/test/test_imports.py
new file mode 100644
index 000000000..8e70c6689
--- /dev/null
+++ b/test/test_imports.py
@@ -0,0 +1,32 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
+import sys
+
+import git
+
+
+def test_git_util_attribute_is_git_index_util():
+    """The top-level module's ``util`` attribute is really :mod:`git.index.util`.
+
+    Although this situation is unintuitive and not a design goal, this has historically
+    been the case, and it should not be changed without considering the effect on
+    backward compatibility. In practice, it cannot be changed at least until the next
+    major version of GitPython. This test checks that it is not accidentally changed,
+    which could happen when refactoring imports.
+    """
+    assert git.util is git.index.util
+
+
+def test_git_index_util_attribute_is_git_index_util():
+    """Nothing unusual is happening with git.index.util itself."""
+    assert git.index.util is sys.modules["git.index.util"]
+
+
+def test_separate_git_util_module_exists():
+    """The real git.util and git.index.util modules really are separate.
+
+    The real git.util module can be accessed to import a name ``...` by writing
+    ``from git.util import ...``, and the module object can be accessed in sys.modules.
+    """
+    assert sys.modules["git.util"] is not sys.modules["git.index.util"]

From fc86a238f99a6406ed949a7d690ec0f6be2f31e0 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 24 Feb 2024 10:52:49 -0500
Subject: [PATCH 003/332] Incompletely change git.index imports to test
 modattrs.py

This also checks if the regression tests in test_imports.py work.

This replaces wildcard imports in git.index with explicit imports,
and adds git.index.__all__. But this temporarily omits from it the
public attributes of git.index that name its Python submodules and
are thus are present as an indirect effect of importing names
*from* them (since doing so also imports them).

This partial change, until it is completed in the next commit,
represents the kind of bug that modattrs.py seeks to safeguard
against, and verifies that a diff between old and current output of
modattrs.py clearly shows it. This diff is temporarily included as
ab.diff, which will be deleted in the next commit.

The key problem that diff reveals is the changed value of the util
attribute of the top-level git module. Due to the way wildcard
imports have been used within GitPython for its own modules,
expressions like `git.util` (where `git` is the top-level git
module) and imports like `from git import util` are actually
referring to git.index.util, rather than the util Python submodule
of the git module (conceptually git.util), which can only be
accessed via `from git.util import ...` or in `sys.modules`.
Although this is not an inherently good situation, in practice it
has to be maintained at least until the next major version, because
reasonable code that uses GitPython would be broken if the
situation were changed to be more intuitive.

But the more intuitive behavior automatically happens if wildcard
imports are replaced with explicit imports of the names they had
originally intended to import (in this case, in the top-level git
module, which has not yet been done but will be), or if __all__ is
added where helpful (in this case, in git.index, which this does).
Adding the names explicitly is sufficient to restore the old
behavior that is needed for backward compatibility, and has the
further advantage that the unintuitive behavior will be explicit
once all wildcard imports are replaced and __all__ is added to each
module where it would be helpful. The modattrs.py script serves to
ensure incompatible changes are not made; this commit checks it.

The tests in test_imports.py also cover this specific anticipated
incompatibility in git.util, but not all breakages that may arise
when refactoring imports and that diff-ing modattrs.py output would
help catch.
---
 ab.diff               | 30 ++++++++++++++++++++++++++++++
 git/index/__init__.py | 13 +++++++++++--
 git/index/base.py     |  5 ++---
 git/index/typ.py      |  5 ++---
 4 files changed, 45 insertions(+), 8 deletions(-)
 create mode 100644 ab.diff

diff --git a/ab.diff b/ab.diff
new file mode 100644
index 000000000..34abf9b16
--- /dev/null
+++ b/ab.diff
@@ -0,0 +1,30 @@
+diff --git a/a b/b
+index 81b3f984..7b8c8ede 100644
+--- a/a
++++ b/b
+@@ -83,14 +83,12 @@ git:
+     __path__:  ['C:\\Users\\ek\\source\\repos\\GitPython\\git']
+     __spec__:  ModuleSpec(name='git', loader=<_frozen_importlib_external.SourceFileLoader object at 0x...>, origin='C:\\Users\\ek\\source\\repos\\GitPython\\git\\__init__.py', submodule_search_locations=['C:\\Users\\ek\\source\\repos\\GitPython\\git'])
+     __version__:  'git'
+-    base:  <module 'git.index.base' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\base.py'>
+     cmd:  <module 'git.cmd' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\cmd.py'>
+     compat:  <module 'git.compat' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\compat.py'>
+     config:  <module 'git.config' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\config.py'>
+     db:  <module 'git.db' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\db.py'>
+     diff:  <module 'git.diff' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\diff.py'>
+     exc:  <module 'git.exc' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\exc.py'>
+-    fun:  <module 'git.index.fun' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\fun.py'>
+     head:  <module 'git.refs.head' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\head.py'>
+     index:  <module 'git.index' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\__init__.py'>
+     log:  <module 'git.refs.log' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\log.py'>
+@@ -106,9 +104,8 @@ git:
+     symbolic:  <module 'git.refs.symbolic' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\symbolic.py'>
+     tag:  <module 'git.refs.tag' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\tag.py'>
+     to_hex_sha:  <function to_hex_sha at 0x...>
+-    typ:  <module 'git.index.typ' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\typ.py'>
+     types:  <module 'git.types' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\types.py'>
+-    util:  <module 'git.index.util' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\util.py'>
++    util:  <module 'git.util' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\util.py'>
+ 
+ 
+ git.cmd:
diff --git a/git/index/__init__.py b/git/index/__init__.py
index c65722cd8..ba48110fd 100644
--- a/git/index/__init__.py
+++ b/git/index/__init__.py
@@ -3,5 +3,14 @@
 
 """Initialize the index package."""
 
-from .base import *  # noqa: F401 F403
-from .typ import *  # noqa: F401 F403
+__all__ = [
+    "BaseIndexEntry",
+    "BlobFilter",
+    "CheckoutError",
+    "IndexEntry",
+    "IndexFile",
+    "StageType",
+]
+
+from .base import CheckoutError, IndexFile
+from .typ import BaseIndexEntry, BlobFilter, IndexEntry, StageType
diff --git a/git/index/base.py b/git/index/base.py
index fb91e092c..38c14252b 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -6,6 +6,8 @@
 """Module containing :class:`IndexFile`, an Index implementation facilitating all kinds
 of index manipulations such as querying and merging."""
 
+__all__ = ("IndexFile", "CheckoutError", "StageType")
+
 import contextlib
 import datetime
 import glob
@@ -81,9 +83,6 @@
 # ------------------------------------------------------------------------------------
 
 
-__all__ = ("IndexFile", "CheckoutError", "StageType")
-
-
 @contextlib.contextmanager
 def _named_temporary_file_for_subprocess(directory: PathLike) -> Generator[str, None, None]:
     """Create a named temporary file git subprocesses can open, deleting it afterward.
diff --git a/git/index/typ.py b/git/index/typ.py
index ffd76dc46..eb0dd4341 100644
--- a/git/index/typ.py
+++ b/git/index/typ.py
@@ -3,13 +3,14 @@
 
 """Additional types used by the index."""
 
+__all__ = ("BlobFilter", "BaseIndexEntry", "IndexEntry", "StageType")
+
 from binascii import b2a_hex
 from pathlib import Path
 
 from .util import pack, unpack
 from git.objects import Blob
 
-
 # typing ----------------------------------------------------------------------
 
 from typing import NamedTuple, Sequence, TYPE_CHECKING, Tuple, Union, cast
@@ -23,8 +24,6 @@
 
 # ---------------------------------------------------------------------------------
 
-__all__ = ("BlobFilter", "BaseIndexEntry", "IndexEntry", "StageType")
-
 # { Invariants
 CE_NAMEMASK = 0x0FFF
 CE_STAGEMASK = 0x3000

From 4badc19f489c177a976d99da85d3c632797f7aeb Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 12:16:33 -0400
Subject: [PATCH 004/332] Fix git.index imports

- Add the base, fun, typ, and util Python submodules of git.index
  to git.index.__all__ to restore the old behavior.

- Import them explicitly for clarity, even though they are bound to
  the same-named attributes of git.index either way. This should
  help human readers know what the entries in __all__ are referring
  to, and may also help some automated tools.

  This is a preliminary decision and not necessarily the one that
  will ultimately be taken in this import refactoring. It *may*
  cause some kinds of mistakes, such as those arising from
  ill-advised use of wildcard imports in production code *outside*
  GitPython, somewhat worse in their effects.

- Remove the ab.diff file that showed the problem this solves.

This resolves the problem deliberately introduced in the previous
incomplete commit, which modattrs.py output and test_imports test
results confirm.
---
 ab.diff               | 30 ------------------------------
 git/index/__init__.py |  5 +++++
 2 files changed, 5 insertions(+), 30 deletions(-)
 delete mode 100644 ab.diff

diff --git a/ab.diff b/ab.diff
deleted file mode 100644
index 34abf9b16..000000000
--- a/ab.diff
+++ /dev/null
@@ -1,30 +0,0 @@
-diff --git a/a b/b
-index 81b3f984..7b8c8ede 100644
---- a/a
-+++ b/b
-@@ -83,14 +83,12 @@ git:
-     __path__:  ['C:\\Users\\ek\\source\\repos\\GitPython\\git']
-     __spec__:  ModuleSpec(name='git', loader=<_frozen_importlib_external.SourceFileLoader object at 0x...>, origin='C:\\Users\\ek\\source\\repos\\GitPython\\git\\__init__.py', submodule_search_locations=['C:\\Users\\ek\\source\\repos\\GitPython\\git'])
-     __version__:  'git'
--    base:  <module 'git.index.base' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\base.py'>
-     cmd:  <module 'git.cmd' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\cmd.py'>
-     compat:  <module 'git.compat' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\compat.py'>
-     config:  <module 'git.config' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\config.py'>
-     db:  <module 'git.db' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\db.py'>
-     diff:  <module 'git.diff' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\diff.py'>
-     exc:  <module 'git.exc' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\exc.py'>
--    fun:  <module 'git.index.fun' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\fun.py'>
-     head:  <module 'git.refs.head' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\head.py'>
-     index:  <module 'git.index' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\__init__.py'>
-     log:  <module 'git.refs.log' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\log.py'>
-@@ -106,9 +104,8 @@ git:
-     symbolic:  <module 'git.refs.symbolic' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\symbolic.py'>
-     tag:  <module 'git.refs.tag' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\refs\\tag.py'>
-     to_hex_sha:  <function to_hex_sha at 0x...>
--    typ:  <module 'git.index.typ' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\typ.py'>
-     types:  <module 'git.types' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\types.py'>
--    util:  <module 'git.index.util' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\index\\util.py'>
-+    util:  <module 'git.util' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\util.py'>
- 
- 
- git.cmd:
diff --git a/git/index/__init__.py b/git/index/__init__.py
index ba48110fd..2086d67f9 100644
--- a/git/index/__init__.py
+++ b/git/index/__init__.py
@@ -4,6 +4,10 @@
 """Initialize the index package."""
 
 __all__ = [
+    "base",
+    "fun",
+    "typ",
+    "util",
     "BaseIndexEntry",
     "BlobFilter",
     "CheckoutError",
@@ -12,5 +16,6 @@
     "StageType",
 ]
 
+from . import base, fun, typ, util
 from .base import CheckoutError, IndexFile
 from .typ import BaseIndexEntry, BlobFilter, IndexEntry, StageType

From 1c9bda222077a6227beed2e06df6f26e2e864807 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 13:59:43 -0400
Subject: [PATCH 005/332] Improve relative order of import groups, and __all__,
 in git.index

---
 git/index/base.py |  5 +++--
 git/index/fun.py  | 41 ++++++++++++++++-------------------------
 git/index/typ.py  |  3 ++-
 git/index/util.py |  7 ++-----
 4 files changed, 23 insertions(+), 33 deletions(-)

diff --git a/git/index/base.py b/git/index/base.py
index 38c14252b..b49841435 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -19,6 +19,9 @@
 import sys
 import tempfile
 
+from gitdb.base import IStream
+from gitdb.db import MemoryDB
+
 from git.compat import defenc, force_bytes
 import git.diff as git_diff
 from git.exc import CheckoutError, GitCommandError, GitError, InvalidGitRepositoryError
@@ -33,8 +36,6 @@
     unbare_repo,
     to_bin_sha,
 )
-from gitdb.base import IStream
-from gitdb.db import MemoryDB
 
 from .fun import (
     S_IFGITLINK,
diff --git a/git/index/fun.py b/git/index/fun.py
index 001e8f6f2..b24e803a3 100644
--- a/git/index/fun.py
+++ b/git/index/fun.py
@@ -4,22 +4,28 @@
 """Standalone functions to accompany the index implementation and make it more
 versatile."""
 
+__all__ = (
+    "write_cache",
+    "read_cache",
+    "write_tree_from_cache",
+    "entry_key",
+    "stat_mode_to_index_mode",
+    "S_IFGITLINK",
+    "run_commit_hook",
+    "hook_path",
+)
+
 from io import BytesIO
 import os
 import os.path as osp
 from pathlib import Path
-from stat import (
-    S_IFDIR,
-    S_IFLNK,
-    S_ISLNK,
-    S_ISDIR,
-    S_IFMT,
-    S_IFREG,
-    S_IXUSR,
-)
+from stat import S_IFDIR, S_IFLNK, S_IFMT, S_IFREG, S_ISDIR, S_ISLNK, S_IXUSR
 import subprocess
 import sys
 
+from gitdb.base import IStream
+from gitdb.typ import str_tree_type
+
 from git.cmd import handle_process_output, safer_popen
 from git.compat import defenc, force_bytes, force_text, safe_decode
 from git.exc import HookExecutionError, UnmergedEntriesError
@@ -29,8 +35,6 @@
     tree_to_stream,
 )
 from git.util import IndexFileSHA1Writer, finalize_process
-from gitdb.base import IStream
-from gitdb.typ import str_tree_type
 
 from .typ import BaseIndexEntry, IndexEntry, CE_NAMEMASK, CE_STAGESHIFT
 from .util import pack, unpack
@@ -42,31 +46,18 @@
 from git.types import PathLike
 
 if TYPE_CHECKING:
-    from .base import IndexFile
     from git.db import GitCmdObjectDB
     from git.objects.tree import TreeCacheTup
 
-    # from git.objects.fun import EntryTupOrNone
+    from .base import IndexFile
 
 # ------------------------------------------------------------------------------------
 
-
 S_IFGITLINK = S_IFLNK | S_IFDIR
 """Flags for a submodule."""
 
 CE_NAMEMASK_INV = ~CE_NAMEMASK
 
-__all__ = (
-    "write_cache",
-    "read_cache",
-    "write_tree_from_cache",
-    "entry_key",
-    "stat_mode_to_index_mode",
-    "S_IFGITLINK",
-    "run_commit_hook",
-    "hook_path",
-)
-
 
 def hook_path(name: str, git_dir: PathLike) -> str:
     """:return: path to the given named hook in the given git repository directory"""
diff --git a/git/index/typ.py b/git/index/typ.py
index eb0dd4341..0fbcd69f0 100644
--- a/git/index/typ.py
+++ b/git/index/typ.py
@@ -8,9 +8,10 @@
 from binascii import b2a_hex
 from pathlib import Path
 
-from .util import pack, unpack
 from git.objects import Blob
 
+from .util import pack, unpack
+
 # typing ----------------------------------------------------------------------
 
 from typing import NamedTuple, Sequence, TYPE_CHECKING, Tuple, Union, cast
diff --git a/git/index/util.py b/git/index/util.py
index 0bad11571..4aee61bce 100644
--- a/git/index/util.py
+++ b/git/index/util.py
@@ -3,6 +3,8 @@
 
 """Index utilities."""
 
+__all__ = ("TemporaryFileSwap", "post_clear_cache", "default_index", "git_working_dir")
+
 import contextlib
 from functools import wraps
 import os
@@ -22,14 +24,9 @@
 
 # ---------------------------------------------------------------------------------
 
-
-__all__ = ("TemporaryFileSwap", "post_clear_cache", "default_index", "git_working_dir")
-
 # { Aliases
 pack = struct.pack
 unpack = struct.unpack
-
-
 # } END aliases
 
 

From 8b51af36a4943f75fb66d553d55e381859fe3a34 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 14:31:12 -0400
Subject: [PATCH 006/332] Improve order of imports and __all__ in git.refs
 submodules

---
 git/refs/head.py      | 12 ++++++------
 git/refs/log.py       | 17 ++++++++---------
 git/refs/reference.py |  9 +++++----
 git/refs/remote.py    | 11 +++++------
 git/refs/symbolic.py  | 11 ++++++-----
 git/refs/tag.py       |  8 +++-----
 6 files changed, 33 insertions(+), 35 deletions(-)

diff --git a/git/refs/head.py b/git/refs/head.py
index 86321d9ea..7e6fc3377 100644
--- a/git/refs/head.py
+++ b/git/refs/head.py
@@ -6,12 +6,14 @@
 Note the distinction between the :class:`HEAD` and :class:`Head` classes.
 """
 
+__all__ = ["HEAD", "Head"]
+
 from git.config import GitConfigParser, SectionConstraint
-from git.util import join_path
 from git.exc import GitCommandError
+from git.util import join_path
 
-from .symbolic import SymbolicReference
 from .reference import Reference
+from .symbolic import SymbolicReference
 
 # typing ---------------------------------------------------
 
@@ -26,8 +28,6 @@
 
 # -------------------------------------------------------------------
 
-__all__ = ["HEAD", "Head"]
-
 
 def strip_quotes(string: str) -> str:
     if string.startswith('"') and string.endswith('"'):
@@ -36,8 +36,8 @@ def strip_quotes(string: str) -> str:
 
 
 class HEAD(SymbolicReference):
-    """Special case of a SymbolicReference representing the repository's HEAD
-    reference."""
+    """Special case of a :class:`~git.refs.symbolic.SymbolicReference` representing the
+    repository's HEAD reference."""
 
     _HEAD_NAME = "HEAD"
     _ORIG_HEAD_NAME = "ORIG_HEAD"
diff --git a/git/refs/log.py b/git/refs/log.py
index f98f56f11..17e3a94b3 100644
--- a/git/refs/log.py
+++ b/git/refs/log.py
@@ -1,44 +1,43 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ["RefLog", "RefLogEntry"]
+
 from mmap import mmap
+import os.path as osp
 import re
 import time as _time
 
 from git.compat import defenc
 from git.objects.util import (
-    parse_date,
     Serializable,
     altz_to_utctz_str,
+    parse_date,
 )
 from git.util import (
     Actor,
     LockedFD,
     LockFile,
     assure_directory_exists,
-    to_native_path,
     bin_to_hex,
     file_contents_ro_filepath,
+    to_native_path,
 )
 
-import os.path as osp
-
-
 # typing ------------------------------------------------------------------
 
-from typing import Iterator, List, Tuple, Union, TYPE_CHECKING
+from typing import Iterator, List, Tuple, TYPE_CHECKING, Union
 
 from git.types import PathLike
 
 if TYPE_CHECKING:
     from io import BytesIO
-    from git.refs import SymbolicReference
+
     from git.config import GitConfigParser, SectionConstraint
+    from git.refs import SymbolicReference
 
 # ------------------------------------------------------------------------------
 
-__all__ = ["RefLog", "RefLogEntry"]
-
 
 class RefLogEntry(Tuple[str, str, Actor, Tuple[int, int], str]):
     """Named tuple allowing easy access to the revlog data fields."""
diff --git a/git/refs/reference.py b/git/refs/reference.py
index 62fb58420..e5d473779 100644
--- a/git/refs/reference.py
+++ b/git/refs/reference.py
@@ -1,7 +1,10 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ["Reference"]
+
 from git.util import IterableObj, LazyMixin
+
 from .symbolic import SymbolicReference, T_References
 
 # typing ------------------------------------------------------------------
@@ -15,8 +18,6 @@
 
 # ------------------------------------------------------------------------------
 
-__all__ = ["Reference"]
-
 # { Utilities
 
 
@@ -34,7 +35,7 @@ def wrapper(self: T_References, *args: Any) -> _T:
     return wrapper
 
 
-# }END utilities
+# } END utilities
 
 
 class Reference(SymbolicReference, LazyMixin, IterableObj):
@@ -142,7 +143,7 @@ def iter_items(
         but will return non-detached references as well."""
         return cls._iter_items(repo, common_path)
 
-    # }END interface
+    # } END interface
 
     # { Remote Interface
 
diff --git a/git/refs/remote.py b/git/refs/remote.py
index 3f9c6c6be..b4f4f7b36 100644
--- a/git/refs/remote.py
+++ b/git/refs/remote.py
@@ -3,24 +3,23 @@
 
 """Module implementing a remote object allowing easy access to git remotes."""
 
+__all__ = ["RemoteReference"]
+
 import os
 
 from git.util import join_path
 
 from .head import Head
 
-
-__all__ = ["RemoteReference"]
-
 # typing ------------------------------------------------------------------
 
-from typing import Any, Iterator, NoReturn, Union, TYPE_CHECKING
-from git.types import PathLike
+from typing import Any, Iterator, NoReturn, TYPE_CHECKING, Union
 
+from git.types import PathLike
 
 if TYPE_CHECKING:
+    from git.remote import Remote
     from git.repo import Repo
-    from git import Remote
 
 # ------------------------------------------------------------------------------
 
diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py
index 2701f9f2b..510850b2e 100644
--- a/git/refs/symbolic.py
+++ b/git/refs/symbolic.py
@@ -1,10 +1,14 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ["SymbolicReference"]
+
 import os
 
+from gitdb.exc import BadName, BadObject
+
 from git.compat import defenc
-from git.objects import Object
+from git.objects.base import Object
 from git.objects.commit import Commit
 from git.refs.log import RefLog
 from git.util import (
@@ -15,7 +19,6 @@
     join_path_native,
     to_native_path_linux,
 )
-from gitdb.exc import BadName, BadObject
 
 # typing ------------------------------------------------------------------
 
@@ -30,6 +33,7 @@
     Union,
     cast,
 )
+
 from git.types import AnyGitObject, PathLike
 
 if TYPE_CHECKING:
@@ -45,9 +49,6 @@
 # ------------------------------------------------------------------------------
 
 
-__all__ = ["SymbolicReference"]
-
-
 def _git_dir(repo: "Repo", path: Union[PathLike, None]) -> PathLike:
     """Find the git dir that is appropriate for the path."""
     name = f"{path}"
diff --git a/git/refs/tag.py b/git/refs/tag.py
index f653d4e7d..1e38663ae 100644
--- a/git/refs/tag.py
+++ b/git/refs/tag.py
@@ -8,10 +8,10 @@
 :mod:`git.objects.tag` module.
 """
 
-from .reference import Reference
-
 __all__ = ["TagReference", "Tag"]
 
+from .reference import Reference
+
 # typing ------------------------------------------------------------------
 
 from typing import Any, TYPE_CHECKING, Type, Union
@@ -19,12 +19,10 @@
 from git.types import AnyGitObject, PathLike
 
 if TYPE_CHECKING:
-    from git.objects import Commit
-    from git.objects import TagObject
+    from git.objects import Commit, TagObject
     from git.refs import SymbolicReference
     from git.repo import Repo
 
-
 # ------------------------------------------------------------------------------
 
 

From b25dd7e6c1b5453fce111a470832e8942430c934 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 14:43:05 -0400
Subject: [PATCH 007/332] Replace wildcard imports in git.refs

This uses explicit imports rather than wildcard imports in git.refs
for names from its submodules.

A comment suggested that the import order was deliberate. But each
of the modules being imported from defined __all__, and there was
no overlap in the names across any of them.

The other main reason to import in a specific order is an order
dependency in the side effects, but that does not appear to apply,
at least not at this time.

(In addition, at least for now, this adds explicit imports for the
Python submodules of git.refs, so it is clear that they can always
be accessed directly in git.refs without further imports, if
desired. For clarity, those appear first, and that makes the order
of the "from" imports not relevant to such side effects, due to the
"from" imports no longer causing modules to be loaded for the first
time. However, this is a much less important reason to consider the
other imports' reordering okay, because these module imports may
end up being removed later during this refactoring; their clarity
benefit might not be justified, because if production code outside
GitPython ill-advisedly uses wildcard imports, the bad effect of
doing so could be increased.)
---
 git/refs/__init__.py | 32 ++++++++++++++++++++++++--------
 1 file changed, 24 insertions(+), 8 deletions(-)

diff --git a/git/refs/__init__.py b/git/refs/__init__.py
index b0233e902..04279901a 100644
--- a/git/refs/__init__.py
+++ b/git/refs/__init__.py
@@ -1,12 +1,28 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-# Import all modules in order, fix the names they require.
+__all__ = [
+    "head",
+    "log",
+    "reference",
+    "remote",
+    "symbolic",
+    "tag",
+    "HEAD",
+    "Head",
+    "RefLog",
+    "RefLogEntry",
+    "Reference",
+    "RemoteReference",
+    "SymbolicReference",
+    "Tag",
+    "TagReference",
+]
 
-from .symbolic import *  # noqa: F401 F403
-from .reference import *  # noqa: F401 F403
-from .head import *  # noqa: F401 F403
-from .tag import *  # noqa: F401 F403
-from .remote import *  # noqa: F401 F403
-
-from .log import *  # noqa: F401 F403
+from . import head, log, reference, remote, symbolic, tag
+from .head import HEAD, Head
+from .log import RefLog, RefLogEntry
+from .reference import Reference
+from .remote import RemoteReference
+from .symbolic import SymbolicReference
+from .tag import Tag, TagReference

From b32ef6528c0da39b91b6399c5593d10ca41c5865 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 15:01:33 -0400
Subject: [PATCH 008/332] Improve order of imports and __all__ in git.repo
 submodules

---
 git/repo/base.py |  4 ++--
 git/repo/fun.py  | 30 ++++++++++++++++--------------
 2 files changed, 18 insertions(+), 16 deletions(-)

diff --git a/git/repo/base.py b/git/repo/base.py
index ce164274e..63b21fdbf 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -5,6 +5,8 @@
 
 from __future__ import annotations
 
+__all__ = ("Repo",)
+
 import gc
 import logging
 import os
@@ -92,8 +94,6 @@
 
 _logger = logging.getLogger(__name__)
 
-__all__ = ("Repo",)
-
 
 class BlameEntry(NamedTuple):
     commit: Dict[str, Commit]
diff --git a/git/repo/fun.py b/git/repo/fun.py
index 738e5816d..1defcb1ac 100644
--- a/git/repo/fun.py
+++ b/git/repo/fun.py
@@ -5,18 +5,31 @@
 
 from __future__ import annotations
 
+__all__ = (
+    "rev_parse",
+    "is_git_dir",
+    "touch",
+    "find_submodule_git_dir",
+    "name_to_object",
+    "short_to_long",
+    "deref_tag",
+    "to_commit",
+    "find_worktree_git_dir",
+)
+
 import os
 import os.path as osp
 from pathlib import Path
 import stat
 from string import digits
 
+from gitdb.exc import BadName, BadObject
+
 from git.cmd import Git
 from git.exc import WorkTreeRepositoryUnsupported
 from git.objects import Object
 from git.refs import SymbolicReference
-from git.util import hex_to_bin, bin_to_hex, cygpath
-from gitdb.exc import BadName, BadObject
+from git.util import cygpath, bin_to_hex, hex_to_bin
 
 # Typing ----------------------------------------------------------------------
 
@@ -29,22 +42,11 @@
     from git.objects import Commit, TagObject
     from git.refs.reference import Reference
     from git.refs.tag import Tag
+
     from .base import Repo
 
 # ----------------------------------------------------------------------------
 
-__all__ = (
-    "rev_parse",
-    "is_git_dir",
-    "touch",
-    "find_submodule_git_dir",
-    "name_to_object",
-    "short_to_long",
-    "deref_tag",
-    "to_commit",
-    "find_worktree_git_dir",
-)
-
 
 def touch(filename: str) -> str:
     with open(filename, "ab"):

From 0ba06e9425e90f7f787ac39f48562af54ff41038 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 15:03:49 -0400
Subject: [PATCH 009/332] Add git.repo.__all__ and make submodules explicit

- No need to import Repo "as Repo". Some tools only recognize this
  to give the name conceptually public status when it appears in
  type stub files (.pyi files), and listing it in the newly created
  __all__ is sufficient to let humans and all tools know it has
  that status.

- As very recently done in git.refs, this explicitly imports the
  submodules, so it is clear they are available and don't have to
  be explicitly imported. (Fundamental to the way they are used is
  that they will end up being imported in order to import Repo.)

  However, also as in git.refs, it may be that the problems this
  could cause in some inherently flawed but plausible uses are too
  greater for it to be justified. So this may be revisited.
---
 git/repo/__init__.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/git/repo/__init__.py b/git/repo/__init__.py
index 50a8c6f86..3b784b975 100644
--- a/git/repo/__init__.py
+++ b/git/repo/__init__.py
@@ -3,4 +3,7 @@
 
 """Initialize the repo package."""
 
-from .base import Repo as Repo  # noqa: F401
+__all__ = ["base", "fun", "Repo"]
+
+from . import base, fun
+from .base import Repo

From c946906c4e92512e4294f587eaccaf0029a425df Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 15:29:45 -0400
Subject: [PATCH 010/332] Improve order of imports and __all__ in git.objects.*

But not yet in git.objects.submodule.* modules.
---
 git/objects/submodule/base.py |  4 ++--
 git/objects/submodule/root.py |  5 +++--
 git/objects/submodule/util.py | 24 +++++++++++-------------
 3 files changed, 16 insertions(+), 17 deletions(-)

diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py
index d01aa448f..fa60bcdaf 100644
--- a/git/objects/submodule/base.py
+++ b/git/objects/submodule/base.py
@@ -1,6 +1,8 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ["Submodule", "UpdateProgress"]
+
 import gc
 from io import BytesIO
 import logging
@@ -68,8 +70,6 @@
 
 # -----------------------------------------------------------------------------
 
-__all__ = ["Submodule", "UpdateProgress"]
-
 _logger = logging.getLogger(__name__)
 
 
diff --git a/git/objects/submodule/root.py b/git/objects/submodule/root.py
index ae56e5ef4..d93193fa3 100644
--- a/git/objects/submodule/root.py
+++ b/git/objects/submodule/root.py
@@ -1,10 +1,13 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ["RootModule", "RootUpdateProgress"]
+
 import logging
 
 import git
 from git.exc import InvalidGitRepositoryError
+
 from .base import Submodule, UpdateProgress
 from .util import find_first_remote_branch
 
@@ -20,8 +23,6 @@
 
 # ----------------------------------------------------------------------------
 
-__all__ = ["RootModule", "RootUpdateProgress"]
-
 _logger = logging.getLogger(__name__)
 
 
diff --git a/git/objects/submodule/util.py b/git/objects/submodule/util.py
index 10b994e9b..fda81249b 100644
--- a/git/objects/submodule/util.py
+++ b/git/objects/submodule/util.py
@@ -1,12 +1,20 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-import git
-from git.exc import InvalidGitRepositoryError
-from git.config import GitConfigParser
+__all__ = (
+    "sm_section",
+    "sm_name",
+    "mkhead",
+    "find_first_remote_branch",
+    "SubmoduleConfigParser",
+)
+
 from io import BytesIO
 import weakref
 
+import git
+from git.config import GitConfigParser
+from git.exc import InvalidGitRepositoryError
 
 # typing -----------------------------------------------------------------------
 
@@ -22,15 +30,6 @@
     from git import Remote
     from git.refs import RemoteReference
 
-
-__all__ = (
-    "sm_section",
-    "sm_name",
-    "mkhead",
-    "find_first_remote_branch",
-    "SubmoduleConfigParser",
-)
-
 # { Utilities
 
 
@@ -65,7 +64,6 @@ def find_first_remote_branch(remotes: Sequence["Remote"], branch_name: str) -> "
 
 # } END utilities
 
-
 # { Classes
 
 

From 4e9a2f2facb0da3adb9c6fe5165dcfb5c6627cb7 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 15:42:09 -0400
Subject: [PATCH 011/332] Improve order of imports and __all__ in
 git.object.submodule.*

---
 git/objects/base.py           | 12 ++++-----
 git/objects/blob.py           |  6 ++---
 git/objects/commit.py         |  9 ++++---
 git/objects/fun.py            | 19 +++++++-------
 git/objects/submodule/util.py |  9 ++++---
 git/objects/tag.py            | 14 +++++++---
 git/objects/tree.py           |  9 +++----
 git/objects/util.py           | 49 +++++++++++++++++++----------------
 8 files changed, 68 insertions(+), 59 deletions(-)

diff --git a/git/objects/base.py b/git/objects/base.py
index 22d939aa6..07ff639e5 100644
--- a/git/objects/base.py
+++ b/git/objects/base.py
@@ -3,15 +3,17 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-import gitdb.typ as dbtyp
+__all__ = ("Object", "IndexObject")
+
 import os.path as osp
 
+import gitdb.typ as dbtyp
+
 from git.exc import WorkTreeRepositoryUnsupported
-from git.util import LazyMixin, join_path_native, stream_copy, bin_to_hex
+from git.util import LazyMixin, bin_to_hex, join_path_native, stream_copy
 
 from .util import get_object_type_by_name
 
-
 # typing ------------------------------------------------------------------
 
 from typing import Any, TYPE_CHECKING, Union
@@ -24,16 +26,14 @@
     from git.refs.reference import Reference
     from git.repo import Repo
 
-    from .tree import Tree
     from .blob import Blob
     from .submodule.base import Submodule
+    from .tree import Tree
 
 IndexObjUnion = Union["Tree", "Blob", "Submodule"]
 
 # --------------------------------------------------------------------------
 
-__all__ = ("Object", "IndexObject")
-
 
 class Object(LazyMixin):
     """Base class for classes representing git object types.
diff --git a/git/objects/blob.py b/git/objects/blob.py
index 122d5f731..50500f550 100644
--- a/git/objects/blob.py
+++ b/git/objects/blob.py
@@ -3,17 +3,17 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ("Blob",)
+
 from mimetypes import guess_type
 import sys
 
-from . import base
-
 if sys.version_info >= (3, 8):
     from typing import Literal
 else:
     from typing_extensions import Literal
 
-__all__ = ("Blob",)
+from . import base
 
 
 class Blob(base.IndexObject):
diff --git a/git/objects/commit.py b/git/objects/commit.py
index 6a60c30bd..b22308726 100644
--- a/git/objects/commit.py
+++ b/git/objects/commit.py
@@ -3,6 +3,8 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ("Commit",)
+
 from collections import defaultdict
 import datetime
 from io import BytesIO
@@ -14,10 +16,12 @@
 from time import altzone, daylight, localtime, time, timezone
 
 from gitdb import IStream
+
 from git.cmd import Git
 from git.diff import Diffable
-from git.util import hex_to_bin, Actor, Stats, finalize_process
+from git.util import Actor, Stats, finalize_process, hex_to_bin
 
+from . import base
 from .tree import Tree
 from .util import (
     Serializable,
@@ -27,7 +31,6 @@
     parse_actor_and_date,
     parse_date,
 )
-from . import base
 
 # typing ------------------------------------------------------------------
 
@@ -59,8 +62,6 @@
 
 _logger = logging.getLogger(__name__)
 
-__all__ = ("Commit",)
-
 
 class Commit(base.Object, TraversableIterableObj, Diffable, Serializable):
     """Wraps a git commit object.
diff --git a/git/objects/fun.py b/git/objects/fun.py
index 5bd8a3d62..9bd2d8f10 100644
--- a/git/objects/fun.py
+++ b/git/objects/fun.py
@@ -3,8 +3,14 @@
 
 """Functions that are supposed to be as fast as possible."""
 
-from stat import S_ISDIR
+__all__ = (
+    "tree_to_stream",
+    "tree_entries_from_data",
+    "traverse_trees_recursive",
+    "traverse_tree_recursive",
+)
 
+from stat import S_ISDIR
 
 from git.compat import safe_decode, defenc
 
@@ -23,22 +29,15 @@
 
 if TYPE_CHECKING:
     from _typeshed import ReadableBuffer
+
     from git import GitCmdObjectDB
 
-EntryTup = Tuple[bytes, int, str]  # same as TreeCacheTup in tree.py
+EntryTup = Tuple[bytes, int, str]  # Same as TreeCacheTup in tree.py.
 EntryTupOrNone = Union[EntryTup, None]
 
 # ---------------------------------------------------
 
 
-__all__ = (
-    "tree_to_stream",
-    "tree_entries_from_data",
-    "traverse_trees_recursive",
-    "traverse_tree_recursive",
-)
-
-
 def tree_to_stream(entries: Sequence[EntryTup], write: Callable[["ReadableBuffer"], Union[int, None]]) -> None:
     """Write the given list of entries into a stream using its ``write`` method.
 
diff --git a/git/objects/submodule/util.py b/git/objects/submodule/util.py
index fda81249b..3a09d6a17 100644
--- a/git/objects/submodule/util.py
+++ b/git/objects/submodule/util.py
@@ -23,12 +23,13 @@
 from git.types import PathLike
 
 if TYPE_CHECKING:
-    from .base import Submodule
     from weakref import ReferenceType
+
+    from git.refs import Head, RemoteReference
+    from git.remote import Remote
     from git.repo import Repo
-    from git.refs import Head
-    from git import Remote
-    from git.refs import RemoteReference
+
+    from .base import Submodule
 
 # { Utilities
 
diff --git a/git/objects/tag.py b/git/objects/tag.py
index 52d79751f..5ad311590 100644
--- a/git/objects/tag.py
+++ b/git/objects/tag.py
@@ -9,12 +9,17 @@
 For lightweight tags, see the :mod:`git.refs.tag` module.
 """
 
+__all__ = ("TagObject",)
+
 import sys
 
+from git.compat import defenc
+from git.util import hex_to_bin
+
 from . import base
 from .util import get_object_type_by_name, parse_actor_and_date
-from ..util import hex_to_bin
-from ..compat import defenc
+
+# typing ----------------------------------------------
 
 from typing import List, TYPE_CHECKING, Union
 
@@ -26,11 +31,12 @@
 if TYPE_CHECKING:
     from git.repo import Repo
     from git.util import Actor
-    from .commit import Commit
+
     from .blob import Blob
+    from .commit import Commit
     from .tree import Tree
 
-__all__ = ("TagObject",)
+# ---------------------------------------------------
 
 
 class TagObject(base.Object):
diff --git a/git/objects/tree.py b/git/objects/tree.py
index c74df58a9..18ccf2f30 100644
--- a/git/objects/tree.py
+++ b/git/objects/tree.py
@@ -3,16 +3,18 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ("TreeModifier", "Tree")
+
 import sys
 
 import git.diff as git_diff
 from git.util import IterableList, join_path, to_bin_sha
 
+from . import util
 from .base import IndexObjUnion, IndexObject
 from .blob import Blob
 from .fun import tree_entries_from_data, tree_to_stream
 from .submodule.base import Submodule
-from . import util
 
 # typing -------------------------------------------------
 
@@ -39,23 +41,20 @@
 
 if TYPE_CHECKING:
     from io import BytesIO
+
     from git.repo import Repo
 
 TreeCacheTup = Tuple[bytes, int, str]
 
 TraversedTreeTup = Union[Tuple[Union["Tree", None], IndexObjUnion, Tuple["Submodule", "Submodule"]]]
 
-
 # def is_tree_cache(inp: Tuple[bytes, int, str]) -> TypeGuard[TreeCacheTup]:
 #     return isinstance(inp[0], bytes) and isinstance(inp[1], int) and isinstance([inp], str)
 
 # --------------------------------------------------------
 
-
 cmp: Callable[[str, str], int] = lambda a, b: (a > b) - (a < b)
 
-__all__ = ("TreeModifier", "Tree")
-
 
 class TreeModifier:
     """A utility class providing methods to alter the underlying cache in a list-like
diff --git a/git/objects/util.py b/git/objects/util.py
index 297b33b70..6f6927b47 100644
--- a/git/objects/util.py
+++ b/git/objects/util.py
@@ -5,26 +5,40 @@
 
 """General utility functions."""
 
+__all__ = (
+    "get_object_type_by_name",
+    "parse_date",
+    "parse_actor_and_date",
+    "ProcessStreamAdapter",
+    "Traversable",
+    "altz_to_utctz_str",
+    "utctz_to_altz",
+    "verify_utctz",
+    "Actor",
+    "tzoffset",
+    "utc",
+)
+
 from abc import ABC, abstractmethod
 import calendar
 from collections import deque
 from datetime import datetime, timedelta, tzinfo
-from string import digits
 import re
+from string import digits
 import time
 import warnings
 
-from git.util import IterableList, IterableObj, Actor
+from git.util import Actor, IterableList, IterableObj
 
 # typing ------------------------------------------------------------
+
 from typing import (
     Any,
     Callable,
     Deque,
-    Iterator,
     # Generic,
+    Iterator,
     NamedTuple,
-    overload,
     Sequence,
     TYPE_CHECKING,
     Tuple,
@@ -32,19 +46,22 @@
     TypeVar,
     Union,
     cast,
+    overload,
 )
 
 from git.types import Has_id_attribute, Literal  # , _T
 
 if TYPE_CHECKING:
     from io import BytesIO, StringIO
-    from .commit import Commit
-    from .blob import Blob
-    from .tag import TagObject
-    from .tree import Tree, TraversedTreeTup
     from subprocess import Popen
-    from .submodule.base import Submodule
+
     from git.types import Protocol, runtime_checkable
+
+    from .blob import Blob
+    from .commit import Commit
+    from .submodule.base import Submodule
+    from .tag import TagObject
+    from .tree import TraversedTreeTup, Tree
 else:
     # Protocol = Generic[_T]  # Needed for typing bug #572?
     Protocol = ABC
@@ -68,20 +85,6 @@ class TraverseNT(NamedTuple):
 
 # --------------------------------------------------------------------
 
-__all__ = (
-    "get_object_type_by_name",
-    "parse_date",
-    "parse_actor_and_date",
-    "ProcessStreamAdapter",
-    "Traversable",
-    "altz_to_utctz_str",
-    "utctz_to_altz",
-    "verify_utctz",
-    "Actor",
-    "tzoffset",
-    "utc",
-)
-
 ZERO = timedelta(0)
 
 # { Functions

From c58be4c0b6e82dc1c0445f9e41e53263f5d2ec4e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 15:44:52 -0400
Subject: [PATCH 012/332] Remove a bit of old commented-out code in
 git.objects.*

---
 git/objects/tree.py | 4 ----
 git/objects/util.py | 4 +---
 2 files changed, 1 insertion(+), 7 deletions(-)

diff --git a/git/objects/tree.py b/git/objects/tree.py
index 18ccf2f30..d8320b319 100644
--- a/git/objects/tree.py
+++ b/git/objects/tree.py
@@ -48,9 +48,6 @@
 
 TraversedTreeTup = Union[Tuple[Union["Tree", None], IndexObjUnion, Tuple["Submodule", "Submodule"]]]
 
-# def is_tree_cache(inp: Tuple[bytes, int, str]) -> TypeGuard[TreeCacheTup]:
-#     return isinstance(inp[0], bytes) and isinstance(inp[1], int) and isinstance([inp], str)
-
 # --------------------------------------------------------
 
 cmp: Callable[[str, str], int] = lambda a, b: (a > b) - (a < b)
@@ -124,7 +121,6 @@ def add(self, sha: bytes, mode: int, name: str, force: bool = False) -> "TreeMod
         index = self._index_by_name(name)
 
         item = (sha, mode, name)
-        # assert is_tree_cache(item)
 
         if index == -1:
             self._cache.append(item)
diff --git a/git/objects/util.py b/git/objects/util.py
index 6f6927b47..9491b067c 100644
--- a/git/objects/util.py
+++ b/git/objects/util.py
@@ -36,7 +36,6 @@
     Any,
     Callable,
     Deque,
-    # Generic,
     Iterator,
     NamedTuple,
     Sequence,
@@ -49,7 +48,7 @@
     overload,
 )
 
-from git.types import Has_id_attribute, Literal  # , _T
+from git.types import Has_id_attribute, Literal
 
 if TYPE_CHECKING:
     from io import BytesIO, StringIO
@@ -63,7 +62,6 @@
     from .tag import TagObject
     from .tree import TraversedTreeTup, Tree
 else:
-    # Protocol = Generic[_T]  # Needed for typing bug #572?
     Protocol = ABC
 
     def runtime_checkable(f):

From 01c95ebcc7d41bc68e1f64f9b1ec01c30f29d591 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 16:38:39 -0400
Subject: [PATCH 013/332] Don't patch IndexObject and Object into
 git.objects.submodule.util

Such patching, which was introduced in 9519f18, seems no longer to
be necessary. Since git.objects.submodule.util.__all__ has existed
for a long time without including those names, they are not
conceptually public attributes of git.objects.submodule.util, so
they should not be in use by any code outside GitPython either.

The modattrs.py script shows the change, as expected, showing these
two names as no longer being in the git.objects.submodule.util
module dictionary, in output of:

    python modattrs.py >b
    git diff --no-index a b

However, because the removal is intentional, this is okay.
---
 git/objects/__init__.py | 7 -------
 1 file changed, 7 deletions(-)

diff --git a/git/objects/__init__.py b/git/objects/__init__.py
index 1061ec874..d636873a0 100644
--- a/git/objects/__init__.py
+++ b/git/objects/__init__.py
@@ -8,17 +8,10 @@
 from .base import *  # noqa: F403
 from .blob import *  # noqa: F403
 from .commit import *  # noqa: F403
-from .submodule import util as smutil
 from .submodule.base import *  # noqa: F403
 from .submodule.root import *  # noqa: F403
 from .tag import *  # noqa: F403
 from .tree import *  # noqa: F403
 
-# Fix import dependency - add IndexObject to the util module, so that it can be imported
-# by the submodule.base.
-smutil.IndexObject = IndexObject  # type: ignore[attr-defined]  # noqa: F405
-smutil.Object = Object  # type: ignore[attr-defined]  # noqa: F405
-del smutil
-
 # Must come after submodule was made available.
 __all__ = [name for name, obj in locals().items() if not (name.startswith("_") or inspect.ismodule(obj))]

From f89d065742ba5b62056f1aa679e1197124bf7bcf Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 18:50:37 -0400
Subject: [PATCH 014/332] Fix git.objects.__all__ and make submodules explicit

The submodules being made explicit here are of course Python
submodules, not git submodules.

The git.objects.submodule Python submodule (which is *about* git
submodules) is made explicit here (as are the names imported from
that Python submodule's own Python submodules) along with the other
Python submodules of git.objects.

Unlike some other submodules, but like the top-level git module
until #1659, git.objects already defined __all__ but it was
dynamically constructed. As with git.__all__ previously (as noted
in #1859), I have used https://github.com/EliahKagan/deltall here
to check that it is safe, sufficient, and probably necessary to
replace this dynamically constructed __all__ with a literal list of
strings in which all of the original elements are included. See:
https://gist.github.com/EliahKagan/e627603717ca5f9cafaf8de9d9f27ad7

Running the modattrs.py script, and diffing against the output from
before the current import refactoring began, reveals two changes to
module contents as a result of the change here:

- git.objects no longer contains `inspect`, which it imported just
  to build the dynamically constructed __all__. Because this was
  not itself included in that __all__ or otherwise made public or
  used out of git.objects, that is okay. This is exactly analogous
  to the situation in 8197e90, which removed it from the top-level
  git module after #1659.

- The top-level git module now has has new attributes blob, commit,
  submodule, and tree, aliasing the corresponding modules. This
  has happened as a result of them being put in git.objects.__all__
  along with various names imported from them. (As noted in some
  prior commits, there are tradeoffs associated with doing this,
  and it may be that such elements of __all__ should be removed,
  here and elsewhere.)
---
 git/objects/__init__.py | 37 ++++++++++++++++++++++++++-----------
 1 file changed, 26 insertions(+), 11 deletions(-)

diff --git a/git/objects/__init__.py b/git/objects/__init__.py
index d636873a0..692a468d6 100644
--- a/git/objects/__init__.py
+++ b/git/objects/__init__.py
@@ -3,15 +3,30 @@
 
 """Import all submodules' main classes into the package space."""
 
-import inspect
+__all__ = [
+    "base",
+    "blob",
+    "commit",
+    "submodule",
+    "tag",
+    "tree",
+    "IndexObject",
+    "Object",
+    "Blob",
+    "Commit",
+    "Submodule",
+    "UpdateProgress",
+    "RootModule",
+    "RootUpdateProgress",
+    "TagObject",
+    "Tree",
+    "TreeModifier",
+]
 
-from .base import *  # noqa: F403
-from .blob import *  # noqa: F403
-from .commit import *  # noqa: F403
-from .submodule.base import *  # noqa: F403
-from .submodule.root import *  # noqa: F403
-from .tag import *  # noqa: F403
-from .tree import *  # noqa: F403
-
-# Must come after submodule was made available.
-__all__ = [name for name, obj in locals().items() if not (name.startswith("_") or inspect.ismodule(obj))]
+from .base import IndexObject, Object
+from .blob import Blob
+from .commit import Commit
+from .submodule.base import Submodule, UpdateProgress
+from .submodule.root import RootModule, RootUpdateProgress
+from .tag import TagObject
+from .tree import Tree, TreeModifier

From 3786307f965d38cde9e251d191f71ff26903935d Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 19:10:17 -0400
Subject: [PATCH 015/332] Make git.objects.util module docstring more specific

So git.objects.util is less likely to be confused with git.util.

(The modattrs.py script reveals the change in the value of
git.objects.util.__doc__, as expected.)
---
 git/objects/util.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/objects/util.py b/git/objects/util.py
index 9491b067c..08aef132a 100644
--- a/git/objects/util.py
+++ b/git/objects/util.py
@@ -3,7 +3,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-"""General utility functions."""
+"""General utility functions for working with git objects."""
 
 __all__ = (
     "get_object_type_by_name",

From de540b78caa71c9f1a0f098c0d1608287c8927ac Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 19:17:48 -0400
Subject: [PATCH 016/332] Add __all__ and imports in git.objects.submodule

This is the top-level of the git.objects.submodule subpackage, for
its own Python submodules.

This appears only not to have been done before because of a cyclic
import problem involving imports that are no longer present. Doing
it improves consistency, since all the other subpackages under the
top-level git package already do this.

The modattrs.py script reveals the four new attributes of
git.objects.submodule for the four classes obtained by the new
imports, as expected. (The explicit module imports do not change
the attribues because they are the same attributes as come into
existence due to those Python submodules being imported anywhere in
any style.)
---
 git/objects/__init__.py           |  3 +--
 git/objects/submodule/__init__.py | 15 +++++++++++++--
 2 files changed, 14 insertions(+), 4 deletions(-)

diff --git a/git/objects/__init__.py b/git/objects/__init__.py
index 692a468d6..4f18c8754 100644
--- a/git/objects/__init__.py
+++ b/git/objects/__init__.py
@@ -26,7 +26,6 @@
 from .base import IndexObject, Object
 from .blob import Blob
 from .commit import Commit
-from .submodule.base import Submodule, UpdateProgress
-from .submodule.root import RootModule, RootUpdateProgress
+from .submodule import RootModule, RootUpdateProgress, Submodule, UpdateProgress
 from .tag import TagObject
 from .tree import Tree, TreeModifier
diff --git a/git/objects/submodule/__init__.py b/git/objects/submodule/__init__.py
index b11b568f2..383439545 100644
--- a/git/objects/submodule/__init__.py
+++ b/git/objects/submodule/__init__.py
@@ -1,5 +1,16 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-# NOTE: Cannot import anything here as the top-level __init__ has to handle
-# our dependencies.
+__all__ = [
+    "base",
+    "root",
+    "util",
+    "Submodule",
+    "UpdateProgress",
+    "RootModule",
+    "RootUpdateProgress",
+]
+
+from . import base, root, util
+from .base import Submodule, UpdateProgress
+from .root import RootModule, RootUpdateProgress

From a05597a1aeacec0c18772944208ab2757b0db34b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 19:48:07 -0400
Subject: [PATCH 017/332] Improve how imports and __all__ are written in
 git.util

+ Update the detailed comment about the unused import situation.
---
 git/util.py | 102 +++++++++++++++++++++++++++-------------------------
 1 file changed, 53 insertions(+), 49 deletions(-)

diff --git a/git/util.py b/git/util.py
index 37986edaa..cf5949693 100644
--- a/git/util.py
+++ b/git/util.py
@@ -3,6 +3,32 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+import sys
+
+__all__ = [
+    "stream_copy",
+    "join_path",
+    "to_native_path_linux",
+    "join_path_native",
+    "Stats",
+    "IndexFileSHA1Writer",
+    "IterableObj",
+    "IterableList",
+    "BlockingLockFile",
+    "LockFile",
+    "Actor",
+    "get_user_id",
+    "assure_directory_exists",
+    "RemoteProgress",
+    "CallableRemoteProgress",
+    "rmtree",
+    "unbare_repo",
+    "HIDE_WINDOWS_KNOWN_ERRORS",
+]
+
+if sys.platform == "win32":
+    __all__.append("to_native_path_windows")
+
 from abc import abstractmethod
 import contextlib
 from functools import wraps
@@ -16,11 +42,27 @@
 import shutil
 import stat
 import subprocess
-import sys
 import time
 from urllib.parse import urlsplit, urlunsplit
 import warnings
 
+# NOTE: Unused imports can be improved now that CI testing has fully resumed. Some of
+# these be used indirectly through other GitPython modules, which avoids having to write
+# gitdb all the time in their imports. They are not in __all__, at least currently,
+# because they could be removed or changed at any time, and so should not be considered
+# conceptually public to code outside GitPython. Linters of course do not like it.
+from gitdb.util import (  # noqa: F401  # @IgnorePep8
+    LazyMixin,  # @UnusedImport
+    LockedFD,  # @UnusedImport
+    bin_to_hex,  # @UnusedImport
+    file_contents_ro_filepath,  # @UnusedImport
+    file_contents_ro,  # @UnusedImport
+    hex_to_bin,  # @UnusedImport
+    make_sha,
+    to_bin_sha,  # @UnusedImport
+    to_hex_sha,  # @UnusedImport
+)
+
 # typing ---------------------------------------------------------
 
 from typing import (
@@ -37,73 +79,36 @@
     Pattern,
     Sequence,
     Tuple,
+    TYPE_CHECKING,
     TypeVar,
     Union,
-    TYPE_CHECKING,
     cast,
     overload,
 )
 
 if TYPE_CHECKING:
+    from git.cmd import Git
+    from git.config import GitConfigParser, SectionConstraint
     from git.remote import Remote
     from git.repo.base import Repo
-    from git.config import GitConfigParser, SectionConstraint
-    from git import Git
 
-from .types import (
+from git.types import (
+    Files_TD,
+    Has_id_attribute,
+    HSH_TD,
     Literal,
-    SupportsIndex,
-    Protocol,
-    runtime_checkable,  # because behind py version guards
     PathLike,
-    HSH_TD,
+    Protocol,
+    SupportsIndex,
     Total_TD,
-    Files_TD,  # aliases
-    Has_id_attribute,
+    runtime_checkable,
 )
 
 # ---------------------------------------------------------------------
 
-from gitdb.util import (  # noqa: F401  # @IgnorePep8
-    make_sha,
-    LockedFD,  # @UnusedImport
-    file_contents_ro,  # @UnusedImport
-    file_contents_ro_filepath,  # @UnusedImport
-    LazyMixin,  # @UnusedImport
-    to_hex_sha,  # @UnusedImport
-    to_bin_sha,  # @UnusedImport
-    bin_to_hex,  # @UnusedImport
-    hex_to_bin,  # @UnusedImport
-)
-
 T_IterableObj = TypeVar("T_IterableObj", bound=Union["IterableObj", "Has_id_attribute"], covariant=True)
 # So IterableList[Head] is subtype of IterableList[IterableObj].
 
-# NOTE:  Some of the unused imports might be used/imported by others.
-# Handle once test-cases are back up and running.
-# Most of these are unused here, but are for use by git-python modules so these
-# don't see gitdb all the time. Flake of course doesn't like it.
-__all__ = [
-    "stream_copy",
-    "join_path",
-    "to_native_path_linux",
-    "join_path_native",
-    "Stats",
-    "IndexFileSHA1Writer",
-    "IterableObj",
-    "IterableList",
-    "BlockingLockFile",
-    "LockFile",
-    "Actor",
-    "get_user_id",
-    "assure_directory_exists",
-    "RemoteProgress",
-    "CallableRemoteProgress",
-    "rmtree",
-    "unbare_repo",
-    "HIDE_WINDOWS_KNOWN_ERRORS",
-]
-
 _logger = logging.getLogger(__name__)
 
 
@@ -292,7 +297,6 @@ def to_native_path_linux(path: PathLike) -> str:
         path = str(path)
         return path.replace("\\", "/")
 
-    __all__.append("to_native_path_windows")
     to_native_path = to_native_path_windows
 else:
     # No need for any work on Linux.

From 2053a3d66c1667f3b130347e9984779f94875719 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 19:58:34 -0400
Subject: [PATCH 018/332] Remove old commented-out change_type assertions in
 git.diff

---
 git/diff.py | 6 ------
 1 file changed, 6 deletions(-)

diff --git a/git/diff.py b/git/diff.py
index a6322ff57..54a5cf9af 100644
--- a/git/diff.py
+++ b/git/diff.py
@@ -40,11 +40,6 @@
 
 Lit_change_type = Literal["A", "D", "C", "M", "R", "T", "U"]
 
-
-# def is_change_type(inp: str) -> TypeGuard[Lit_change_type]:
-#     # return True
-#     return inp in ['A', 'D', 'C', 'M', 'R', 'T', 'U']
-
 # ------------------------------------------------------------------------
 
 
@@ -693,7 +688,6 @@ def _handle_diff_line(lines_bytes: bytes, repo: "Repo", index: DiffIndex) -> Non
             # Change type can be R100
             # R: status letter
             # 100: score (in case of copy and rename)
-            # assert is_change_type(_change_type[0]), f"Unexpected value for change_type received: {_change_type[0]}"
             change_type: Lit_change_type = cast(Lit_change_type, _change_type[0])
             score_str = "".join(_change_type[1:])
             score = int(score_str) if score_str.isdigit() else None

From b8bab43db59c8a110d11dd16e24d61adf65c18fd Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 20:02:55 -0400
Subject: [PATCH 019/332] Remove old commented-out flagKeyLiteral assertions in
 git.remote

---
 git/remote.py | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/git/remote.py b/git/remote.py
index 2c452022e..65bafc0e9 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -50,10 +50,6 @@
 
 flagKeyLiteral = Literal[" ", "!", "+", "-", "*", "=", "t", "?"]
 
-# def is_flagKeyLiteral(inp: str) -> TypeGuard[flagKeyLiteral]:
-#     return inp in [' ', '!', '+', '-', '=', '*', 't', '?']
-
-
 # -------------------------------------------------------------
 
 _logger = logging.getLogger(__name__)
@@ -415,7 +411,6 @@ def _from_line(cls, repo: "Repo", line: str, fetch_line: str) -> "FetchInfo":
             remote_local_ref_str,
             note,
         ) = match.groups()
-        # assert is_flagKeyLiteral(control_character), f"{control_character}"
         control_character = cast(flagKeyLiteral, control_character)
         try:
             _new_hex_sha, _fetch_operation, fetch_note = fetch_line.split("\t")

From 3d4e47623ef7dc76883abfd5d44fcaef92e7bd1c Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 20:07:17 -0400
Subject: [PATCH 020/332] Improve how second-level imports and __all__ are
 written

These are in the modules that are directly under the top-level git
module (and are not themselves subpackages, with their own
submodules in the Python sense), except for:

- git.util, where this change was very recently made.

- git.compat, where no improvements of this kind were needed.

- git.types, which currently has no __all__ and will only benefit from
  it if what should go in it is carefully considered (and where the
  imports themselves are grouped, sorted, and formatted already).
---
 git/cmd.py    | 15 +++++++--------
 git/config.py |  7 ++++---
 git/db.py     | 13 ++++++-------
 git/diff.py   | 24 +++++++++++-------------
 git/exc.py    |  4 +++-
 git/remote.py |  4 ++--
 6 files changed, 33 insertions(+), 34 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 2862b1600..7459fae97 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -5,18 +5,20 @@
 
 from __future__ import annotations
 
-import re
+__all__ = ("Git",)
+
 import contextlib
 import io
 import itertools
 import logging
 import os
+import re
 import signal
-from subprocess import Popen, PIPE, DEVNULL
 import subprocess
+from subprocess import DEVNULL, PIPE, Popen
 import sys
-import threading
 from textwrap import dedent
+import threading
 
 from git.compat import defenc, force_bytes, safe_decode
 from git.exc import (
@@ -57,12 +59,11 @@
     overload,
 )
 
-from git.types import PathLike, Literal, TBD
+from git.types import Literal, PathLike, TBD
 
 if TYPE_CHECKING:
-    from git.repo.base import Repo
     from git.diff import DiffIndex
-
+    from git.repo.base import Repo
 
 # ---------------------------------------------------------------------------------
 
@@ -84,8 +85,6 @@
 
 _logger = logging.getLogger(__name__)
 
-__all__ = ("Git",)
-
 
 # ==============================================================================
 ## @name Utilities
diff --git a/git/config.py b/git/config.py
index f74d290cc..1c9761c1b 100644
--- a/git/config.py
+++ b/git/config.py
@@ -5,6 +5,8 @@
 
 """Parser for reading and writing configuration files."""
 
+__all__ = ("GitConfigParser", "SectionConstraint")
+
 import abc
 import configparser as cp
 import fnmatch
@@ -40,9 +42,10 @@
 from git.types import Lit_config_levels, ConfigLevels_Tup, PathLike, assert_never, _T
 
 if TYPE_CHECKING:
-    from git.repo.base import Repo
     from io import BytesIO
 
+    from git.repo.base import Repo
+
 T_ConfigParser = TypeVar("T_ConfigParser", bound="GitConfigParser")
 T_OMD_value = TypeVar("T_OMD_value", str, bytes, int, float, bool)
 
@@ -58,8 +61,6 @@
 
 # -------------------------------------------------------------
 
-__all__ = ("GitConfigParser", "SectionConstraint")
-
 _logger = logging.getLogger(__name__)
 
 CONFIG_LEVELS: ConfigLevels_Tup = ("system", "user", "global", "repository")
diff --git a/git/db.py b/git/db.py
index 5b2ca4de2..302192bdf 100644
--- a/git/db.py
+++ b/git/db.py
@@ -3,27 +3,26 @@
 
 """Module with our own gitdb implementation - it uses the git command."""
 
-from git.util import bin_to_hex, hex_to_bin
-from gitdb.base import OInfo, OStream
-from gitdb.db import GitDB
-from gitdb.db import LooseObjectDB
+__all__ = ("GitCmdObjectDB", "GitDB")
 
+from gitdb.base import OInfo, OStream
+from gitdb.db import GitDB, LooseObjectDB
 from gitdb.exc import BadObject
+
+from git.util import bin_to_hex, hex_to_bin
 from git.exc import GitCommandError
 
 # typing-------------------------------------------------
 
 from typing import TYPE_CHECKING
+
 from git.types import PathLike
 
 if TYPE_CHECKING:
     from git.cmd import Git
 
-
 # --------------------------------------------------------
 
-__all__ = ("GitCmdObjectDB", "GitDB")
-
 
 class GitCmdObjectDB(LooseObjectDB):
     """A database representing the default git object store, which includes loose
diff --git a/git/diff.py b/git/diff.py
index 54a5cf9af..1381deca0 100644
--- a/git/diff.py
+++ b/git/diff.py
@@ -3,17 +3,17 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = ("DiffConstants", "NULL_TREE", "INDEX", "Diffable", "DiffIndex", "Diff")
+
 import enum
 import re
 
 from git.cmd import handle_process_output
 from git.compat import defenc
+from git.objects.blob import Blob
+from git.objects.util import mode_str_to_int
 from git.util import finalize_process, hex_to_bin
 
-from .objects.blob import Blob
-from .objects.util import mode_str_to_int
-
-
 # typing ------------------------------------------------------------------
 
 from typing import (
@@ -23,29 +23,27 @@
     Match,
     Optional,
     Tuple,
+    TYPE_CHECKING,
     TypeVar,
     Union,
-    TYPE_CHECKING,
     cast,
 )
 from git.types import Literal, PathLike
 
 if TYPE_CHECKING:
-    from .objects.tree import Tree
-    from .objects import Commit
-    from git.repo.base import Repo
-    from git.objects.base import IndexObject
     from subprocess import Popen
-    from git import Git
+
+    from git.cmd import Git
+    from git.objects.base import IndexObject
+    from git.objects.commit import Commit
+    from git.objects.tree import Tree
+    from git.repo.base import Repo
 
 Lit_change_type = Literal["A", "D", "C", "M", "R", "T", "U"]
 
 # ------------------------------------------------------------------------
 
 
-__all__ = ("DiffConstants", "NULL_TREE", "INDEX", "Diffable", "DiffIndex", "Diff")
-
-
 @enum.unique
 class DiffConstants(enum.Enum):
     """Special objects for :meth:`Diffable.diff`.
diff --git a/git/exc.py b/git/exc.py
index 9f6462b39..583eee8c1 100644
--- a/git/exc.py
+++ b/git/exc.py
@@ -42,12 +42,14 @@
     ParseError,
     UnsupportedOperation,
 )
+
 from git.compat import safe_decode
 from git.util import remove_password_if_present
 
 # typing ----------------------------------------------------
 
-from typing import List, Sequence, Tuple, Union, TYPE_CHECKING
+from typing import List, Sequence, Tuple, TYPE_CHECKING, Union
+
 from git.types import PathLike
 
 if TYPE_CHECKING:
diff --git a/git/remote.py b/git/remote.py
index 65bafc0e9..54a5c459c 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -5,6 +5,8 @@
 
 """Module implementing a remote object allowing easy access to git remotes."""
 
+__all__ = ("RemoteProgress", "PushInfo", "FetchInfo", "Remote")
+
 import contextlib
 import logging
 import re
@@ -54,8 +56,6 @@
 
 _logger = logging.getLogger(__name__)
 
-__all__ = ("RemoteProgress", "PushInfo", "FetchInfo", "Remote")
-
 # { Utilities
 
 

From 6318eea9743efec557bd7dbfdea46ddc21f654f2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 20:27:20 -0400
Subject: [PATCH 021/332] Make F401 "unused import" suppressions more specific

In git.compat and git.util.

This applies them individually to each name that is known to be an
unused import, rather than to entire import statements (except
where the entire statement fit on one line and everything it
imported is known to be unused).

Either Ruff has the ability to accept this more granular style of
suppression, or I had been unaware of it from flake8 (used before).
I have veriifed that the suppressions are not superfluous: with no
suppressions, Ruff does warn. This commit makes no change in
git.types because it looks like no suppression at all may be
needed there anymore; that will be covered in the next commit.

This also removes the old `@UnusedImport` comments, which had been
kept before because they were more granular; this specificity is
now achieved by comments the tools being used can recognize.
---
 git/compat.py | 14 +++++++-------
 git/util.py   | 18 +++++++++---------
 2 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/git/compat.py b/git/compat.py
index 6f5376c9d..4ede8c985 100644
--- a/git/compat.py
+++ b/git/compat.py
@@ -14,18 +14,18 @@
 import os
 import sys
 
-from gitdb.utils.encoding import force_bytes, force_text  # noqa: F401  # @UnusedImport
+from gitdb.utils.encoding import force_bytes, force_text  # noqa: F401
 
 # typing --------------------------------------------------------------------
 
-from typing import (  # noqa: F401
-    Any,
+from typing import (
+    Any,  # noqa: F401
     AnyStr,
-    Dict,
-    IO,
+    Dict,  # noqa: F401
+    IO,  # noqa: F401
     Optional,
-    Tuple,
-    Type,
+    Tuple,  # noqa: F401
+    Type,  # noqa: F401
     Union,
     overload,
 )
diff --git a/git/util.py b/git/util.py
index cf5949693..a56b63d69 100644
--- a/git/util.py
+++ b/git/util.py
@@ -51,16 +51,16 @@
 # gitdb all the time in their imports. They are not in __all__, at least currently,
 # because they could be removed or changed at any time, and so should not be considered
 # conceptually public to code outside GitPython. Linters of course do not like it.
-from gitdb.util import (  # noqa: F401  # @IgnorePep8
-    LazyMixin,  # @UnusedImport
-    LockedFD,  # @UnusedImport
-    bin_to_hex,  # @UnusedImport
-    file_contents_ro_filepath,  # @UnusedImport
-    file_contents_ro,  # @UnusedImport
-    hex_to_bin,  # @UnusedImport
+from gitdb.util import (
+    LazyMixin,  # noqa: F401
+    LockedFD,  # noqa: F401
+    bin_to_hex,  # noqa: F401
+    file_contents_ro_filepath,  # noqa: F401
+    file_contents_ro,  # noqa: F401
+    hex_to_bin,  # noqa: F401
     make_sha,
-    to_bin_sha,  # @UnusedImport
-    to_hex_sha,  # @UnusedImport
+    to_bin_sha,  # noqa: F401
+    to_hex_sha,  # noqa: F401
 )
 
 # typing ---------------------------------------------------------

From 31bc8a4e062841e041e9fff664bb08366d8fce48 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 20:33:33 -0400
Subject: [PATCH 022/332] Remove unneeded F401 "Unused import" suppressions

In git.types.

It appears Ruff, unlike flake8, recognizes "import X as X" to mean
that "X" should not be considered unused, even when it appears
outside a .pyi type stub file where that notation is more commonly
used.

Those imports in git.types may benefit from being changed in some
way that uses a syntax whose intent is clearer in context, and
depending on how that is done it may even be necessary to bring
back suppressions. If so, they can be written more specifically.
(For now, changing them would express more than is known about what
names that are only present in git.types becuase it imports them
should continue to be imported, should be considered conceptually
public, or should be condered suitable for use within GitPython.)
---
 git/types.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/git/types.py b/git/types.py
index e3ae9e3d5..230422dff 100644
--- a/git/types.py
+++ b/git/types.py
@@ -3,7 +3,7 @@
 
 import os
 import sys
-from typing import (  # noqa: F401
+from typing import (
     Any,
     Callable,
     Dict,
@@ -17,7 +17,7 @@
 )
 
 if sys.version_info >= (3, 8):
-    from typing import (  # noqa: F401
+    from typing import (
         Literal,
         Protocol,
         SupportsIndex as SupportsIndex,
@@ -25,7 +25,7 @@
         runtime_checkable,
     )
 else:
-    from typing_extensions import (  # noqa: F401
+    from typing_extensions import (
         Literal,
         Protocol,
         SupportsIndex as SupportsIndex,

From abbe74d030a647664f646a8d43e071608593b6a3 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 20:40:13 -0400
Subject: [PATCH 023/332] Fix a tiny import sorting nit

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

diff --git a/git/util.py b/git/util.py
index a56b63d69..5839f9720 100644
--- a/git/util.py
+++ b/git/util.py
@@ -55,8 +55,8 @@
     LazyMixin,  # noqa: F401
     LockedFD,  # noqa: F401
     bin_to_hex,  # noqa: F401
-    file_contents_ro_filepath,  # noqa: F401
     file_contents_ro,  # noqa: F401
+    file_contents_ro_filepath,  # noqa: F401
     hex_to_bin,  # noqa: F401
     make_sha,
     to_bin_sha,  # noqa: F401

From 774525087bcc0389bf585b88ee38eabf69933183 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 22:09:52 -0400
Subject: [PATCH 024/332] Replace wildcard imports in top-level git module

- Use explicit imports instead of * imports.
- Remove now-unneeded linter suppressions.
- Alphabetize inside the try-block, though this will be undone.

This currently fails to import due to a cyclic import error, so the
third change, alphabetizing the imports, will have to be undone (at
least in the absence of other changes). It is not merely that they
should not be reordered in a way that makes them cross into or out
of the try-block, but that within the try block not all orders will
work.

There will be more to do for backward compatibility, but the
modattrs.py script, which imports the top-level git module, cannot
be run until the cyclic import problem is fixed.
---
 git/__init__.py | 95 ++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 78 insertions(+), 17 deletions(-)

diff --git a/git/__init__.py b/git/__init__.py
index ca5bed7a3..ebcaf89a2 100644
--- a/git/__init__.py
+++ b/git/__init__.py
@@ -5,7 +5,7 @@
 
 # @PydevCodeAnalysisIgnore
 
-__all__ = [  # noqa: F405
+__all__ = [
     "Actor",
     "AmbiguousObjectName",
     "BadName",
@@ -88,32 +88,93 @@
 
 __version__ = "git"
 
-from typing import List, Optional, Sequence, Tuple, Union, TYPE_CHECKING
+from typing import List, Optional, Sequence, TYPE_CHECKING, Tuple, Union
 
 from gitdb.util import to_hex_sha
-from git.exc import *  # noqa: F403  # @NoMove @IgnorePep8
+
+from git.exc import (
+    AmbiguousObjectName,
+    BadName,
+    BadObject,
+    BadObjectType,
+    CacheError,
+    CheckoutError,
+    CommandError,
+    GitCommandError,
+    GitCommandNotFound,
+    GitError,
+    HookExecutionError,
+    InvalidDBRoot,
+    InvalidGitRepositoryError,
+    NoSuchPathError,
+    ODBError,
+    ParseError,
+    RepositoryDirtyError,
+    UnmergedEntriesError,
+    UnsafeOptionError,
+    UnsafeProtocolError,
+    UnsupportedOperation,
+    WorkTreeRepositoryUnsupported,
+)
 from git.types import PathLike
 
 try:
-    from git.compat import safe_decode  # @NoMove @IgnorePep8
-    from git.config import GitConfigParser  # @NoMove @IgnorePep8
-    from git.objects import *  # noqa: F403  # @NoMove @IgnorePep8
-    from git.refs import *  # noqa: F403  # @NoMove @IgnorePep8
-    from git.diff import *  # noqa: F403  # @NoMove @IgnorePep8
-    from git.db import *  # noqa: F403  # @NoMove @IgnorePep8
-    from git.cmd import Git  # @NoMove @IgnorePep8
-    from git.repo import Repo  # @NoMove @IgnorePep8
-    from git.remote import *  # noqa: F403  # @NoMove @IgnorePep8
-    from git.index import *  # noqa: F403  # @NoMove @IgnorePep8
-    from git.util import (  # @NoMove @IgnorePep8
-        LockFile,
+    from git.cmd import Git  # @NoMove
+    from git.compat import safe_decode  # @NoMove
+    from git.config import GitConfigParser  # @NoMove
+    from git.db import GitCmdObjectDB, GitDB  # @NoMove
+    from git.diff import (  # @NoMove
+        INDEX,
+        NULL_TREE,
+        Diff,
+        DiffConstants,
+        DiffIndex,
+        Diffable,
+    )
+    from git.index import (  # @NoMove
+        BaseIndexEntry,
+        BlobFilter,
+        CheckoutError,
+        IndexEntry,
+        IndexFile,
+        StageType,
+        util,  # noqa: F401  # For backward compatibility.
+    )
+    from git.objects import (  # @NoMove
+        Blob,
+        Commit,
+        IndexObject,
+        Object,
+        RootModule,
+        RootUpdateProgress,
+        Submodule,
+        TagObject,
+        Tree,
+        TreeModifier,
+        UpdateProgress,
+    )
+    from git.refs import (  # @NoMove
+        HEAD,
+        Head,
+        RefLog,
+        RefLogEntry,
+        Reference,
+        RemoteReference,
+        SymbolicReference,
+        Tag,
+        TagReference,
+    )
+    from git.remote import FetchInfo, PushInfo, Remote, RemoteProgress  # @NoMove
+    from git.repo import Repo  # @NoMove
+    from git.util import (  # @NoMove
+        Actor,
         BlockingLockFile,
+        LockFile,
         Stats,
-        Actor,
         remove_password_if_present,
         rmtree,
     )
-except GitError as _exc:  # noqa: F405
+except GitError as _exc:
     raise ImportError("%s: %s" % (_exc.__class__.__name__, _exc)) from _exc
 
 # { Initialize git executable path

From 64c9efdad216954b19cb91a4c04ea2de9d598159 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 22:13:45 -0400
Subject: [PATCH 025/332] Restore relative order to fix circular import error

This still uses all explicit rather than wildcard imports (and
still omits suppressions that are no longer needed due to wildcard
imports not being used). But it brings back the old relative order
of the `from ... import ...` statements inside the try-block.

Since this fixes the circular import problem, it is possible to run
the modattrs.py script to check for changes. New changes since
replacing wildcard imports, which are probably undesirable, are the
removal of these attributes pointing to indirect Python submodules
of the git module:

    base -> git.index.base
    fun -> git.index.fun
    head -> git.refs.head
    log -> git.refs.log
    reference -> git.refs.reference
    symbolic -> git.refs.symbolic
    tag -> git.refs.tag
    typ -> git.index.typ
---
 git/__init__.py | 40 ++++++++++++++++++++--------------------
 1 file changed, 20 insertions(+), 20 deletions(-)

diff --git a/git/__init__.py b/git/__init__.py
index ebcaf89a2..7e07dcf13 100644
--- a/git/__init__.py
+++ b/git/__init__.py
@@ -119,27 +119,8 @@
 from git.types import PathLike
 
 try:
-    from git.cmd import Git  # @NoMove
     from git.compat import safe_decode  # @NoMove
     from git.config import GitConfigParser  # @NoMove
-    from git.db import GitCmdObjectDB, GitDB  # @NoMove
-    from git.diff import (  # @NoMove
-        INDEX,
-        NULL_TREE,
-        Diff,
-        DiffConstants,
-        DiffIndex,
-        Diffable,
-    )
-    from git.index import (  # @NoMove
-        BaseIndexEntry,
-        BlobFilter,
-        CheckoutError,
-        IndexEntry,
-        IndexFile,
-        StageType,
-        util,  # noqa: F401  # For backward compatibility.
-    )
     from git.objects import (  # @NoMove
         Blob,
         Commit,
@@ -164,8 +145,27 @@
         Tag,
         TagReference,
     )
-    from git.remote import FetchInfo, PushInfo, Remote, RemoteProgress  # @NoMove
+    from git.diff import (  # @NoMove
+        INDEX,
+        NULL_TREE,
+        Diff,
+        DiffConstants,
+        DiffIndex,
+        Diffable,
+    )
+    from git.db import GitCmdObjectDB, GitDB  # @NoMove
+    from git.cmd import Git  # @NoMove
     from git.repo import Repo  # @NoMove
+    from git.remote import FetchInfo, PushInfo, Remote, RemoteProgress  # @NoMove
+    from git.index import (  # @NoMove
+        BaseIndexEntry,
+        BlobFilter,
+        CheckoutError,
+        IndexEntry,
+        IndexFile,
+        StageType,
+        util,  # noqa: F401  # For backward compatibility.
+    )
     from git.util import (  # @NoMove
         Actor,
         BlockingLockFile,

From 31f89a1fa9f7fea7a13ba7aa9079364c22fb0e68 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 23:17:09 -0400
Subject: [PATCH 026/332] Add the nonpublic indirect submodule aliases back for
 now

These should definitely never be used by code inside or outside of
GitPython, as they have never been public, having even been omitted
by the dynamic (and expansive) technique used to build git.__all__
in the past (which omitted modules intentionally).

However, to ease compatibility, for now they are back. This is so
that the change of making all imports explicit rather than using
wildcards does not break anything. However, code using these names
could never be relied on to work, and these should be considered
eligible for removal, at least from the perspective of code outside
GitPython.

That is for the indirect submodules whose absence as a same-named
direct submodule or attribute listed in __all__ was readily
discoverable.

The more difficult case is util. git.util is a module, git/util.py,
which is how it is treated when it appears immediately after the
"from" keyword, or as a key in sys.modules. However, the expression
`git.util`, and a `from git import util` import, instead access the
separate git.index.util module, which due to a wildcard import has
been an attribute of the top-level git module for a long time.

It may not be safe to change this, because although any code
anywhere is better off not relying on this, this situation hasn't
been (and isn't) immediately clear. To help with it somewhat, this
also includes a detailed comment over where util is imported from
git.index, explaining the situation.
---
 git/__init__.py | 21 ++++++++++++++++++++-
 1 file changed, 20 insertions(+), 1 deletion(-)

diff --git a/git/__init__.py b/git/__init__.py
index 7e07dcf13..a13030456 100644
--- a/git/__init__.py
+++ b/git/__init__.py
@@ -144,6 +144,11 @@
         SymbolicReference,
         Tag,
         TagReference,
+        head,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.head.
+        log,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.log.
+        reference,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.reference.
+        symbolic,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.symbolic.
+        tag,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.tag.
     )
     from git.diff import (  # @NoMove
         INDEX,
@@ -164,7 +169,21 @@
         IndexEntry,
         IndexFile,
         StageType,
-        util,  # noqa: F401  # For backward compatibility.
+        base,  # noqa: F401  # Nonpublic. May disappear! Use git.index.base.
+        fun,  # noqa: F401  # Nonpublic. May disappear! Use git.index.fun.
+        typ,  # noqa: F401  # Nonpublic. May disappear! Use git.index.typ.
+        #
+        # NOTE: The expression `git.util` evaluates to git.index.util, and the import
+        # `from git import util` imports git.index.util, NOT git.util. It may not be
+        # feasible to change this until the next major version, to avoid breaking code
+        # inadvertently relying on it. If git.index.util really is what you want, use or
+        # import from that name, to avoid confusion. To use the "real" git.util module,
+        # write `from git.util import ...`, or access it as `sys.modules["git.util"]`.
+        # (This differs from other historical indirect-submodule imports that are
+        # unambiguously nonpublic and are subject to immediate removal. Here, the public
+        # git.util module, even though different, makes it less discoverable that the
+        # expression `git.util` refers to a non-public attribute of the git module.)
+        util,  # noqa: F401
     )
     from git.util import (  # @NoMove
         Actor,

From 9bbbcb5e847c9f0e4c91cb75d1c93be2b9cb1f57 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 23:28:09 -0400
Subject: [PATCH 027/332] Further improve git.objects.util module docstring

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

diff --git a/git/objects/util.py b/git/objects/util.py
index 08aef132a..2cba89a9f 100644
--- a/git/objects/util.py
+++ b/git/objects/util.py
@@ -3,7 +3,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-"""General utility functions for working with git objects."""
+"""Utility functions for working with git objects."""
 
 __all__ = (
     "get_object_type_by_name",

From 00f4cbcf46426311f3cb6748912a1ea675639cf2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 23:32:06 -0400
Subject: [PATCH 028/332] Add missing submodule imports in git.objects

Since they are listed in __all__. (They are imported either way
because names are imported from them, and this caused them to be
present with the same effect.)

Though they are proably about to be removed along with the
corresponding entries in __all__.
---
 git/objects/__init__.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/git/objects/__init__.py b/git/objects/__init__.py
index 4f18c8754..d146901a1 100644
--- a/git/objects/__init__.py
+++ b/git/objects/__init__.py
@@ -23,6 +23,7 @@
     "TreeModifier",
 ]
 
+from . import base, blob, commit, submodule, tag, tree
 from .base import IndexObject, Object
 from .blob import Blob
 from .commit import Commit

From fcc741838dbffa45648f3f224c01b9cb8941adc2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 23:35:58 -0400
Subject: [PATCH 029/332] Don't explicitly list direct submodules in __all__

This is for non-toplevel __all__, as git.__all__ already did not
do this.

As noted in some of the previous commit messags that added them,
omitting them might be a bit safer in terms of the impact of bugs
bugs in code using GitPython, in that unexpected modules, some of
which have the same name as other modules within GitPython, won't
be imported due to wildcard imports from intermediate subpackages
(those that are below the top-level git package/module but collect
non-subpackage modules). Though it is hard to know, since some of
these would have been imported before, when an __all__ was not
defined at that level.

However, a separate benefit of omitting them is consistency with
git.__all__, which does not list the direct Python submodules of
the git module.

This does not affect the output of the modattrs.py script, because
the attributes exist with the same objects as their values as a
result of those Python submodules being imported (in "from" imports
and otherwise), including when importing the top-level git module.
---
 git/index/__init__.py             |  5 -----
 git/objects/__init__.py           |  7 -------
 git/objects/submodule/__init__.py | 11 +----------
 git/refs/__init__.py              |  7 -------
 git/repo/__init__.py              |  3 +--
 5 files changed, 2 insertions(+), 31 deletions(-)

diff --git a/git/index/__init__.py b/git/index/__init__.py
index 2086d67f9..ba48110fd 100644
--- a/git/index/__init__.py
+++ b/git/index/__init__.py
@@ -4,10 +4,6 @@
 """Initialize the index package."""
 
 __all__ = [
-    "base",
-    "fun",
-    "typ",
-    "util",
     "BaseIndexEntry",
     "BlobFilter",
     "CheckoutError",
@@ -16,6 +12,5 @@
     "StageType",
 ]
 
-from . import base, fun, typ, util
 from .base import CheckoutError, IndexFile
 from .typ import BaseIndexEntry, BlobFilter, IndexEntry, StageType
diff --git a/git/objects/__init__.py b/git/objects/__init__.py
index d146901a1..4447ca50d 100644
--- a/git/objects/__init__.py
+++ b/git/objects/__init__.py
@@ -4,12 +4,6 @@
 """Import all submodules' main classes into the package space."""
 
 __all__ = [
-    "base",
-    "blob",
-    "commit",
-    "submodule",
-    "tag",
-    "tree",
     "IndexObject",
     "Object",
     "Blob",
@@ -23,7 +17,6 @@
     "TreeModifier",
 ]
 
-from . import base, blob, commit, submodule, tag, tree
 from .base import IndexObject, Object
 from .blob import Blob
 from .commit import Commit
diff --git a/git/objects/submodule/__init__.py b/git/objects/submodule/__init__.py
index 383439545..c0604e76f 100644
--- a/git/objects/submodule/__init__.py
+++ b/git/objects/submodule/__init__.py
@@ -1,16 +1,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-__all__ = [
-    "base",
-    "root",
-    "util",
-    "Submodule",
-    "UpdateProgress",
-    "RootModule",
-    "RootUpdateProgress",
-]
+__all__ = ["Submodule", "UpdateProgress", "RootModule", "RootUpdateProgress"]
 
-from . import base, root, util
 from .base import Submodule, UpdateProgress
 from .root import RootModule, RootUpdateProgress
diff --git a/git/refs/__init__.py b/git/refs/__init__.py
index 04279901a..d6157e6f3 100644
--- a/git/refs/__init__.py
+++ b/git/refs/__init__.py
@@ -2,12 +2,6 @@
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
 __all__ = [
-    "head",
-    "log",
-    "reference",
-    "remote",
-    "symbolic",
-    "tag",
     "HEAD",
     "Head",
     "RefLog",
@@ -19,7 +13,6 @@
     "TagReference",
 ]
 
-from . import head, log, reference, remote, symbolic, tag
 from .head import HEAD, Head
 from .log import RefLog, RefLogEntry
 from .reference import Reference
diff --git a/git/repo/__init__.py b/git/repo/__init__.py
index 3b784b975..66319ef95 100644
--- a/git/repo/__init__.py
+++ b/git/repo/__init__.py
@@ -3,7 +3,6 @@
 
 """Initialize the repo package."""
 
-__all__ = ["base", "fun", "Repo"]
+__all__ = ["Repo"]
 
-from . import base, fun
 from .base import Repo

From 78055a8b8473a25e8f36a3846b920a698e8ba66b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 18 Mar 2024 23:52:57 -0400
Subject: [PATCH 030/332] Pick a consistent type for __all__ (for now, list)

This makes all __all__ everywhere in the git package lists. Before,
roughly half were lists and half were tuples. There are reasonable
theoretical arguments for both, and in practice all tools today
support both. Traditionally using a list is far more common, and it
remains at least somewhat more common. Furthermore, git/util.py
uses a list and is currently written to append an element to it
that is conditionally defined on Windows (though it would probably
be fine for that to be the only list, since it reflects an actual
relevant difference about it).

The goal here is just to remove inconsistency that does not signify
anything and is the result of drift over time. If a reason (even
preference) arises to make them all tuples in the future, then that
is also probably fine.
---
 git/cmd.py                    | 2 +-
 git/config.py                 | 2 +-
 git/db.py                     | 2 +-
 git/diff.py                   | 2 +-
 git/index/base.py             | 2 +-
 git/index/fun.py              | 4 ++--
 git/index/typ.py              | 2 +-
 git/index/util.py             | 2 +-
 git/objects/base.py           | 2 +-
 git/objects/blob.py           | 2 +-
 git/objects/commit.py         | 2 +-
 git/objects/fun.py            | 4 ++--
 git/objects/submodule/util.py | 4 ++--
 git/objects/tag.py            | 2 +-
 git/objects/tree.py           | 2 +-
 git/objects/util.py           | 4 ++--
 git/remote.py                 | 2 +-
 git/repo/base.py              | 2 +-
 git/repo/fun.py               | 4 ++--
 19 files changed, 24 insertions(+), 24 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 7459fae97..03e5d7ffc 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -5,7 +5,7 @@
 
 from __future__ import annotations
 
-__all__ = ("Git",)
+__all__ = ["Git"]
 
 import contextlib
 import io
diff --git a/git/config.py b/git/config.py
index 1c9761c1b..3ce9b123f 100644
--- a/git/config.py
+++ b/git/config.py
@@ -5,7 +5,7 @@
 
 """Parser for reading and writing configuration files."""
 
-__all__ = ("GitConfigParser", "SectionConstraint")
+__all__ = ["GitConfigParser", "SectionConstraint"]
 
 import abc
 import configparser as cp
diff --git a/git/db.py b/git/db.py
index 302192bdf..cacd030d0 100644
--- a/git/db.py
+++ b/git/db.py
@@ -3,7 +3,7 @@
 
 """Module with our own gitdb implementation - it uses the git command."""
 
-__all__ = ("GitCmdObjectDB", "GitDB")
+__all__ = ["GitCmdObjectDB", "GitDB"]
 
 from gitdb.base import OInfo, OStream
 from gitdb.db import GitDB, LooseObjectDB
diff --git a/git/diff.py b/git/diff.py
index 1381deca0..0e39fe7a8 100644
--- a/git/diff.py
+++ b/git/diff.py
@@ -3,7 +3,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-__all__ = ("DiffConstants", "NULL_TREE", "INDEX", "Diffable", "DiffIndex", "Diff")
+__all__ = ["DiffConstants", "NULL_TREE", "INDEX", "Diffable", "DiffIndex", "Diff"]
 
 import enum
 import re
diff --git a/git/index/base.py b/git/index/base.py
index b49841435..b8161ea52 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -6,7 +6,7 @@
 """Module containing :class:`IndexFile`, an Index implementation facilitating all kinds
 of index manipulations such as querying and merging."""
 
-__all__ = ("IndexFile", "CheckoutError", "StageType")
+__all__ = ["IndexFile", "CheckoutError", "StageType"]
 
 import contextlib
 import datetime
diff --git a/git/index/fun.py b/git/index/fun.py
index b24e803a3..59cce6ae6 100644
--- a/git/index/fun.py
+++ b/git/index/fun.py
@@ -4,7 +4,7 @@
 """Standalone functions to accompany the index implementation and make it more
 versatile."""
 
-__all__ = (
+__all__ = [
     "write_cache",
     "read_cache",
     "write_tree_from_cache",
@@ -13,7 +13,7 @@
     "S_IFGITLINK",
     "run_commit_hook",
     "hook_path",
-)
+]
 
 from io import BytesIO
 import os
diff --git a/git/index/typ.py b/git/index/typ.py
index 0fbcd69f0..974252528 100644
--- a/git/index/typ.py
+++ b/git/index/typ.py
@@ -3,7 +3,7 @@
 
 """Additional types used by the index."""
 
-__all__ = ("BlobFilter", "BaseIndexEntry", "IndexEntry", "StageType")
+__all__ = ["BlobFilter", "BaseIndexEntry", "IndexEntry", "StageType"]
 
 from binascii import b2a_hex
 from pathlib import Path
diff --git a/git/index/util.py b/git/index/util.py
index 4aee61bce..e59cb609f 100644
--- a/git/index/util.py
+++ b/git/index/util.py
@@ -3,7 +3,7 @@
 
 """Index utilities."""
 
-__all__ = ("TemporaryFileSwap", "post_clear_cache", "default_index", "git_working_dir")
+__all__ = ["TemporaryFileSwap", "post_clear_cache", "default_index", "git_working_dir"]
 
 import contextlib
 from functools import wraps
diff --git a/git/objects/base.py b/git/objects/base.py
index 07ff639e5..eeaebc09b 100644
--- a/git/objects/base.py
+++ b/git/objects/base.py
@@ -3,7 +3,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-__all__ = ("Object", "IndexObject")
+__all__ = ["Object", "IndexObject"]
 
 import os.path as osp
 
diff --git a/git/objects/blob.py b/git/objects/blob.py
index 50500f550..58de59642 100644
--- a/git/objects/blob.py
+++ b/git/objects/blob.py
@@ -3,7 +3,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-__all__ = ("Blob",)
+__all__ = ["Blob"]
 
 from mimetypes import guess_type
 import sys
diff --git a/git/objects/commit.py b/git/objects/commit.py
index b22308726..8de52980c 100644
--- a/git/objects/commit.py
+++ b/git/objects/commit.py
@@ -3,7 +3,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-__all__ = ("Commit",)
+__all__ = ["Commit"]
 
 from collections import defaultdict
 import datetime
diff --git a/git/objects/fun.py b/git/objects/fun.py
index 9bd2d8f10..fe57da13a 100644
--- a/git/objects/fun.py
+++ b/git/objects/fun.py
@@ -3,12 +3,12 @@
 
 """Functions that are supposed to be as fast as possible."""
 
-__all__ = (
+__all__ = [
     "tree_to_stream",
     "tree_entries_from_data",
     "traverse_trees_recursive",
     "traverse_tree_recursive",
-)
+]
 
 from stat import S_ISDIR
 
diff --git a/git/objects/submodule/util.py b/git/objects/submodule/util.py
index 3a09d6a17..c021510d8 100644
--- a/git/objects/submodule/util.py
+++ b/git/objects/submodule/util.py
@@ -1,13 +1,13 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-__all__ = (
+__all__ = [
     "sm_section",
     "sm_name",
     "mkhead",
     "find_first_remote_branch",
     "SubmoduleConfigParser",
-)
+]
 
 from io import BytesIO
 import weakref
diff --git a/git/objects/tag.py b/git/objects/tag.py
index 5ad311590..a3bb0b882 100644
--- a/git/objects/tag.py
+++ b/git/objects/tag.py
@@ -9,7 +9,7 @@
 For lightweight tags, see the :mod:`git.refs.tag` module.
 """
 
-__all__ = ("TagObject",)
+__all__ = ["TagObject"]
 
 import sys
 
diff --git a/git/objects/tree.py b/git/objects/tree.py
index d8320b319..09184a781 100644
--- a/git/objects/tree.py
+++ b/git/objects/tree.py
@@ -3,7 +3,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-__all__ = ("TreeModifier", "Tree")
+__all__ = ["TreeModifier", "Tree"]
 
 import sys
 
diff --git a/git/objects/util.py b/git/objects/util.py
index 2cba89a9f..5c56e6134 100644
--- a/git/objects/util.py
+++ b/git/objects/util.py
@@ -5,7 +5,7 @@
 
 """Utility functions for working with git objects."""
 
-__all__ = (
+__all__ = [
     "get_object_type_by_name",
     "parse_date",
     "parse_actor_and_date",
@@ -17,7 +17,7 @@
     "Actor",
     "tzoffset",
     "utc",
-)
+]
 
 from abc import ABC, abstractmethod
 import calendar
diff --git a/git/remote.py b/git/remote.py
index 54a5c459c..f2ecd0f36 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -5,7 +5,7 @@
 
 """Module implementing a remote object allowing easy access to git remotes."""
 
-__all__ = ("RemoteProgress", "PushInfo", "FetchInfo", "Remote")
+__all__ = ["RemoteProgress", "PushInfo", "FetchInfo", "Remote"]
 
 import contextlib
 import logging
diff --git a/git/repo/base.py b/git/repo/base.py
index 63b21fdbf..51ea76901 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -5,7 +5,7 @@
 
 from __future__ import annotations
 
-__all__ = ("Repo",)
+__all__ = ["Repo"]
 
 import gc
 import logging
diff --git a/git/repo/fun.py b/git/repo/fun.py
index 1defcb1ac..e44d9c644 100644
--- a/git/repo/fun.py
+++ b/git/repo/fun.py
@@ -5,7 +5,7 @@
 
 from __future__ import annotations
 
-__all__ = (
+__all__ = [
     "rev_parse",
     "is_git_dir",
     "touch",
@@ -15,7 +15,7 @@
     "deref_tag",
     "to_commit",
     "find_worktree_git_dir",
-)
+]
 
 import os
 import os.path as osp

From ecdb6aa25a95e2cb3a650a26f3ffd9c45e127ed1 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 19 Mar 2024 00:05:15 -0400
Subject: [PATCH 031/332] Save diff of non-__all__ attributes across import
 changes

This commits the diff of the output of the modattrs.py script
between when the script was introduced in 1e5a944, and now (with
the import refactoring finished and no wildcard imports remaining
outside the test suite, and only there in one import statement for
test helpers).

Neither this diff nor modattrs.py itself will be retained. Both
will be removed in the next commit. This is committed here to
facilitate inspection and debugging (especially if turns out there
are thus-far undetected regressions).
---
 ab.diff | 42 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)
 create mode 100644 ab.diff

diff --git a/ab.diff b/ab.diff
new file mode 100644
index 000000000..1f2885b90
--- /dev/null
+++ b/ab.diff
@@ -0,0 +1,42 @@
+diff --git a/a b/b
+index 81b3f984..099fa33b 100644
+--- a/a
++++ b/b
+@@ -634,7 +634,6 @@ git.objects:
+     blob:  <module 'git.objects.blob' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\blob.py'>
+     commit:  <module 'git.objects.commit' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\commit.py'>
+     fun:  <module 'git.objects.fun' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\fun.py'>
+-    inspect:  <module 'inspect' from 'C:\\Program Files\\WindowsApps\\PythonSoftwareFoundation.Python.3.12_3.12.752.0_x64__qbz5n2kfra8p0\\Lib\\inspect.py'>
+     submodule:  <module 'git.objects.submodule' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\submodule\\__init__.py'>
+     tag:  <module 'git.objects.tag' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\tag.py'>
+     tree:  <module 'git.objects.tree' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\tree.py'>
+@@ -770,6 +769,10 @@ git.objects.fun:
+ 
+ 
+ git.objects.submodule:
++    RootModule:  <class 'git.objects.submodule.root.RootModule'>
++    RootUpdateProgress:  <class 'git.objects.submodule.root.RootUpdateProgress'>
++    Submodule:  <class 'git.objects.submodule.base.Submodule'>
++    UpdateProgress:  <class 'git.objects.submodule.base.UpdateProgress'>
+     __builtins__:  {'__name__': 'builtins', '__doc__': "Built-in functions, types, exceptions, and other objects.\n\nThis module provides direct access to all 'built-in'\nidentifiers of Python; for example, builtins.len is\nthe full name for the built-in function len().\n\nThis module is not normally accessed explicitly by most\napplications, but can be useful in modules that provide\nobjects with the same name as a built-in value, but in\nwhich the built-in of that name is also needed.", '__package__': '', '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>, origin='built-in'), '__build_class__': <built-in function __build_class__>, '__import__': <built-in function __import__>, 'abs': <built-in function abs>, 'all': <built-in function all>, 'any': <built-in function any>, 'ascii': <built-in function ascii>, 'bin': <built-in function bin>, 'breakpoint': <built-in function breakpoint>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'compile': <built-in function compile>, 'delattr': <built-in function delattr>, 'dir': <built-in function dir>, 'divmod': <built-in function divmod>, 'eval': <built-in function eval>, 'exec': <built-in function exec>, 'format': <built-in function format>, 'getattr': <built-in function getattr>, 'globals': <built-in function globals>, 'hasattr': <built-in function hasattr>, 'hash': <built-in function hash>, 'hex': <built-in function hex>, 'id': <built-in function id>, 'input': <built-in function input>, 'isinstance': <built-in function isinstance>, 'issubclass': <built-in function issubclass>, 'iter': <built-in function iter>, 'aiter': <built-in function aiter>, 'len': <built-in function len>, 'locals': <built-in function locals>, 'max': <built-in function max>, 'min': <built-in function min>, 'next': <built-in function next>, 'anext': <built-in function anext>, 'oct': <built-in function oct>, 'ord': <built-in function ord>, 'pow': <built-in function pow>, 'print': <built-in function print>, 'repr': <built-in function repr>, 'round': <built-in function round>, 'setattr': <built-in function setattr>, 'sorted': <built-in function sorted>, 'sum': <built-in function sum>, 'vars': <built-in function vars>, 'None': None, 'Ellipsis': Ellipsis, 'NotImplemented': NotImplemented, 'False': False, 'True': True, 'bool': <class 'bool'>, 'memoryview': <class 'memoryview'>, 'bytearray': <class 'bytearray'>, 'bytes': <class 'bytes'>, 'classmethod': <class 'classmethod'>, 'complex': <class 'complex'>, 'dict': <class 'dict'>, 'enumerate': <class 'enumerate'>, 'filter': <class 'filter'>, 'float': <class 'float'>, 'frozenset': <class 'frozenset'>, 'property': <class 'property'>, 'int': <class 'int'>, 'list': <class 'list'>, 'map': <class 'map'>, 'object': <class 'object'>, 'range': <class 'range'>, 'reversed': <class 'reversed'>, 'set': <class 'set'>, 'slice': <class 'slice'>, 'staticmethod': <class 'staticmethod'>, 'str': <class 'str'>, 'super': <class 'super'>, 'tuple': <class 'tuple'>, 'type': <class 'type'>, 'zip': <class 'zip'>, '__debug__': True, 'BaseException': <class 'BaseException'>, 'BaseExceptionGroup': <class 'BaseExceptionGroup'>, 'Exception': <class 'Exception'>, 'GeneratorExit': <class 'GeneratorExit'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'SystemExit': <class 'SystemExit'>, 'ArithmeticError': <class 'ArithmeticError'>, 'AssertionError': <class 'AssertionError'>, 'AttributeError': <class 'AttributeError'>, 'BufferError': <class 'BufferError'>, 'EOFError': <class 'EOFError'>, 'ImportError': <class 'ImportError'>, 'LookupError': <class 'LookupError'>, 'MemoryError': <class 'MemoryError'>, 'NameError': <class 'NameError'>, 'OSError': <class 'OSError'>, 'ReferenceError': <class 'ReferenceError'>, 'RuntimeError': <class 'RuntimeError'>, 'StopAsyncIteration': <class 'StopAsyncIteration'>, 'StopIteration': <class 'StopIteration'>, 'SyntaxError': <class 'SyntaxError'>, 'SystemError': <class 'SystemError'>, 'TypeError': <class 'TypeError'>, 'ValueError': <class 'ValueError'>, 'Warning': <class 'Warning'>, 'FloatingPointError': <class 'FloatingPointError'>, 'OverflowError': <class 'OverflowError'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'BytesWarning': <class 'BytesWarning'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'EncodingWarning': <class 'EncodingWarning'>, 'FutureWarning': <class 'FutureWarning'>, 'ImportWarning': <class 'ImportWarning'>, 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'ResourceWarning': <class 'ResourceWarning'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'SyntaxWarning': <class 'SyntaxWarning'>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'UserWarning': <class 'UserWarning'>, 'BlockingIOError': <class 'BlockingIOError'>, 'ChildProcessError': <class 'ChildProcessError'>, 'ConnectionError': <class 'ConnectionError'>, 'FileExistsError': <class 'FileExistsError'>, 'FileNotFoundError': <class 'FileNotFoundError'>, 'InterruptedError': <class 'InterruptedError'>, 'IsADirectoryError': <class 'IsADirectoryError'>, 'NotADirectoryError': <class 'NotADirectoryError'>, 'PermissionError': <class 'PermissionError'>, 'ProcessLookupError': <class 'ProcessLookupError'>, 'TimeoutError': <class 'TimeoutError'>, 'IndentationError': <class 'IndentationError'>, 'IndexError': <class 'IndexError'>, 'KeyError': <class 'KeyError'>, 'ModuleNotFoundError': <class 'ModuleNotFoundError'>, 'NotImplementedError': <class 'NotImplementedError'>, 'RecursionError': <class 'RecursionError'>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'UnicodeError': <class 'UnicodeError'>, 'BrokenPipeError': <class 'BrokenPipeError'>, 'ConnectionAbortedError': <class 'ConnectionAbortedError'>, 'ConnectionRefusedError': <class 'ConnectionRefusedError'>, 'ConnectionResetError': <class 'ConnectionResetError'>, 'TabError': <class 'TabError'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'ExceptionGroup': <class 'ExceptionGroup'>, 'EnvironmentError': <class 'OSError'>, 'IOError': <class 'OSError'>, 'WindowsError': <class 'OSError'>, 'open': <built-in function open>, 'quit': Use quit() or Ctrl-Z plus Return to exit, 'exit': Use exit() or Ctrl-Z plus Return to exit, 'copyright': Copyright (c) 2001-2023 Python Software Foundation.?All Rights Reserved.??Copyright (c) 2000 BeOpen.com.?All Rights Reserved.??Copyright (c) 1995-2001 Corporation for National Research Initiatives.?All Rights Reserved.??Copyright (c) 1991-1995 Stichting Mathematisch Centrum, Amsterdam.?All Rights Reserved., 'credits':     Thanks to CWI, CNRI, BeOpen.com, Zope Corporation and a cast of thousands?    for supporting Python development.  See www.python.org for more information., 'license': Type license() to see the full license text, 'help': Type help() for interactive help, or help(object) for help about object.}
+     __cached__:  'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\submodule\\__pycache__\\__init__.cpython-312.pyc'
+     __doc__:  None
+@@ -881,9 +884,7 @@ git.objects.submodule.util:
+     Any:  typing.Any
+     BytesIO:  <class '_io.BytesIO'>
+     GitConfigParser:  <class 'git.config.GitConfigParser'>
+-    IndexObject:  <class 'git.objects.base.IndexObject'>
+     InvalidGitRepositoryError:  <class 'git.exc.InvalidGitRepositoryError'>
+-    Object:  <class 'git.objects.base.Object'>
+     PathLike:  typing.Union[str, ForwardRef('os.PathLike[str]')]
+     Sequence:  typing.Sequence
+     SubmoduleConfigParser:  <class 'git.objects.submodule.util.SubmoduleConfigParser'>
+@@ -998,7 +999,7 @@ git.objects.util:
+     ZERO:  datetime.timedelta(0)
+     __builtins__:  {'__name__': 'builtins', '__doc__': "Built-in functions, types, exceptions, and other objects.\n\nThis module provides direct access to all 'built-in'\nidentifiers of Python; for example, builtins.len is\nthe full name for the built-in function len().\n\nThis module is not normally accessed explicitly by most\napplications, but can be useful in modules that provide\nobjects with the same name as a built-in value, but in\nwhich the built-in of that name is also needed.", '__package__': '', '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>, origin='built-in'), '__build_class__': <built-in function __build_class__>, '__import__': <built-in function __import__>, 'abs': <built-in function abs>, 'all': <built-in function all>, 'any': <built-in function any>, 'ascii': <built-in function ascii>, 'bin': <built-in function bin>, 'breakpoint': <built-in function breakpoint>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'compile': <built-in function compile>, 'delattr': <built-in function delattr>, 'dir': <built-in function dir>, 'divmod': <built-in function divmod>, 'eval': <built-in function eval>, 'exec': <built-in function exec>, 'format': <built-in function format>, 'getattr': <built-in function getattr>, 'globals': <built-in function globals>, 'hasattr': <built-in function hasattr>, 'hash': <built-in function hash>, 'hex': <built-in function hex>, 'id': <built-in function id>, 'input': <built-in function input>, 'isinstance': <built-in function isinstance>, 'issubclass': <built-in function issubclass>, 'iter': <built-in function iter>, 'aiter': <built-in function aiter>, 'len': <built-in function len>, 'locals': <built-in function locals>, 'max': <built-in function max>, 'min': <built-in function min>, 'next': <built-in function next>, 'anext': <built-in function anext>, 'oct': <built-in function oct>, 'ord': <built-in function ord>, 'pow': <built-in function pow>, 'print': <built-in function print>, 'repr': <built-in function repr>, 'round': <built-in function round>, 'setattr': <built-in function setattr>, 'sorted': <built-in function sorted>, 'sum': <built-in function sum>, 'vars': <built-in function vars>, 'None': None, 'Ellipsis': Ellipsis, 'NotImplemented': NotImplemented, 'False': False, 'True': True, 'bool': <class 'bool'>, 'memoryview': <class 'memoryview'>, 'bytearray': <class 'bytearray'>, 'bytes': <class 'bytes'>, 'classmethod': <class 'classmethod'>, 'complex': <class 'complex'>, 'dict': <class 'dict'>, 'enumerate': <class 'enumerate'>, 'filter': <class 'filter'>, 'float': <class 'float'>, 'frozenset': <class 'frozenset'>, 'property': <class 'property'>, 'int': <class 'int'>, 'list': <class 'list'>, 'map': <class 'map'>, 'object': <class 'object'>, 'range': <class 'range'>, 'reversed': <class 'reversed'>, 'set': <class 'set'>, 'slice': <class 'slice'>, 'staticmethod': <class 'staticmethod'>, 'str': <class 'str'>, 'super': <class 'super'>, 'tuple': <class 'tuple'>, 'type': <class 'type'>, 'zip': <class 'zip'>, '__debug__': True, 'BaseException': <class 'BaseException'>, 'BaseExceptionGroup': <class 'BaseExceptionGroup'>, 'Exception': <class 'Exception'>, 'GeneratorExit': <class 'GeneratorExit'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'SystemExit': <class 'SystemExit'>, 'ArithmeticError': <class 'ArithmeticError'>, 'AssertionError': <class 'AssertionError'>, 'AttributeError': <class 'AttributeError'>, 'BufferError': <class 'BufferError'>, 'EOFError': <class 'EOFError'>, 'ImportError': <class 'ImportError'>, 'LookupError': <class 'LookupError'>, 'MemoryError': <class 'MemoryError'>, 'NameError': <class 'NameError'>, 'OSError': <class 'OSError'>, 'ReferenceError': <class 'ReferenceError'>, 'RuntimeError': <class 'RuntimeError'>, 'StopAsyncIteration': <class 'StopAsyncIteration'>, 'StopIteration': <class 'StopIteration'>, 'SyntaxError': <class 'SyntaxError'>, 'SystemError': <class 'SystemError'>, 'TypeError': <class 'TypeError'>, 'ValueError': <class 'ValueError'>, 'Warning': <class 'Warning'>, 'FloatingPointError': <class 'FloatingPointError'>, 'OverflowError': <class 'OverflowError'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'BytesWarning': <class 'BytesWarning'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'EncodingWarning': <class 'EncodingWarning'>, 'FutureWarning': <class 'FutureWarning'>, 'ImportWarning': <class 'ImportWarning'>, 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'ResourceWarning': <class 'ResourceWarning'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'SyntaxWarning': <class 'SyntaxWarning'>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'UserWarning': <class 'UserWarning'>, 'BlockingIOError': <class 'BlockingIOError'>, 'ChildProcessError': <class 'ChildProcessError'>, 'ConnectionError': <class 'ConnectionError'>, 'FileExistsError': <class 'FileExistsError'>, 'FileNotFoundError': <class 'FileNotFoundError'>, 'InterruptedError': <class 'InterruptedError'>, 'IsADirectoryError': <class 'IsADirectoryError'>, 'NotADirectoryError': <class 'NotADirectoryError'>, 'PermissionError': <class 'PermissionError'>, 'ProcessLookupError': <class 'ProcessLookupError'>, 'TimeoutError': <class 'TimeoutError'>, 'IndentationError': <class 'IndentationError'>, 'IndexError': <class 'IndexError'>, 'KeyError': <class 'KeyError'>, 'ModuleNotFoundError': <class 'ModuleNotFoundError'>, 'NotImplementedError': <class 'NotImplementedError'>, 'RecursionError': <class 'RecursionError'>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'UnicodeError': <class 'UnicodeError'>, 'BrokenPipeError': <class 'BrokenPipeError'>, 'ConnectionAbortedError': <class 'ConnectionAbortedError'>, 'ConnectionRefusedError': <class 'ConnectionRefusedError'>, 'ConnectionResetError': <class 'ConnectionResetError'>, 'TabError': <class 'TabError'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'ExceptionGroup': <class 'ExceptionGroup'>, 'EnvironmentError': <class 'OSError'>, 'IOError': <class 'OSError'>, 'WindowsError': <class 'OSError'>, 'open': <built-in function open>, 'quit': Use quit() or Ctrl-Z plus Return to exit, 'exit': Use exit() or Ctrl-Z plus Return to exit, 'copyright': Copyright (c) 2001-2023 Python Software Foundation.?All Rights Reserved.??Copyright (c) 2000 BeOpen.com.?All Rights Reserved.??Copyright (c) 1995-2001 Corporation for National Research Initiatives.?All Rights Reserved.??Copyright (c) 1991-1995 Stichting Mathematisch Centrum, Amsterdam.?All Rights Reserved., 'credits':     Thanks to CWI, CNRI, BeOpen.com, Zope Corporation and a cast of thousands?    for supporting Python development.  See www.python.org for more information., 'license': Type license() to see the full license text, 'help': Type help() for interactive help, or help(object) for help about object.}
+     __cached__:  'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\__pycache__\\util.cpython-312.pyc'
+-    __doc__:  'General utility functions.'
++    __doc__:  'Utility functions for working with git objects.'
+     __file__:  'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\util.py'
+     __loader__:  <_frozen_importlib_external.SourceFileLoader object at 0x...>
+     __name__:  'git.objects.util'

From f705fd6dde6047c7787809aeb691988fa4af80d8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 19 Mar 2024 00:15:50 -0400
Subject: [PATCH 032/332] Remove modattrs.py and related

Now that it has served its purpose. (Of course, it can also be
brought back from the history at any time if needed.)

Changes:

- Delete the modattrs.py script that was used to check for
  unintended changes to what module attributes existed and what
  objects they referred to, while doing the import refactoring.

- Delete the ab.diff file showing the overall diff, which was
  temporarily introduced in the previous commit.

- Remove the "a" and "b" temporary entries in .gitignore that were
  used to facilitate efficient use of modattrs.py while carrying
  out the import refactoring.
---
 .gitignore  |  4 ----
 ab.diff     | 42 ------------------------------------------
 modattrs.py | 53 -----------------------------------------------------
 3 files changed, 99 deletions(-)
 delete mode 100644 ab.diff
 delete mode 100755 modattrs.py

diff --git a/.gitignore b/.gitignore
index 1be4f3201..7765293d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,7 +47,3 @@ output.txt
 
 # Finder metadata
 .DS_Store
-
-# Output files for modattrs.py (these entries will be removed soon)
-a
-b
diff --git a/ab.diff b/ab.diff
deleted file mode 100644
index 1f2885b90..000000000
--- a/ab.diff
+++ /dev/null
@@ -1,42 +0,0 @@
-diff --git a/a b/b
-index 81b3f984..099fa33b 100644
---- a/a
-+++ b/b
-@@ -634,7 +634,6 @@ git.objects:
-     blob:  <module 'git.objects.blob' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\blob.py'>
-     commit:  <module 'git.objects.commit' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\commit.py'>
-     fun:  <module 'git.objects.fun' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\fun.py'>
--    inspect:  <module 'inspect' from 'C:\\Program Files\\WindowsApps\\PythonSoftwareFoundation.Python.3.12_3.12.752.0_x64__qbz5n2kfra8p0\\Lib\\inspect.py'>
-     submodule:  <module 'git.objects.submodule' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\submodule\\__init__.py'>
-     tag:  <module 'git.objects.tag' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\tag.py'>
-     tree:  <module 'git.objects.tree' from 'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\tree.py'>
-@@ -770,6 +769,10 @@ git.objects.fun:
- 
- 
- git.objects.submodule:
-+    RootModule:  <class 'git.objects.submodule.root.RootModule'>
-+    RootUpdateProgress:  <class 'git.objects.submodule.root.RootUpdateProgress'>
-+    Submodule:  <class 'git.objects.submodule.base.Submodule'>
-+    UpdateProgress:  <class 'git.objects.submodule.base.UpdateProgress'>
-     __builtins__:  {'__name__': 'builtins', '__doc__': "Built-in functions, types, exceptions, and other objects.\n\nThis module provides direct access to all 'built-in'\nidentifiers of Python; for example, builtins.len is\nthe full name for the built-in function len().\n\nThis module is not normally accessed explicitly by most\napplications, but can be useful in modules that provide\nobjects with the same name as a built-in value, but in\nwhich the built-in of that name is also needed.", '__package__': '', '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>, origin='built-in'), '__build_class__': <built-in function __build_class__>, '__import__': <built-in function __import__>, 'abs': <built-in function abs>, 'all': <built-in function all>, 'any': <built-in function any>, 'ascii': <built-in function ascii>, 'bin': <built-in function bin>, 'breakpoint': <built-in function breakpoint>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'compile': <built-in function compile>, 'delattr': <built-in function delattr>, 'dir': <built-in function dir>, 'divmod': <built-in function divmod>, 'eval': <built-in function eval>, 'exec': <built-in function exec>, 'format': <built-in function format>, 'getattr': <built-in function getattr>, 'globals': <built-in function globals>, 'hasattr': <built-in function hasattr>, 'hash': <built-in function hash>, 'hex': <built-in function hex>, 'id': <built-in function id>, 'input': <built-in function input>, 'isinstance': <built-in function isinstance>, 'issubclass': <built-in function issubclass>, 'iter': <built-in function iter>, 'aiter': <built-in function aiter>, 'len': <built-in function len>, 'locals': <built-in function locals>, 'max': <built-in function max>, 'min': <built-in function min>, 'next': <built-in function next>, 'anext': <built-in function anext>, 'oct': <built-in function oct>, 'ord': <built-in function ord>, 'pow': <built-in function pow>, 'print': <built-in function print>, 'repr': <built-in function repr>, 'round': <built-in function round>, 'setattr': <built-in function setattr>, 'sorted': <built-in function sorted>, 'sum': <built-in function sum>, 'vars': <built-in function vars>, 'None': None, 'Ellipsis': Ellipsis, 'NotImplemented': NotImplemented, 'False': False, 'True': True, 'bool': <class 'bool'>, 'memoryview': <class 'memoryview'>, 'bytearray': <class 'bytearray'>, 'bytes': <class 'bytes'>, 'classmethod': <class 'classmethod'>, 'complex': <class 'complex'>, 'dict': <class 'dict'>, 'enumerate': <class 'enumerate'>, 'filter': <class 'filter'>, 'float': <class 'float'>, 'frozenset': <class 'frozenset'>, 'property': <class 'property'>, 'int': <class 'int'>, 'list': <class 'list'>, 'map': <class 'map'>, 'object': <class 'object'>, 'range': <class 'range'>, 'reversed': <class 'reversed'>, 'set': <class 'set'>, 'slice': <class 'slice'>, 'staticmethod': <class 'staticmethod'>, 'str': <class 'str'>, 'super': <class 'super'>, 'tuple': <class 'tuple'>, 'type': <class 'type'>, 'zip': <class 'zip'>, '__debug__': True, 'BaseException': <class 'BaseException'>, 'BaseExceptionGroup': <class 'BaseExceptionGroup'>, 'Exception': <class 'Exception'>, 'GeneratorExit': <class 'GeneratorExit'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'SystemExit': <class 'SystemExit'>, 'ArithmeticError': <class 'ArithmeticError'>, 'AssertionError': <class 'AssertionError'>, 'AttributeError': <class 'AttributeError'>, 'BufferError': <class 'BufferError'>, 'EOFError': <class 'EOFError'>, 'ImportError': <class 'ImportError'>, 'LookupError': <class 'LookupError'>, 'MemoryError': <class 'MemoryError'>, 'NameError': <class 'NameError'>, 'OSError': <class 'OSError'>, 'ReferenceError': <class 'ReferenceError'>, 'RuntimeError': <class 'RuntimeError'>, 'StopAsyncIteration': <class 'StopAsyncIteration'>, 'StopIteration': <class 'StopIteration'>, 'SyntaxError': <class 'SyntaxError'>, 'SystemError': <class 'SystemError'>, 'TypeError': <class 'TypeError'>, 'ValueError': <class 'ValueError'>, 'Warning': <class 'Warning'>, 'FloatingPointError': <class 'FloatingPointError'>, 'OverflowError': <class 'OverflowError'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'BytesWarning': <class 'BytesWarning'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'EncodingWarning': <class 'EncodingWarning'>, 'FutureWarning': <class 'FutureWarning'>, 'ImportWarning': <class 'ImportWarning'>, 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'ResourceWarning': <class 'ResourceWarning'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'SyntaxWarning': <class 'SyntaxWarning'>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'UserWarning': <class 'UserWarning'>, 'BlockingIOError': <class 'BlockingIOError'>, 'ChildProcessError': <class 'ChildProcessError'>, 'ConnectionError': <class 'ConnectionError'>, 'FileExistsError': <class 'FileExistsError'>, 'FileNotFoundError': <class 'FileNotFoundError'>, 'InterruptedError': <class 'InterruptedError'>, 'IsADirectoryError': <class 'IsADirectoryError'>, 'NotADirectoryError': <class 'NotADirectoryError'>, 'PermissionError': <class 'PermissionError'>, 'ProcessLookupError': <class 'ProcessLookupError'>, 'TimeoutError': <class 'TimeoutError'>, 'IndentationError': <class 'IndentationError'>, 'IndexError': <class 'IndexError'>, 'KeyError': <class 'KeyError'>, 'ModuleNotFoundError': <class 'ModuleNotFoundError'>, 'NotImplementedError': <class 'NotImplementedError'>, 'RecursionError': <class 'RecursionError'>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'UnicodeError': <class 'UnicodeError'>, 'BrokenPipeError': <class 'BrokenPipeError'>, 'ConnectionAbortedError': <class 'ConnectionAbortedError'>, 'ConnectionRefusedError': <class 'ConnectionRefusedError'>, 'ConnectionResetError': <class 'ConnectionResetError'>, 'TabError': <class 'TabError'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'ExceptionGroup': <class 'ExceptionGroup'>, 'EnvironmentError': <class 'OSError'>, 'IOError': <class 'OSError'>, 'WindowsError': <class 'OSError'>, 'open': <built-in function open>, 'quit': Use quit() or Ctrl-Z plus Return to exit, 'exit': Use exit() or Ctrl-Z plus Return to exit, 'copyright': Copyright (c) 2001-2023 Python Software Foundation.?All Rights Reserved.??Copyright (c) 2000 BeOpen.com.?All Rights Reserved.??Copyright (c) 1995-2001 Corporation for National Research Initiatives.?All Rights Reserved.??Copyright (c) 1991-1995 Stichting Mathematisch Centrum, Amsterdam.?All Rights Reserved., 'credits':     Thanks to CWI, CNRI, BeOpen.com, Zope Corporation and a cast of thousands?    for supporting Python development.  See www.python.org for more information., 'license': Type license() to see the full license text, 'help': Type help() for interactive help, or help(object) for help about object.}
-     __cached__:  'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\submodule\\__pycache__\\__init__.cpython-312.pyc'
-     __doc__:  None
-@@ -881,9 +884,7 @@ git.objects.submodule.util:
-     Any:  typing.Any
-     BytesIO:  <class '_io.BytesIO'>
-     GitConfigParser:  <class 'git.config.GitConfigParser'>
--    IndexObject:  <class 'git.objects.base.IndexObject'>
-     InvalidGitRepositoryError:  <class 'git.exc.InvalidGitRepositoryError'>
--    Object:  <class 'git.objects.base.Object'>
-     PathLike:  typing.Union[str, ForwardRef('os.PathLike[str]')]
-     Sequence:  typing.Sequence
-     SubmoduleConfigParser:  <class 'git.objects.submodule.util.SubmoduleConfigParser'>
-@@ -998,7 +999,7 @@ git.objects.util:
-     ZERO:  datetime.timedelta(0)
-     __builtins__:  {'__name__': 'builtins', '__doc__': "Built-in functions, types, exceptions, and other objects.\n\nThis module provides direct access to all 'built-in'\nidentifiers of Python; for example, builtins.len is\nthe full name for the built-in function len().\n\nThis module is not normally accessed explicitly by most\napplications, but can be useful in modules that provide\nobjects with the same name as a built-in value, but in\nwhich the built-in of that name is also needed.", '__package__': '', '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>, origin='built-in'), '__build_class__': <built-in function __build_class__>, '__import__': <built-in function __import__>, 'abs': <built-in function abs>, 'all': <built-in function all>, 'any': <built-in function any>, 'ascii': <built-in function ascii>, 'bin': <built-in function bin>, 'breakpoint': <built-in function breakpoint>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'compile': <built-in function compile>, 'delattr': <built-in function delattr>, 'dir': <built-in function dir>, 'divmod': <built-in function divmod>, 'eval': <built-in function eval>, 'exec': <built-in function exec>, 'format': <built-in function format>, 'getattr': <built-in function getattr>, 'globals': <built-in function globals>, 'hasattr': <built-in function hasattr>, 'hash': <built-in function hash>, 'hex': <built-in function hex>, 'id': <built-in function id>, 'input': <built-in function input>, 'isinstance': <built-in function isinstance>, 'issubclass': <built-in function issubclass>, 'iter': <built-in function iter>, 'aiter': <built-in function aiter>, 'len': <built-in function len>, 'locals': <built-in function locals>, 'max': <built-in function max>, 'min': <built-in function min>, 'next': <built-in function next>, 'anext': <built-in function anext>, 'oct': <built-in function oct>, 'ord': <built-in function ord>, 'pow': <built-in function pow>, 'print': <built-in function print>, 'repr': <built-in function repr>, 'round': <built-in function round>, 'setattr': <built-in function setattr>, 'sorted': <built-in function sorted>, 'sum': <built-in function sum>, 'vars': <built-in function vars>, 'None': None, 'Ellipsis': Ellipsis, 'NotImplemented': NotImplemented, 'False': False, 'True': True, 'bool': <class 'bool'>, 'memoryview': <class 'memoryview'>, 'bytearray': <class 'bytearray'>, 'bytes': <class 'bytes'>, 'classmethod': <class 'classmethod'>, 'complex': <class 'complex'>, 'dict': <class 'dict'>, 'enumerate': <class 'enumerate'>, 'filter': <class 'filter'>, 'float': <class 'float'>, 'frozenset': <class 'frozenset'>, 'property': <class 'property'>, 'int': <class 'int'>, 'list': <class 'list'>, 'map': <class 'map'>, 'object': <class 'object'>, 'range': <class 'range'>, 'reversed': <class 'reversed'>, 'set': <class 'set'>, 'slice': <class 'slice'>, 'staticmethod': <class 'staticmethod'>, 'str': <class 'str'>, 'super': <class 'super'>, 'tuple': <class 'tuple'>, 'type': <class 'type'>, 'zip': <class 'zip'>, '__debug__': True, 'BaseException': <class 'BaseException'>, 'BaseExceptionGroup': <class 'BaseExceptionGroup'>, 'Exception': <class 'Exception'>, 'GeneratorExit': <class 'GeneratorExit'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'SystemExit': <class 'SystemExit'>, 'ArithmeticError': <class 'ArithmeticError'>, 'AssertionError': <class 'AssertionError'>, 'AttributeError': <class 'AttributeError'>, 'BufferError': <class 'BufferError'>, 'EOFError': <class 'EOFError'>, 'ImportError': <class 'ImportError'>, 'LookupError': <class 'LookupError'>, 'MemoryError': <class 'MemoryError'>, 'NameError': <class 'NameError'>, 'OSError': <class 'OSError'>, 'ReferenceError': <class 'ReferenceError'>, 'RuntimeError': <class 'RuntimeError'>, 'StopAsyncIteration': <class 'StopAsyncIteration'>, 'StopIteration': <class 'StopIteration'>, 'SyntaxError': <class 'SyntaxError'>, 'SystemError': <class 'SystemError'>, 'TypeError': <class 'TypeError'>, 'ValueError': <class 'ValueError'>, 'Warning': <class 'Warning'>, 'FloatingPointError': <class 'FloatingPointError'>, 'OverflowError': <class 'OverflowError'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'BytesWarning': <class 'BytesWarning'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'EncodingWarning': <class 'EncodingWarning'>, 'FutureWarning': <class 'FutureWarning'>, 'ImportWarning': <class 'ImportWarning'>, 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'ResourceWarning': <class 'ResourceWarning'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'SyntaxWarning': <class 'SyntaxWarning'>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'UserWarning': <class 'UserWarning'>, 'BlockingIOError': <class 'BlockingIOError'>, 'ChildProcessError': <class 'ChildProcessError'>, 'ConnectionError': <class 'ConnectionError'>, 'FileExistsError': <class 'FileExistsError'>, 'FileNotFoundError': <class 'FileNotFoundError'>, 'InterruptedError': <class 'InterruptedError'>, 'IsADirectoryError': <class 'IsADirectoryError'>, 'NotADirectoryError': <class 'NotADirectoryError'>, 'PermissionError': <class 'PermissionError'>, 'ProcessLookupError': <class 'ProcessLookupError'>, 'TimeoutError': <class 'TimeoutError'>, 'IndentationError': <class 'IndentationError'>, 'IndexError': <class 'IndexError'>, 'KeyError': <class 'KeyError'>, 'ModuleNotFoundError': <class 'ModuleNotFoundError'>, 'NotImplementedError': <class 'NotImplementedError'>, 'RecursionError': <class 'RecursionError'>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'UnicodeError': <class 'UnicodeError'>, 'BrokenPipeError': <class 'BrokenPipeError'>, 'ConnectionAbortedError': <class 'ConnectionAbortedError'>, 'ConnectionRefusedError': <class 'ConnectionRefusedError'>, 'ConnectionResetError': <class 'ConnectionResetError'>, 'TabError': <class 'TabError'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'ExceptionGroup': <class 'ExceptionGroup'>, 'EnvironmentError': <class 'OSError'>, 'IOError': <class 'OSError'>, 'WindowsError': <class 'OSError'>, 'open': <built-in function open>, 'quit': Use quit() or Ctrl-Z plus Return to exit, 'exit': Use exit() or Ctrl-Z plus Return to exit, 'copyright': Copyright (c) 2001-2023 Python Software Foundation.?All Rights Reserved.??Copyright (c) 2000 BeOpen.com.?All Rights Reserved.??Copyright (c) 1995-2001 Corporation for National Research Initiatives.?All Rights Reserved.??Copyright (c) 1991-1995 Stichting Mathematisch Centrum, Amsterdam.?All Rights Reserved., 'credits':     Thanks to CWI, CNRI, BeOpen.com, Zope Corporation and a cast of thousands?    for supporting Python development.  See www.python.org for more information., 'license': Type license() to see the full license text, 'help': Type help() for interactive help, or help(object) for help about object.}
-     __cached__:  'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\__pycache__\\util.cpython-312.pyc'
--    __doc__:  'General utility functions.'
-+    __doc__:  'Utility functions for working with git objects.'
-     __file__:  'C:\\Users\\ek\\source\\repos\\GitPython\\git\\objects\\util.py'
-     __loader__:  <_frozen_importlib_external.SourceFileLoader object at 0x...>
-     __name__:  'git.objects.util'
diff --git a/modattrs.py b/modattrs.py
deleted file mode 100755
index 245f68912..000000000
--- a/modattrs.py
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/usr/bin/env python
-
-"""Script to get the names and "stabilized" reprs of module attributes in GitPython.
-
-Run with :envvar:`PYTHONHASHSEED` set to ``0`` for fully comparable results. These are
-only still meaningful for comparing if the same platform and Python version are used.
-
-The output of this script should probably not be committed, because within the reprs of
-objects found in modules, it may contain sensitive information, such as API keys stored
-in environment variables. The "sanitization" performed here is only for common forms of
-whitespace that clash with the output format.
-"""
-
-# fmt: off
-
-__all__ = ["git", "main"]
-
-import itertools
-import re
-import sys
-
-import git
-
-
-def main():
-    # This assumes `import git` causes all of them to be loaded.
-    gitpython_modules = sorted(
-        (module_name, module)
-        for module_name, module in sys.modules.items()
-        if re.match(r"git(?:\.|$)", module_name)
-    )
-
-    # We will print two blank lines between successive module reports.
-    separators = itertools.chain(("",), itertools.repeat("\n\n"))
-
-    # Report each module's contents.
-    for (module_name, module), separator in zip(gitpython_modules, separators):
-        print(f"{separator}{module_name}:")
-
-        attributes = sorted(
-            (name, value)
-            for name, value in module.__dict__.items()
-            if name != "__all__"  # Because we are deliberately adding these.
-        )
-
-        for name, value in attributes:
-            sanitized_repr = re.sub(r"[\r\n\v\f]", "?", repr(value))
-            normalized_repr = re.sub(r" at 0x[0-9a-fA-F]+", " at 0x...", sanitized_repr)
-            print(f"    {name}:  {normalized_repr}")
-
-
-if __name__ == "__main__":
-    main()

From 4a4d880fec4364c7d49e7708238a962942ae69f3 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 19 Mar 2024 00:45:40 -0400
Subject: [PATCH 033/332] Improve test suite import grouping/sorting, __all__
 placement

There is only one __all__ in the test suite, so this is mostly the
change to imports, grouping and sorting them in a fully consistent
style that is the same as the import style in the code under test.
---
 test/lib/helper.py               | 32 ++++++++++++++++----------------
 test/performance/__init__.py     |  2 ++
 test/performance/lib.py          |  5 +++--
 test/performance/test_commit.py  |  6 ++++--
 test/performance/test_odb.py     |  2 +-
 test/performance/test_streams.py |  8 ++++----
 test/test_actor.py               |  3 ++-
 test/test_base.py                | 10 +++++-----
 test/test_blob.py                |  3 ++-
 test/test_clone.py               |  5 +----
 test/test_commit.py              | 20 +++++++++++---------
 test/test_config.py              |  2 +-
 test/test_db.py                  |  5 +++--
 test/test_diff.py                |  1 +
 test/test_docs.py                |  3 +--
 test/test_exc.py                 |  7 ++++---
 test/test_fun.py                 | 17 ++++++++---------
 test/test_git.py                 |  3 ++-
 test/test_index.py               | 14 ++++----------
 test/test_reflog.py              |  5 +++--
 test/test_refs.py                | 23 ++++++++++++-----------
 test/test_remote.py              |  3 +--
 test/test_repo.py                |  7 ++-----
 test/test_stats.py               |  3 ++-
 test/test_submodule.py           |  1 +
 test/test_tree.py                |  3 ++-
 test/test_util.py                |  1 +
 27 files changed, 99 insertions(+), 95 deletions(-)

diff --git a/test/lib/helper.py b/test/lib/helper.py
index 45a778b7d..5d91447ea 100644
--- a/test/lib/helper.py
+++ b/test/lib/helper.py
@@ -3,6 +3,22 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+__all__ = [
+    "fixture_path",
+    "fixture",
+    "StringProcessAdapter",
+    "with_rw_directory",
+    "with_rw_repo",
+    "with_rw_and_rw_remote_repo",
+    "TestBase",
+    "VirtualEnvironment",
+    "TestCase",
+    "SkipTest",
+    "skipIf",
+    "GIT_REPO",
+    "GIT_DAEMON_PORT",
+]
+
 import contextlib
 from functools import wraps
 import gc
@@ -31,22 +47,6 @@
 GIT_REPO = os.environ.get("GIT_PYTHON_TEST_GIT_REPO_BASE", ospd(ospd(ospd(__file__))))
 GIT_DAEMON_PORT = os.environ.get("GIT_PYTHON_TEST_GIT_DAEMON_PORT", "19418")
 
-__all__ = (
-    "fixture_path",
-    "fixture",
-    "StringProcessAdapter",
-    "with_rw_directory",
-    "with_rw_repo",
-    "with_rw_and_rw_remote_repo",
-    "TestBase",
-    "VirtualEnvironment",
-    "TestCase",
-    "SkipTest",
-    "skipIf",
-    "GIT_REPO",
-    "GIT_DAEMON_PORT",
-)
-
 _logger = logging.getLogger(__name__)
 
 # { Routines
diff --git a/test/performance/__init__.py b/test/performance/__init__.py
index e69de29bb..56b5d89db 100644
--- a/test/performance/__init__.py
+++ b/test/performance/__init__.py
@@ -0,0 +1,2 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
diff --git a/test/performance/lib.py b/test/performance/lib.py
index d08e1027f..2ca3c409b 100644
--- a/test/performance/lib.py
+++ b/test/performance/lib.py
@@ -5,13 +5,14 @@
 
 import logging
 import os
+import os.path as osp
 import tempfile
 
 from git import Repo
 from git.db import GitCmdObjectDB, GitDB
-from test.lib import TestBase
 from git.util import rmtree
-import os.path as osp
+
+from test.lib import TestBase
 
 # { Invariants
 
diff --git a/test/performance/test_commit.py b/test/performance/test_commit.py
index 00d768f0a..b943f1975 100644
--- a/test/performance/test_commit.py
+++ b/test/performance/test_commit.py
@@ -10,9 +10,11 @@
 from time import time
 import sys
 
-from .lib import TestBigRepoRW
-from git import Commit
 from gitdb import IStream
+
+from git import Commit
+
+from test.performance.lib import TestBigRepoRW
 from test.test_commit import TestCommitSerialization
 
 
diff --git a/test/performance/test_odb.py b/test/performance/test_odb.py
index 00e245fb7..fdbbeb8c3 100644
--- a/test/performance/test_odb.py
+++ b/test/performance/test_odb.py
@@ -6,7 +6,7 @@
 import sys
 from time import time
 
-from .lib import TestBigRepoR
+from test.performance.lib import TestBigRepoR
 
 
 class TestObjDBPerformance(TestBigRepoR):
diff --git a/test/performance/test_streams.py b/test/performance/test_streams.py
index 56b5274ec..f6ffeba8e 100644
--- a/test/performance/test_streams.py
+++ b/test/performance/test_streams.py
@@ -5,18 +5,18 @@
 
 import gc
 import os
+import os.path as osp
 import subprocess
 import sys
 from time import time
 
-from test.lib import with_rw_repo
-from git.util import bin_to_hex
 from gitdb import LooseObjectDB, IStream
 from gitdb.test.lib import make_memory_file
 
-import os.path as osp
+from git.util import bin_to_hex
 
-from .lib import TestBigRepoR
+from test.lib import with_rw_repo
+from test.performance.lib import TestBigRepoR
 
 
 class TestObjDBPerformance(TestBigRepoR):
diff --git a/test/test_actor.py b/test/test_actor.py
index caf095739..5e6635709 100644
--- a/test/test_actor.py
+++ b/test/test_actor.py
@@ -3,9 +3,10 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-from test.lib import TestBase
 from git import Actor
 
+from test.lib import TestBase
+
 
 class TestActor(TestBase):
     def test_from_string_should_separate_name_and_email(self):
diff --git a/test/test_base.py b/test/test_base.py
index e477b4837..86bcc5c79 100644
--- a/test/test_base.py
+++ b/test/test_base.py
@@ -5,18 +5,18 @@
 
 import gc
 import os
+import os.path as osp
 import sys
 import tempfile
 from unittest import skipIf
 
 from git import Repo
-from git.objects import Blob, Tree, Commit, TagObject
+from git.objects import Blob, Commit, TagObject, Tree
+import git.objects.base as base
 from git.objects.util import get_object_type_by_name
-from test.lib import TestBase as _TestBase, with_rw_repo, with_rw_and_rw_remote_repo
-from git.util import hex_to_bin, HIDE_WINDOWS_FREEZE_ERRORS
+from git.util import HIDE_WINDOWS_FREEZE_ERRORS, hex_to_bin
 
-import git.objects.base as base
-import os.path as osp
+from test.lib import TestBase as _TestBase, with_rw_and_rw_remote_repo, with_rw_repo
 
 
 class TestBase(_TestBase):
diff --git a/test/test_blob.py b/test/test_blob.py
index ff59c67ea..affaa60fc 100644
--- a/test/test_blob.py
+++ b/test/test_blob.py
@@ -3,9 +3,10 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-from test.lib import TestBase
 from git import Blob
 
+from test.lib import TestBase
+
 
 class TestBlob(TestBase):
     def test_mime_type_should_return_mime_type_for_known_types(self):
diff --git a/test/test_clone.py b/test/test_clone.py
index be2e6b19b..126ef0063 100644
--- a/test/test_clone.py
+++ b/test/test_clone.py
@@ -6,10 +6,7 @@
 
 import git
 
-from .lib import (
-    TestBase,
-    with_rw_directory,
-)
+from test.lib import TestBase, with_rw_directory
 
 
 class TestClone(TestBase):
diff --git a/test/test_commit.py b/test/test_commit.py
index 5571b9ecb..5832258de 100644
--- a/test/test_commit.py
+++ b/test/test_commit.py
@@ -6,23 +6,25 @@
 import copy
 from datetime import datetime
 from io import BytesIO
+import os.path as osp
 import re
 import sys
 import time
 from unittest.mock import Mock
 
-from git import (
-    Commit,
-    Actor,
-)
-from git import Repo
+from gitdb import IStream
+
+from git import Actor, Commit, Repo
 from git.objects.util import tzoffset, utc
 from git.repo.fun import touch
-from test.lib import TestBase, with_rw_repo, fixture_path, StringProcessAdapter
-from test.lib import with_rw_directory
-from gitdb import IStream
 
-import os.path as osp
+from test.lib import (
+    StringProcessAdapter,
+    TestBase,
+    fixture_path,
+    with_rw_directory,
+    with_rw_repo,
+)
 
 
 class TestCommitSerialization(TestBase):
diff --git a/test/test_config.py b/test/test_config.py
index ac19a7fa8..0911d0262 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -15,8 +15,8 @@
 from git import GitConfigParser
 from git.config import _OMD, cp
 from git.util import rmfile
-from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory
 
+from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory
 
 _tc_lock_fpaths = osp.join(osp.dirname(__file__), "fixtures/*.lock")
 
diff --git a/test/test_db.py b/test/test_db.py
index de093cbd8..72d63b44b 100644
--- a/test/test_db.py
+++ b/test/test_db.py
@@ -3,12 +3,13 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+import os.path as osp
+
 from git.db import GitCmdObjectDB
 from git.exc import BadObject
-from test.lib import TestBase
 from git.util import bin_to_hex
 
-import os.path as osp
+from test.lib import TestBase
 
 
 class TestDB(TestBase):
diff --git a/test/test_diff.py b/test/test_diff.py
index 96fbc60e3..928a9f428 100644
--- a/test/test_diff.py
+++ b/test/test_diff.py
@@ -14,6 +14,7 @@
 
 from git import NULL_TREE, Diff, DiffIndex, Diffable, GitCommandError, Repo, Submodule
 from git.cmd import Git
+
 from test.lib import StringProcessAdapter, TestBase, fixture, with_rw_directory
 
 
diff --git a/test/test_docs.py b/test/test_docs.py
index 409f66bb3..b3547c1de 100644
--- a/test/test_docs.py
+++ b/test/test_docs.py
@@ -5,6 +5,7 @@
 
 import gc
 import os
+import os.path
 import sys
 
 import pytest
@@ -12,8 +13,6 @@
 from test.lib import TestBase
 from test.lib.helper import with_rw_directory
 
-import os.path
-
 
 class Tutorials(TestBase):
     def tearDown(self):
diff --git a/test/test_exc.py b/test/test_exc.py
index 3f4d0b803..c1eae7240 100644
--- a/test/test_exc.py
+++ b/test/test_exc.py
@@ -3,9 +3,11 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
+from itertools import product
 import re
 
 import ddt
+
 from git.exc import (
     InvalidGitRepositoryError,
     WorkTreeRepositoryUnsupported,
@@ -20,9 +22,8 @@
     RepositoryDirtyError,
 )
 from git.util import remove_password_if_present
-from test.lib import TestBase
 
-import itertools as itt
+from test.lib import TestBase
 
 
 _cmd_argvs = (
@@ -79,7 +80,7 @@ def test_ExceptionsHaveBaseClass(self):
         for ex_class in exception_classes:
             self.assertTrue(issubclass(ex_class, GitError))
 
-    @ddt.data(*list(itt.product(_cmd_argvs, _causes_n_substrings, _streams_n_substrings)))
+    @ddt.data(*list(product(_cmd_argvs, _causes_n_substrings, _streams_n_substrings)))
     def test_CommandError_unicode(self, case):
         argv, (cause, subs), stream = case
         cls = CommandError
diff --git a/test/test_fun.py b/test/test_fun.py
index 2d30d355a..b8593b400 100644
--- a/test/test_fun.py
+++ b/test/test_fun.py
@@ -2,27 +2,26 @@
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
 from io import BytesIO
-from stat import S_IFDIR, S_IFREG, S_IFLNK, S_IXUSR
+from stat import S_IFDIR, S_IFLNK, S_IFREG, S_IXUSR
 from os import stat
 import os.path as osp
 
+from gitdb.base import IStream
+from gitdb.typ import str_tree_type
+
 from git import Git
 from git.index import IndexFile
-from git.index.fun import (
-    aggressive_tree_merge,
-    stat_mode_to_index_mode,
-)
+from git.index.fun import aggressive_tree_merge, stat_mode_to_index_mode
 from git.objects.fun import (
     traverse_tree_recursive,
     traverse_trees_recursive,
-    tree_to_stream,
     tree_entries_from_data,
+    tree_to_stream,
 )
 from git.repo.fun import find_worktree_git_dir
-from test.lib import TestBase, with_rw_repo, with_rw_directory
 from git.util import bin_to_hex, cygpath, join_path_native
-from gitdb.base import IStream
-from gitdb.typ import str_tree_type
+
+from test.lib import TestBase, with_rw_directory, with_rw_repo
 
 
 class TestFun(TestBase):
diff --git a/test/test_git.py b/test/test_git.py
index dae0f6a39..112e2f0eb 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -25,8 +25,9 @@
 
 import ddt
 
-from git import Git, refresh, GitCommandError, GitCommandNotFound, Repo, cmd
+from git import Git, GitCommandError, GitCommandNotFound, Repo, cmd, refresh
 from git.util import cwd, finalize_process
+
 from test.lib import TestBase, fixture_path, with_rw_directory
 
 
diff --git a/test/test_index.py b/test/test_index.py
index 622e7ca9a..b92258c92 100644
--- a/test/test_index.py
+++ b/test/test_index.py
@@ -17,18 +17,12 @@
 import sys
 import tempfile
 
+from gitdb.base import IStream
+
 import ddt
 import pytest
 
-from git import (
-    BlobFilter,
-    Diff,
-    Git,
-    IndexFile,
-    Object,
-    Repo,
-    Tree,
-)
+from git import BlobFilter, Diff, Git, IndexFile, Object, Repo, Tree
 from git.exc import (
     CheckoutError,
     GitCommandError,
@@ -41,7 +35,7 @@
 from git.index.util import TemporaryFileSwap
 from git.objects import Blob
 from git.util import Actor, cwd, hex_to_bin, rmtree
-from gitdb.base import IStream
+
 from test.lib import (
     TestBase,
     VirtualEnvironment,
diff --git a/test/test_reflog.py b/test/test_reflog.py
index 1bd2e5dab..7ce64219a 100644
--- a/test/test_reflog.py
+++ b/test/test_reflog.py
@@ -5,9 +5,10 @@
 import tempfile
 
 from git.objects import IndexObject
-from git.refs import RefLogEntry, RefLog
+from git.refs import RefLog, RefLogEntry
+from git.util import Actor, hex_to_bin, rmtree
+
 from test.lib import TestBase, fixture_path
-from git.util import Actor, rmtree, hex_to_bin
 
 
 class TestRefLog(TestBase):
diff --git a/test/test_refs.py b/test/test_refs.py
index 28db70c6e..08096e69e 100644
--- a/test/test_refs.py
+++ b/test/test_refs.py
@@ -4,27 +4,28 @@
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
 from itertools import chain
+import os.path as osp
 from pathlib import Path
+import tempfile
+
+from gitdb.exc import BadName
 
 from git import (
-    Reference,
-    Head,
-    TagReference,
-    RemoteReference,
     Commit,
-    SymbolicReference,
     GitCommandError,
-    RefLog,
     GitConfigParser,
+    Head,
+    RefLog,
+    Reference,
+    RemoteReference,
+    SymbolicReference,
+    TagReference,
 )
 from git.objects.tag import TagObject
-from test.lib import TestBase, with_rw_repo
+import git.refs as refs
 from git.util import Actor
-from gitdb.exc import BadName
 
-import git.refs as refs
-import os.path as osp
-import tempfile
+from test.lib import TestBase, with_rw_repo
 
 
 class TestRefs(TestBase):
diff --git a/test/test_remote.py b/test/test_remote.py
index f84452deb..5ddb41bc0 100644
--- a/test/test_remote.py
+++ b/test/test_remote.py
@@ -28,7 +28,7 @@
 )
 from git.cmd import Git
 from git.exc import UnsafeOptionError, UnsafeProtocolError
-from git.util import rmtree, HIDE_WINDOWS_FREEZE_ERRORS, IterableList
+from git.util import HIDE_WINDOWS_FREEZE_ERRORS, IterableList, rmtree
 from test.lib import (
     GIT_DAEMON_PORT,
     TestBase,
@@ -37,7 +37,6 @@
     with_rw_repo,
 )
 
-
 # Make sure we have repeatable results.
 random.seed(0)
 
diff --git a/test/test_repo.py b/test/test_repo.py
index 238f94712..e38da5bb6 100644
--- a/test/test_repo.py
+++ b/test/test_repo.py
@@ -36,13 +36,10 @@
     Submodule,
     Tree,
 )
-from git.exc import (
-    BadObject,
-    UnsafeOptionError,
-    UnsafeProtocolError,
-)
+from git.exc import BadObject, UnsafeOptionError, UnsafeProtocolError
 from git.repo.fun import touch
 from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree
+
 from test.lib import TestBase, fixture, with_rw_directory, with_rw_repo
 
 
diff --git a/test/test_stats.py b/test/test_stats.py
index 4efb6f313..eec73c802 100644
--- a/test/test_stats.py
+++ b/test/test_stats.py
@@ -3,10 +3,11 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-from test.lib import TestBase, fixture
 from git import Stats
 from git.compat import defenc
 
+from test.lib import TestBase, fixture
+
 
 class TestStats(TestBase):
     def test_list_from_string(self):
diff --git a/test/test_submodule.py b/test/test_submodule.py
index ee7795dbb..d88f9dab0 100644
--- a/test/test_submodule.py
+++ b/test/test_submodule.py
@@ -27,6 +27,7 @@
 from git.objects.submodule.root import RootModule, RootUpdateProgress
 from git.repo.fun import find_submodule_git_dir, touch
 from git.util import HIDE_WINDOWS_KNOWN_ERRORS, join_path_native, to_native_path_linux
+
 from test.lib import TestBase, with_rw_directory, with_rw_repo
 
 
diff --git a/test/test_tree.py b/test/test_tree.py
index 0c06b950c..73158113d 100644
--- a/test/test_tree.py
+++ b/test/test_tree.py
@@ -8,8 +8,9 @@
 from pathlib import Path
 import subprocess
 
-from git.objects import Tree, Blob
+from git.objects import Blob, Tree
 from git.util import cwd
+
 from test.lib import TestBase, with_rw_directory
 
 
diff --git a/test/test_util.py b/test/test_util.py
index 369896581..dad2f3dcd 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -38,6 +38,7 @@
     remove_password_if_present,
     rmtree,
 )
+
 from test.lib import TestBase, with_rw_repo
 
 

From d524c76a460b339b224c59b6753607eb2e546b2a Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 19 Mar 2024 01:02:20 -0400
Subject: [PATCH 034/332] Fix slightly unsorted imports in setup.py

---
 setup.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.py b/setup.py
index 143206653..f28fedb85 100755
--- a/setup.py
+++ b/setup.py
@@ -1,8 +1,8 @@
 #!/usr/bin/env python
 
 import os
-import sys
 from pathlib import Path
+import sys
 from typing import Sequence
 
 from setuptools import setup, find_packages

From 838eb923751aad3063a47ef24c0b806e9c4e2453 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 19 Mar 2024 13:22:46 -0400
Subject: [PATCH 035/332] Clarify how tag objects are usually tree-ish and
 commit-ish

This revises the docstrings of the Tree_ish and Commit_ish unions,
to make clearer that tags objects are usually used to identify
commits and are thus usually tree-ish and commit-ish.

This change relates to the discussion in #1878, starting at:
https://github.com/gitpython-developers/GitPython/pull/1878#pullrequestreview-1938343498
---
 git/types.py | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/git/types.py b/git/types.py
index 230422dff..a93ebdb4f 100644
--- a/git/types.py
+++ b/git/types.py
@@ -75,7 +75,7 @@
 """
 
 Tree_ish = Union["Commit", "Tree", "TagObject"]
-"""Union of :class:`~git.objects.base.Object`-based types that are sometimes tree-ish.
+"""Union of :class:`~git.objects.base.Object`-based types that are typically tree-ish.
 
 See :manpage:`gitglossary(7)` on "tree-ish":
 https://git-scm.com/docs/gitglossary#def_tree-ish
@@ -83,10 +83,11 @@
 :note:
     :class:`~git.objects.tree.Tree` and :class:`~git.objects.commit.Commit` are the
     classes whose instances are all tree-ish. This union includes them, but also
-    :class:`~git.objects.tag.TagObject`, only **some** of whose instances are tree-ish.
+    :class:`~git.objects.tag.TagObject`, only **most** of whose instances are tree-ish.
     Whether a particular :class:`~git.objects.tag.TagObject` peels (recursively
     dereferences) to a tree or commit, rather than a blob, can in general only be known
-    at runtime.
+    at runtime. In practice, git tag objects are nearly always used for tagging commits,
+    and such tags are tree-ish because commits are tree-ish.
 
 :note:
     See also the :class:`AnyGitObject` union of all four classes corresponding to git
@@ -94,7 +95,7 @@
 """
 
 Commit_ish = Union["Commit", "TagObject"]
-"""Union of :class:`~git.objects.base.Object`-based types that are sometimes commit-ish.
+"""Union of :class:`~git.objects.base.Object`-based types that are typically commit-ish.
 
 See :manpage:`gitglossary(7)` on "commit-ish":
 https://git-scm.com/docs/gitglossary#def_commit-ish
@@ -102,10 +103,11 @@
 :note:
     :class:`~git.objects.commit.Commit` is the only class whose instances are all
     commit-ish. This union type includes :class:`~git.objects.commit.Commit`, but also
-    :class:`~git.objects.tag.TagObject`, only **some** of whose instances are
+    :class:`~git.objects.tag.TagObject`, only **most** of whose instances are
     commit-ish. Whether a particular :class:`~git.objects.tag.TagObject` peels
     (recursively dereferences) to a commit, rather than a tree or blob, can in general
-    only be known at runtime.
+    only be known at runtime. In practice, git tag objects are nearly always used for
+    tagging commits, and such tags are of course commit-ish.
 
 :note:
     See also the :class:`AnyGitObject` union of all four classes corresponding to git

From 2382891377f60c1467c1965b25e3ecf293f39b80 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 19:41:55 -0400
Subject: [PATCH 036/332] Test that deprecated Diff.renamed property warns

This starts on a test.deprecation subpackage for deprecation tests.
Having these tests in a separate directory inside the test suite
may or may not be how they will ultimately be orgnaized, but it has
two advantages:

- Once all the tests are written, it should be easy to see what in
  GitPython is deprecated.

- Some deprecation warnings -- those on module or class attribute
  access -- will require the introduction of new dynamic behavior,
  and thus run the risk of breaking static type checking. So that
  should be checked for, where applicable. But currently the test
  suite has no type annotations and is not checked by mypy. Having
  deprecation-related tests under the same path will make it easier
  to enable mypy for just this part of the test suite (for now).

It is also for this latter reason that the one test so far is
written without using the GitPython test suite's existing fixtures
whose uses are harder to annotate. This may be changed if
warranted, though some of the more complex deprecation-related
tests may benefit from being written as pure pytest tests.

Although a number of deprecated features in GitPython do already
issue warnings, Diff.renamed is one of the features that does not
yet do so. So the newly introduced test will fail until that is
fixed in the next commit.
---
 test/deprecation/__init__.py     |  2 ++
 test/deprecation/test_various.py | 20 ++++++++++++++++++++
 2 files changed, 22 insertions(+)
 create mode 100644 test/deprecation/__init__.py
 create mode 100644 test/deprecation/test_various.py

diff --git a/test/deprecation/__init__.py b/test/deprecation/__init__.py
new file mode 100644
index 000000000..56b5d89db
--- /dev/null
+++ b/test/deprecation/__init__.py
@@ -0,0 +1,2 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
new file mode 100644
index 000000000..9f948bc48
--- /dev/null
+++ b/test/deprecation/test_various.py
@@ -0,0 +1,20 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
+"""Tests of assorted deprecation warnings with no extra subtleties to check."""
+
+from git.diff import NULL_TREE
+from git.repo import Repo
+
+import pytest
+
+
+def test_diff_renamed_warns(tmp_path):
+    (tmp_path / "a.txt").write_text("hello\n", encoding="utf-8")
+    repo = Repo.init(tmp_path)
+    repo.index.add(["a.txt"])
+    commit = repo.index.commit("Initial commit")
+    (diff,) = commit.diff(NULL_TREE)  # Exactly one file in the diff.
+
+    with pytest.deprecated_call():
+        diff.renamed

From e7dec7d0eecc362f02418820a146735a68430fbd Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 19:52:17 -0400
Subject: [PATCH 037/332] Have the deprecated Diff.renamed property issue a
 warning

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

diff --git a/git/diff.py b/git/diff.py
index 0e39fe7a8..f89b12d98 100644
--- a/git/diff.py
+++ b/git/diff.py
@@ -7,6 +7,7 @@
 
 import enum
 import re
+import warnings
 
 from git.cmd import handle_process_output
 from git.compat import defenc
@@ -554,6 +555,11 @@ def renamed(self) -> bool:
             This property is deprecated.
             Please use the :attr:`renamed_file` property instead.
         """
+        warnings.warn(
+            "Diff.renamed is deprecated, use Diff.renamed_file instead",
+            DeprecationWarning,
+            stacklevel=2,
+        )
         return self.renamed_file
 
     @property

From a8f109ca1704827b017349d6d97f8a0a3229d9a8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 20:04:25 -0400
Subject: [PATCH 038/332] Fix exception in Popen.__del__ in test on Windows

The newly introduced test would pass, but show:

    Exception ignored in: <function Popen.__del__ at 0x000002483DE14900>
    Traceback (most recent call last):
      File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.752.0_x64__qbz5n2kfra8p0\Lib\subprocess.py", line 1130, in __del__
        self._internal_poll(_deadstate=_maxsize)
      File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.752.0_x64__qbz5n2kfra8p0\Lib\subprocess.py", line 1575, in _internal_poll
        if _WaitForSingleObject(self._handle, 0) == _WAIT_OBJECT_0:
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    OSError: [WinError 6] The handle is invalid

This was due to how, at least for now, the deprecation warning
tests are not using GitPython's normal fixtures, which help clean
things up on Windows.

This adds a small amount of ad-hoc cleanup. It also moves the
acquisition of the Diff object into a pytest fixture, which can be
reused for the immediately forthcoming test that the preferred
property does not warn.
---
 test/deprecation/test_various.py | 17 +++++++++++++----
 1 file changed, 13 insertions(+), 4 deletions(-)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index 9f948bc48..056e0ac0d 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -3,18 +3,27 @@
 
 """Tests of assorted deprecation warnings with no extra subtleties to check."""
 
-from git.diff import NULL_TREE
-from git.repo import Repo
+import gc
 
 import pytest
 
+from git.diff import NULL_TREE
+from git.repo import Repo
+
 
-def test_diff_renamed_warns(tmp_path):
+@pytest.fixture
+def single_diff(tmp_path):
+    """Fixture to supply a single-file diff."""
     (tmp_path / "a.txt").write_text("hello\n", encoding="utf-8")
     repo = Repo.init(tmp_path)
     repo.index.add(["a.txt"])
     commit = repo.index.commit("Initial commit")
     (diff,) = commit.diff(NULL_TREE)  # Exactly one file in the diff.
+    yield diff
+    del repo, commit, diff
+    gc.collect()
+
 
+def test_diff_renamed_warns(single_diff):
     with pytest.deprecated_call():
-        diff.renamed
+        single_diff.renamed

From fffa6cea663b179c0e5d53cf5eb93159e2bbc3b0 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 20:31:10 -0400
Subject: [PATCH 039/332] Test that the preferred renamed_file property does
 not warn

---
 test/deprecation/test_various.py | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index 056e0ac0d..9dd95f723 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -4,6 +4,7 @@
 """Tests of assorted deprecation warnings with no extra subtleties to check."""
 
 import gc
+import warnings
 
 import pytest
 
@@ -25,5 +26,14 @@ def single_diff(tmp_path):
 
 
 def test_diff_renamed_warns(single_diff):
+    """The deprecated Diff.renamed property issues a deprecation warning."""
     with pytest.deprecated_call():
         single_diff.renamed
+
+
+def test_diff_renamed_file_does_not_warn(single_diff):
+    """The preferred Diff.renamed_file property issues no deprecation warning."""
+    with warnings.catch_warnings():
+        # FIXME: Refine this to filter for deprecation warnings from GitPython.
+        warnings.simplefilter("error", DeprecationWarning)
+        single_diff.renamed_file

From bc111b799b06990fd7dde6385265d0e6d3a6289d Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 20:31:30 -0400
Subject: [PATCH 040/332] Add a TODO for simplifying the single_diff fixture

---
 test/deprecation/test_various.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index 9dd95f723..efdb0a57c 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -15,6 +15,7 @@
 @pytest.fixture
 def single_diff(tmp_path):
     """Fixture to supply a single-file diff."""
+    # TODO: Consider making a fake diff rather than using a real repo and commit.
     (tmp_path / "a.txt").write_text("hello\n", encoding="utf-8")
     repo = Repo.init(tmp_path)
     repo.index.add(["a.txt"])

From e3728c3dca9cac2b49f827dbf5d1a100602f33d9 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 20:54:50 -0400
Subject: [PATCH 041/332] Decompose new fixture logic better

This will be even more helpful when testing a deprecated member of
the Commit class (not yet done).
---
 test/deprecation/test_various.py | 25 +++++++++++++++----------
 1 file changed, 15 insertions(+), 10 deletions(-)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index efdb0a57c..398367b61 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -13,28 +13,33 @@
 
 
 @pytest.fixture
-def single_diff(tmp_path):
-    """Fixture to supply a single-file diff."""
-    # TODO: Consider making a fake diff rather than using a real repo and commit.
+def commit(tmp_path):
+    """Fixture to supply a one-commit repo's commit, enough for deprecation tests."""
     (tmp_path / "a.txt").write_text("hello\n", encoding="utf-8")
     repo = Repo.init(tmp_path)
     repo.index.add(["a.txt"])
-    commit = repo.index.commit("Initial commit")
+    yield repo.index.commit("Initial commit")
+    del repo
+    gc.collect()
+
+
+@pytest.fixture
+def diff(commit):
+    """Fixture to supply a single-file diff."""
+    # TODO: Consider making a fake diff rather than using a real repo and commit.
     (diff,) = commit.diff(NULL_TREE)  # Exactly one file in the diff.
     yield diff
-    del repo, commit, diff
-    gc.collect()
 
 
-def test_diff_renamed_warns(single_diff):
+def test_diff_renamed_warns(diff):
     """The deprecated Diff.renamed property issues a deprecation warning."""
     with pytest.deprecated_call():
-        single_diff.renamed
+        diff.renamed
 
 
-def test_diff_renamed_file_does_not_warn(single_diff):
+def test_diff_renamed_file_does_not_warn(diff):
     """The preferred Diff.renamed_file property issues no deprecation warning."""
     with warnings.catch_warnings():
         # FIXME: Refine this to filter for deprecation warnings from GitPython.
         warnings.simplefilter("error", DeprecationWarning)
-        single_diff.renamed_file
+        diff.renamed_file

From ff4b58dd56d382b26d83f2f41f7d11d15e9db8dc Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 20:57:40 -0400
Subject: [PATCH 042/332] Extract no-deprecation-warning asserter as a context
 manager

+ Remove the TODO suggesting the diff not be computed from a real
  commit, since we're going to have the logic for that in use in
  forthcoming commit-specific deprecation warning tests anyway.
---
 test/deprecation/test_various.py | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index 398367b61..60f94311f 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -3,6 +3,7 @@
 
 """Tests of assorted deprecation warnings with no extra subtleties to check."""
 
+import contextlib
 import gc
 import warnings
 
@@ -12,6 +13,15 @@
 from git.repo import Repo
 
 
+@contextlib.contextmanager
+def _assert_no_deprecation_warning():
+    """Context manager to assert that code does not issue any deprecation warnings."""
+    with warnings.catch_warnings():
+        # FIXME: Refine this to filter for deprecation warnings from GitPython.
+        warnings.simplefilter("error", DeprecationWarning)
+        yield
+
+
 @pytest.fixture
 def commit(tmp_path):
     """Fixture to supply a one-commit repo's commit, enough for deprecation tests."""
@@ -26,7 +36,6 @@ def commit(tmp_path):
 @pytest.fixture
 def diff(commit):
     """Fixture to supply a single-file diff."""
-    # TODO: Consider making a fake diff rather than using a real repo and commit.
     (diff,) = commit.diff(NULL_TREE)  # Exactly one file in the diff.
     yield diff
 
@@ -39,7 +48,5 @@ def test_diff_renamed_warns(diff):
 
 def test_diff_renamed_file_does_not_warn(diff):
     """The preferred Diff.renamed_file property issues no deprecation warning."""
-    with warnings.catch_warnings():
-        # FIXME: Refine this to filter for deprecation warnings from GitPython.
-        warnings.simplefilter("error", DeprecationWarning)
+    with _assert_no_deprecation_warning():
         diff.renamed_file

From 2c526962ebf0a5d4d31a725577db112f2ce60447 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 21:04:06 -0400
Subject: [PATCH 043/332] Test that the deprecated Commit.trailers property
 warns

And that the non-deprecated recommended alternative trailers_list
and trailers_dict properties do not warn.

The test that trailers warns does not yet pass yet, because it has
not yet been made to warn.
---
 test/deprecation/test_various.py | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index 60f94311f..99ce882b0 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -50,3 +50,21 @@ def test_diff_renamed_file_does_not_warn(diff):
     """The preferred Diff.renamed_file property issues no deprecation warning."""
     with _assert_no_deprecation_warning():
         diff.renamed_file
+
+
+def test_commit_trailers_warns(commit):
+    """The deprecated Commit.trailers property issues a deprecation warning."""
+    with pytest.deprecated_call():
+        commit.trailers
+
+
+def test_commit_trailers_list_does_not_warn(commit):
+    """The nondeprecated Commit.trailers_list property issues no deprecation warning."""
+    with _assert_no_deprecation_warning():
+        commit.trailers_list
+
+
+def test_commit_trailers_dict_does_not_warn(commit):
+    """The nondeprecated Commit.trailers_dict property issues no deprecation warning."""
+    with _assert_no_deprecation_warning():
+        commit.trailers_dict

From 03464d90a66caf6a5d8dbbd37318758331c85168 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 21:12:41 -0400
Subject: [PATCH 044/332] Have the deprecated Commit.trailers property issue a
 warning

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

diff --git a/git/objects/commit.py b/git/objects/commit.py
index 8de52980c..d957c9051 100644
--- a/git/objects/commit.py
+++ b/git/objects/commit.py
@@ -14,6 +14,7 @@
 from subprocess import Popen, PIPE
 import sys
 from time import altzone, daylight, localtime, time, timezone
+import warnings
 
 from gitdb import IStream
 
@@ -399,6 +400,11 @@ def trailers(self) -> Dict[str, str]:
             Dictionary containing whitespace stripped trailer information.
             Only contains the latest instance of each trailer key.
         """
+        warnings.warn(
+            "Commit.trailers is deprecated, use Commit.trailers_list or Commit.trailers_dict instead",
+            DeprecationWarning,
+            stacklevel=2,
+        )
         return {k: v[0] for k, v in self.trailers_dict.items()}
 
     @property

From 9d096e08d7645bc5206c4728b45efcf26486b635 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 21:51:11 -0400
Subject: [PATCH 045/332] Test that Traversable.{list_,}traverse, but not
 overrides, warn

---
 test/deprecation/test_various.py | 25 +++++++++++++++++++++++++
 1 file changed, 25 insertions(+)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index 99ce882b0..be3195a5e 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -10,6 +10,7 @@
 import pytest
 
 from git.diff import NULL_TREE
+from git.objects.util import Traversable
 from git.repo import Repo
 
 
@@ -68,3 +69,27 @@ def test_commit_trailers_dict_does_not_warn(commit):
     """The nondeprecated Commit.trailers_dict property issues no deprecation warning."""
     with _assert_no_deprecation_warning():
         commit.trailers_dict
+
+
+def test_traverse_list_traverse_in_base_class_warns(commit):
+    """Traversable.list_traverse's base implementation issues a deprecation warning."""
+    with pytest.deprecated_call():
+        Traversable.list_traverse(commit)
+
+
+def test_traversable_list_traverse_override_does_not_warn(commit):
+    """Calling list_traverse on concrete subclasses is not deprecated, does not warn."""
+    with _assert_no_deprecation_warning():
+        commit.list_traverse()
+
+
+def test_traverse_traverse_in_base_class_warns(commit):
+    """Traversable.traverse's base implementation issues a deprecation warning."""
+    with pytest.deprecated_call():
+        Traversable.traverse(commit)
+
+
+def test_traverse_traverse_override_does_not_warn(commit):
+    """Calling traverse on concrete subclasses is not deprecated, does not warn."""
+    with _assert_no_deprecation_warning():
+        commit.traverse()

From 21c2b72b019627dba81b35085117b79660567abd Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 28 Mar 2024 01:51:54 -0400
Subject: [PATCH 046/332] Use the :exc: Sphinx role for DeprecationWarning

Python warnings are exceptions, to facilitate converting them to
errors by raising them. Thus the more specific :exc: role applies
and is a better fit than the more general :class: role.
---
 git/util.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/util.py b/git/util.py
index 5839f9720..8c1c26012 100644
--- a/git/util.py
+++ b/git/util.py
@@ -1285,7 +1285,7 @@ def list_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> IterableList[T_I
 
 
 class IterableClassWatcher(type):
-    """Metaclass that issues :class:`DeprecationWarning` when :class:`git.util.Iterable`
+    """Metaclass that issues :exc:`DeprecationWarning` when :class:`git.util.Iterable`
     is subclassed."""
 
     def __init__(cls, name: str, bases: Tuple, clsdict: Dict) -> None:

From ca385a59439006355de02ddab9012031e8577e41 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 28 Mar 2024 02:00:43 -0400
Subject: [PATCH 047/332] Test that subclassing deprecated git.util.Iterable
 warns

And that subclassing the strongly preferred git.util.Iterable class
does not warn.
---
 test/deprecation/test_various.py | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index be3195a5e..71f9cf940 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -12,6 +12,7 @@
 from git.diff import NULL_TREE
 from git.objects.util import Traversable
 from git.repo import Repo
+from git.util import Iterable as _Iterable, IterableObj
 
 
 @contextlib.contextmanager
@@ -93,3 +94,19 @@ def test_traverse_traverse_override_does_not_warn(commit):
     """Calling traverse on concrete subclasses is not deprecated, does not warn."""
     with _assert_no_deprecation_warning():
         commit.traverse()
+
+
+def test_iterable_inheriting_warns():
+    """Subclassing the deprecated git.util.Iterable issues a deprecation warning."""
+    with pytest.deprecated_call():
+
+        class Derived(_Iterable):
+            pass
+
+
+def test_iterable_obj_inheriting_does_not_warn():
+    """Subclassing git.util.IterableObj is not deprecated, does not warn."""
+    with _assert_no_deprecation_warning():
+
+        class Derived(IterableObj):
+            pass

From 8bbcb26ea6e798a10969570d24cd5e9c401feed7 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 28 Mar 2024 11:43:13 -0400
Subject: [PATCH 048/332] Call repo.close() instead of manually collecting

The code this replaces in the `commit` pytest fixture seems not to
be needed anymore, and could arguably be removed now with no
further related changes. But whether it is needed depends on
subtle and sometimes nondeterministic factors, and may also vary
across Python versions.

To be safe, this replaces it with a call to the Repo instance's
close method, which is in effect more robust than what was being
done before, as it calls clear_cache on the the Git object that the
Repo object uses, does a gitdb/smmap collection, and on Windows
calls gc.collect both before and after that collection.

This may happen immediately anyway if the Repo object is not
reachable from any cycle, since the reference count should go to
zero after each of the deprecation warning tests (the fixture's
lifetime is that of the test case), and Repo.close is called in
Repo.__del__. But this makes it happen immediately even if there is
a cycle.
---
 test/deprecation/test_various.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_various.py
index 71f9cf940..a82b989b7 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_various.py
@@ -4,7 +4,6 @@
 """Tests of assorted deprecation warnings with no extra subtleties to check."""
 
 import contextlib
-import gc
 import warnings
 
 import pytest
@@ -31,8 +30,7 @@ def commit(tmp_path):
     repo = Repo.init(tmp_path)
     repo.index.add(["a.txt"])
     yield repo.index.commit("Initial commit")
-    del repo
-    gc.collect()
+    repo.close()
 
 
 @pytest.fixture

From b8ce99031d3183a895f15e49fb7149a56a653065 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 28 Mar 2024 19:39:00 -0400
Subject: [PATCH 049/332] Better name and document the basic deprecation test
 module

The other deprecation test modules this refers to don't exist yet
but will be introduced soon.
---
 .../deprecation/{test_various.py => test_basic.py} | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)
 rename test/deprecation/{test_various.py => test_basic.py} (80%)

diff --git a/test/deprecation/test_various.py b/test/deprecation/test_basic.py
similarity index 80%
rename from test/deprecation/test_various.py
rename to test/deprecation/test_basic.py
index a82b989b7..459d79268 100644
--- a/test/deprecation/test_various.py
+++ b/test/deprecation/test_basic.py
@@ -1,7 +1,19 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-"""Tests of assorted deprecation warnings with no extra subtleties to check."""
+"""Tests of assorted deprecation warnings when there are no extra subtleties to check.
+
+This tests deprecation warnings where all that needs be verified is that a deprecated
+property, function, or class issues a DeprecationWarning when used and, if applicable,
+that recommended alternatives do not issue the warning.
+
+This is in contrast to other modules within test.deprecation, which test warnings where
+there is a risk of breaking other runtime behavior, or of breaking static type checking
+or making it less useful, by introducing the warning or in plausible future changes to
+how the warning is implemented. That happens when it is necessary to customize attribute
+access on a module or class, in a way it was not customized before, to issue a warning.
+It is inapplicable to the deprecations whose warnings are tested in this module.
+"""
 
 import contextlib
 import warnings

From 61273aa2084ada8c48d0f9e511556d3d72eec32c Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 28 Mar 2024 20:49:34 -0400
Subject: [PATCH 050/332] Annotate basic deprecation tests; have mypy scan it

- Add type hints to test/deprecation/basic.py. As its module
  docstring (already) notes, that test module does not contain
  code where it is specifically important that it be type checked
  to verify important properties of the code under test. However,
  other test.deprecation.* modules will, and it is much more
  convenient to be able to scan the whole directory than the
  directory except for one file. (Less importantly, for the
  deprecation tests to be easily readable as a coherent whole, it
  makes sense that all, not just most, would have annotations.)

- Configure mypy in pyproject.toml so it can be run without
  arguments (mypy errors when run that way unless configured),
  where the effect is to scan the git/ directory, as was commonly
  done before, as well as the test/deprecation/ directory.

- Change the CI test workflow, as well as tox.ini, to run mypy with
  no arguments instead of passing `-p git`, so that it will scan
  both the git package as it had before and the test.deprecation
  package (due to the new pyproject.toml configuration).

- Change the readme to recommend running it that way, too.
---
 .github/workflows/pythonpackage.yml |  2 +-
 README.md                           |  2 +-
 pyproject.toml                      |  1 +
 test/deprecation/test_basic.py      | 40 +++++++++++++++++++----------
 tox.ini                             |  2 +-
 5 files changed, 30 insertions(+), 17 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 7cee0cd64..4c918a92d 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -88,7 +88,7 @@ jobs:
 
     - name: Check types with mypy
       run: |
-        mypy --python-version=${{ matrix.python-version }} -p git
+        mypy --python-version=${{ matrix.python-version }}
       env:
         MYPY_FORCE_COLOR: "1"
         TERM: "xterm-256color"  # For color: https://github.com/python/mypy/issues/13817
diff --git a/README.md b/README.md
index 30af532db..9bedaaae7 100644
--- a/README.md
+++ b/README.md
@@ -167,7 +167,7 @@ This includes the linting and autoformatting done by Ruff, as well as some other
 To typecheck, run:
 
 ```sh
-mypy -p git
+mypy
 ```
 
 #### CI (and tox)
diff --git a/pyproject.toml b/pyproject.toml
index 1dc1e6aed..5eac2be09 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,7 @@ testpaths = "test"  # Space separated list of paths from root e.g test tests doc
 
 [tool.mypy]
 python_version = "3.8"
+files = ["git/", "test/deprecation/"]
 disallow_untyped_defs = true
 no_implicit_optional = true
 warn_redundant_casts = true
diff --git a/test/deprecation/test_basic.py b/test/deprecation/test_basic.py
index 459d79268..8ee7e72b1 100644
--- a/test/deprecation/test_basic.py
+++ b/test/deprecation/test_basic.py
@@ -25,9 +25,21 @@
 from git.repo import Repo
 from git.util import Iterable as _Iterable, IterableObj
 
+# typing -----------------------------------------------------------------
+
+from typing import Generator, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from git.diff import Diff
+    from git.objects.commit import Commit
+
+# ------------------------------------------------------------------------
+
 
 @contextlib.contextmanager
-def _assert_no_deprecation_warning():
+def _assert_no_deprecation_warning() -> Generator[None, None, None]:
     """Context manager to assert that code does not issue any deprecation warnings."""
     with warnings.catch_warnings():
         # FIXME: Refine this to filter for deprecation warnings from GitPython.
@@ -36,7 +48,7 @@ def _assert_no_deprecation_warning():
 
 
 @pytest.fixture
-def commit(tmp_path):
+def commit(tmp_path: "Path") -> Generator["Commit", None, None]:
     """Fixture to supply a one-commit repo's commit, enough for deprecation tests."""
     (tmp_path / "a.txt").write_text("hello\n", encoding="utf-8")
     repo = Repo.init(tmp_path)
@@ -46,67 +58,67 @@ def commit(tmp_path):
 
 
 @pytest.fixture
-def diff(commit):
+def diff(commit: "Commit") -> Generator["Diff", None, None]:
     """Fixture to supply a single-file diff."""
     (diff,) = commit.diff(NULL_TREE)  # Exactly one file in the diff.
     yield diff
 
 
-def test_diff_renamed_warns(diff):
+def test_diff_renamed_warns(diff: "Diff") -> None:
     """The deprecated Diff.renamed property issues a deprecation warning."""
     with pytest.deprecated_call():
         diff.renamed
 
 
-def test_diff_renamed_file_does_not_warn(diff):
+def test_diff_renamed_file_does_not_warn(diff: "Diff") -> None:
     """The preferred Diff.renamed_file property issues no deprecation warning."""
     with _assert_no_deprecation_warning():
         diff.renamed_file
 
 
-def test_commit_trailers_warns(commit):
+def test_commit_trailers_warns(commit: "Commit") -> None:
     """The deprecated Commit.trailers property issues a deprecation warning."""
     with pytest.deprecated_call():
         commit.trailers
 
 
-def test_commit_trailers_list_does_not_warn(commit):
+def test_commit_trailers_list_does_not_warn(commit: "Commit") -> None:
     """The nondeprecated Commit.trailers_list property issues no deprecation warning."""
     with _assert_no_deprecation_warning():
         commit.trailers_list
 
 
-def test_commit_trailers_dict_does_not_warn(commit):
+def test_commit_trailers_dict_does_not_warn(commit: "Commit") -> None:
     """The nondeprecated Commit.trailers_dict property issues no deprecation warning."""
     with _assert_no_deprecation_warning():
         commit.trailers_dict
 
 
-def test_traverse_list_traverse_in_base_class_warns(commit):
+def test_traverse_list_traverse_in_base_class_warns(commit: "Commit") -> None:
     """Traversable.list_traverse's base implementation issues a deprecation warning."""
     with pytest.deprecated_call():
         Traversable.list_traverse(commit)
 
 
-def test_traversable_list_traverse_override_does_not_warn(commit):
+def test_traversable_list_traverse_override_does_not_warn(commit: "Commit") -> None:
     """Calling list_traverse on concrete subclasses is not deprecated, does not warn."""
     with _assert_no_deprecation_warning():
         commit.list_traverse()
 
 
-def test_traverse_traverse_in_base_class_warns(commit):
+def test_traverse_traverse_in_base_class_warns(commit: "Commit") -> None:
     """Traversable.traverse's base implementation issues a deprecation warning."""
     with pytest.deprecated_call():
         Traversable.traverse(commit)
 
 
-def test_traverse_traverse_override_does_not_warn(commit):
+def test_traverse_traverse_override_does_not_warn(commit: "Commit") -> None:
     """Calling traverse on concrete subclasses is not deprecated, does not warn."""
     with _assert_no_deprecation_warning():
         commit.traverse()
 
 
-def test_iterable_inheriting_warns():
+def test_iterable_inheriting_warns() -> None:
     """Subclassing the deprecated git.util.Iterable issues a deprecation warning."""
     with pytest.deprecated_call():
 
@@ -114,7 +126,7 @@ class Derived(_Iterable):
             pass
 
 
-def test_iterable_obj_inheriting_does_not_warn():
+def test_iterable_obj_inheriting_does_not_warn() -> None:
     """Subclassing git.util.IterableObj is not deprecated, does not warn."""
     with _assert_no_deprecation_warning():
 
diff --git a/tox.ini b/tox.ini
index 33074a78a..fc62fa587 100644
--- a/tox.ini
+++ b/tox.ini
@@ -30,7 +30,7 @@ description = Typecheck with mypy
 base_python = py{39,310,311,312,38,37}
 set_env =
     MYPY_FORCE_COLOR = 1
-commands = mypy -p git
+commands = mypy
 ignore_outcome = true
 
 [testenv:html]

From b7a3d8c08537d00aac065b7dcbe1a4896919ee07 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 19 Mar 2024 17:05:06 -0400
Subject: [PATCH 051/332] Start on top-level module attribute access regression
 tests

---
 test/deprecation/test_attributes.py | 10 ++++++++++
 1 file changed, 10 insertions(+)
 create mode 100644 test/deprecation/test_attributes.py

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
new file mode 100644
index 000000000..2673cc172
--- /dev/null
+++ b/test/deprecation/test_attributes.py
@@ -0,0 +1,10 @@
+"""Tests for dynamic and static attribute errors."""
+
+import pytest
+
+import git
+
+
+def test_no_attribute() -> None:
+    with pytest.raises(AttributeError):
+        git.foo

From 105f50056126a73e8cbfeb0d1157695fe6b296b1 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 19 Mar 2024 17:15:02 -0400
Subject: [PATCH 052/332] Test attribute access and importing separately

Rather than only testing attribute access.
---
 test/deprecation/test_attributes.py | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 2673cc172..aea3278f8 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -2,9 +2,14 @@
 
 import pytest
 
-import git
 
+def test_cannot_get_undefined() -> None:
+    import git
 
-def test_no_attribute() -> None:
     with pytest.raises(AttributeError):
         git.foo
+
+
+def test_cannot_import_undefined() -> None:
+    with pytest.raises(ImportError):
+        from git import foo

From 859e38cfb395e6a937c30fedd36a9b3896747188 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 20 Mar 2024 20:14:22 -0400
Subject: [PATCH 053/332] Expand to test top-level deprecated names

---
 test/deprecation/test_attributes.py | 89 ++++++++++++++++++++++++++++-
 1 file changed, 88 insertions(+), 1 deletion(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index aea3278f8..428dab236 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -1,5 +1,7 @@
 """Tests for dynamic and static attribute errors."""
 
+import importlib
+
 import pytest
 
 
@@ -12,4 +14,89 @@ def test_cannot_get_undefined() -> None:
 
 def test_cannot_import_undefined() -> None:
     with pytest.raises(ImportError):
-        from git import foo
+        from git import foo  # noqa: F401
+
+
+def test_util_alias_access_resolves() -> None:
+    """These resolve for now, though they're private we do not guarantee this."""
+    import git
+
+    assert git.util is git.index.util
+
+
+def test_util_alias_import_resolves() -> None:
+    from git import util
+    import git
+
+    util is git.index.util
+
+
+def test_util_alias_access_warns() -> None:
+    import git
+
+    with pytest.deprecated_call() as ctx:
+        git.util
+
+    assert len(ctx) == 1
+    message = ctx[0].message.args[0]
+    assert "git.util" in message
+    assert "git.index.util" in message
+    assert "should not be relied on" in message
+
+
+def test_util_alias_import_warns() -> None:
+    with pytest.deprecated_call() as ctx:
+        from git import util  # noqa: F401
+
+    message = ctx[0].message.args[0]
+    assert "git.util" in message
+    assert "git.index.util" in message
+    assert "should not be relied on" in message
+
+
+_parametrize_by_private_alias = pytest.mark.parametrize(
+    "name, fullname",
+    [
+        ("head", "git.refs.head"),
+        ("log", "git.refs.log"),
+        ("reference", "git.refs.reference"),
+        ("symbolic", "git.refs.symbolic"),
+        ("tag", "git.refs.tag"),
+        ("base", "git.index.base"),
+        ("fun", "git.index.fun"),
+        ("typ", "git.index.typ"),
+    ],
+)
+
+
+@_parametrize_by_private_alias
+def test_private_module_alias_access_resolves(name: str, fullname: str) -> None:
+    """These resolve for now, though they're private we do not guarantee this."""
+    import git
+
+    assert getattr(git, name) is importlib.import_module(fullname)
+
+
+@_parametrize_by_private_alias
+def test_private_module_alias_import_resolves(name: str, fullname: str) -> None:
+    exec(f"from git import {name}")
+    locals()[name] is importlib.import_module(fullname)
+
+
+@_parametrize_by_private_alias
+def test_private_module_alias_access_warns(name: str, fullname: str) -> None:
+    import git
+
+    with pytest.deprecated_call() as ctx:
+        getattr(git, name)
+
+    assert len(ctx) == 1
+    assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
+
+
+@_parametrize_by_private_alias
+def test_private_module_alias_import_warns(name: str, fullname: str) -> None:
+    with pytest.deprecated_call() as ctx:
+        exec(f"from git import {name}")
+
+    assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")

From 46a739da24331849323a7c583ffd61a51583d3c1 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 20 Mar 2024 20:20:04 -0400
Subject: [PATCH 054/332] Hoist `import git` to module level in test module

Because it's going to be necessary to express things in terms of it
in parametrization markings, in order for mypy to show the expected
errors for names that are available dynamically but deliberately
static type errors.
---
 test/deprecation/test_attributes.py | 13 ++-----------
 1 file changed, 2 insertions(+), 11 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 428dab236..85aa7a579 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -4,10 +4,10 @@
 
 import pytest
 
+import git
 
-def test_cannot_get_undefined() -> None:
-    import git
 
+def test_cannot_get_undefined() -> None:
     with pytest.raises(AttributeError):
         git.foo
 
@@ -19,21 +19,16 @@ def test_cannot_import_undefined() -> None:
 
 def test_util_alias_access_resolves() -> None:
     """These resolve for now, though they're private we do not guarantee this."""
-    import git
-
     assert git.util is git.index.util
 
 
 def test_util_alias_import_resolves() -> None:
     from git import util
-    import git
 
     util is git.index.util
 
 
 def test_util_alias_access_warns() -> None:
-    import git
-
     with pytest.deprecated_call() as ctx:
         git.util
 
@@ -72,8 +67,6 @@ def test_util_alias_import_warns() -> None:
 @_parametrize_by_private_alias
 def test_private_module_alias_access_resolves(name: str, fullname: str) -> None:
     """These resolve for now, though they're private we do not guarantee this."""
-    import git
-
     assert getattr(git, name) is importlib.import_module(fullname)
 
 
@@ -85,8 +78,6 @@ def test_private_module_alias_import_resolves(name: str, fullname: str) -> None:
 
 @_parametrize_by_private_alias
 def test_private_module_alias_access_warns(name: str, fullname: str) -> None:
-    import git
-
     with pytest.deprecated_call() as ctx:
         getattr(git, name)
 

From a2df3a8283274dda9236d0d41cf44a38317560cb Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 20 Mar 2024 20:40:17 -0400
Subject: [PATCH 055/332] Test static typing of private module aliases

This tests that mypy considers them not to be present.

That mypy is configured with `warn_unused_ignores = true` is key,
since that is what verifies that the type errors really do occur,
based on the suppressions written for them.
---
 test/deprecation/test_attributes.py | 19 +++++++++++++++++--
 1 file changed, 17 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 85aa7a579..53612bde2 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -9,12 +9,12 @@
 
 def test_cannot_get_undefined() -> None:
     with pytest.raises(AttributeError):
-        git.foo
+        git.foo  # type: ignore[attr-defined]
 
 
 def test_cannot_import_undefined() -> None:
     with pytest.raises(ImportError):
-        from git import foo  # noqa: F401
+        from git import foo  # type: ignore[attr-defined]  # noqa: F401
 
 
 def test_util_alias_access_resolves() -> None:
@@ -49,6 +49,21 @@ def test_util_alias_import_warns() -> None:
     assert "should not be relied on" in message
 
 
+def test_private_module_aliases() -> None:
+    """These exist dynamically but mypy will show them as absent (intentionally).
+
+    More detailed dynamic behavior is examined in the subsequent test cases.
+    """
+    git.head  # type: ignore[attr-defined]
+    git.log  # type: ignore[attr-defined]
+    git.reference  # type: ignore[attr-defined]
+    git.symbolic  # type: ignore[attr-defined]
+    git.tag  # type: ignore[attr-defined]
+    git.base  # type: ignore[attr-defined]
+    git.fun  # type: ignore[attr-defined]
+    git.typ  # type: ignore[attr-defined]
+
+
 _parametrize_by_private_alias = pytest.mark.parametrize(
     "name, fullname",
     [

From a15a830d47089ba625374fa3fd65e8568b7e0372 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 20 Mar 2024 20:52:56 -0400
Subject: [PATCH 056/332] Improve a couple test case docstrings

---
 test/deprecation/test_attributes.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 53612bde2..6df1359f5 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -18,7 +18,7 @@ def test_cannot_import_undefined() -> None:
 
 
 def test_util_alias_access_resolves() -> None:
-    """These resolve for now, though they're private we do not guarantee this."""
+    """These resolve for now, though they're private and we do not guarantee this."""
     assert git.util is git.index.util
 
 
@@ -50,7 +50,7 @@ def test_util_alias_import_warns() -> None:
 
 
 def test_private_module_aliases() -> None:
-    """These exist dynamically but mypy will show them as absent (intentionally).
+    """These exist dynamically (for now) but mypy treats them as absent (intentionally).
 
     More detailed dynamic behavior is examined in the subsequent test cases.
     """

From dbaa535e47b61db0f9ce29c65b2a6616f6454196 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 20 Mar 2024 20:57:05 -0400
Subject: [PATCH 057/332] Add a couple missing assert keywords

---
 test/deprecation/test_attributes.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 6df1359f5..b9ca1d7c2 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -25,7 +25,7 @@ def test_util_alias_access_resolves() -> None:
 def test_util_alias_import_resolves() -> None:
     from git import util
 
-    util is git.index.util
+    assert util is git.index.util
 
 
 def test_util_alias_access_warns() -> None:
@@ -88,7 +88,7 @@ def test_private_module_alias_access_resolves(name: str, fullname: str) -> None:
 @_parametrize_by_private_alias
 def test_private_module_alias_import_resolves(name: str, fullname: str) -> None:
     exec(f"from git import {name}")
-    locals()[name] is importlib.import_module(fullname)
+    assert locals()[name] is importlib.import_module(fullname)
 
 
 @_parametrize_by_private_alias

From d00c843434806389bfb0bf7992505f358e97513f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 20 Mar 2024 21:00:05 -0400
Subject: [PATCH 058/332] Clarify how test_private_module_aliases is statically
 checkable

---
 test/deprecation/test_attributes.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index b9ca1d7c2..386ae1838 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -52,6 +52,9 @@ def test_util_alias_import_warns() -> None:
 def test_private_module_aliases() -> None:
     """These exist dynamically (for now) but mypy treats them as absent (intentionally).
 
+    This code verifies the effect of static type checking when analyzed by mypy, if mypy
+    is configured with ``warn_unused_ignores = true``.
+
     More detailed dynamic behavior is examined in the subsequent test cases.
     """
     git.head  # type: ignore[attr-defined]

From 983fda774a6eedda3b62ac2fa94ce54675a5f662 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 21 Mar 2024 02:04:21 -0400
Subject: [PATCH 059/332] Move mark-sharing tests into a class

---
 test/deprecation/test_attributes.py | 46 +++++++++++++----------------
 1 file changed, 20 insertions(+), 26 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 386ae1838..7af77000f 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -49,8 +49,8 @@ def test_util_alias_import_warns() -> None:
     assert "should not be relied on" in message
 
 
-def test_private_module_aliases() -> None:
-    """These exist dynamically (for now) but mypy treats them as absent (intentionally).
+def test_private_module_aliases_exist_dynamically() -> None:
+    """These exist at runtime (for now) but mypy treats them as absent (intentionally).
 
     This code verifies the effect of static type checking when analyzed by mypy, if mypy
     is configured with ``warn_unused_ignores = true``.
@@ -67,7 +67,7 @@ def test_private_module_aliases() -> None:
     git.typ  # type: ignore[attr-defined]
 
 
-_parametrize_by_private_alias = pytest.mark.parametrize(
+@pytest.mark.parametrize(
     "name, fullname",
     [
         ("head", "git.refs.head"),
@@ -80,32 +80,26 @@ def test_private_module_aliases() -> None:
         ("typ", "git.index.typ"),
     ],
 )
+class TestPrivateModuleAliases:
+    """Tests of the private module aliases' shared specific runtime behaviors."""
 
+    def test_private_module_alias_access_resolves(self, name: str, fullname: str) -> None:
+        """These resolve for now, though they're private we do not guarantee this."""
+        assert getattr(git, name) is importlib.import_module(fullname)
 
-@_parametrize_by_private_alias
-def test_private_module_alias_access_resolves(name: str, fullname: str) -> None:
-    """These resolve for now, though they're private we do not guarantee this."""
-    assert getattr(git, name) is importlib.import_module(fullname)
-
-
-@_parametrize_by_private_alias
-def test_private_module_alias_import_resolves(name: str, fullname: str) -> None:
-    exec(f"from git import {name}")
-    assert locals()[name] is importlib.import_module(fullname)
-
-
-@_parametrize_by_private_alias
-def test_private_module_alias_access_warns(name: str, fullname: str) -> None:
-    with pytest.deprecated_call() as ctx:
-        getattr(git, name)
+    def test_private_module_alias_import_resolves(self, name: str, fullname: str) -> None:
+        exec(f"from git import {name}")
+        assert locals()[name] is importlib.import_module(fullname)
 
-    assert len(ctx) == 1
-    assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
+    def test_private_module_alias_access_warns(self, name: str, fullname: str) -> None:
+        with pytest.deprecated_call() as ctx:
+            getattr(git, name)
 
+        assert len(ctx) == 1
+        assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
 
-@_parametrize_by_private_alias
-def test_private_module_alias_import_warns(name: str, fullname: str) -> None:
-    with pytest.deprecated_call() as ctx:
-        exec(f"from git import {name}")
+    def test_private_module_alias_import_warns(self, name: str, fullname: str) -> None:
+        with pytest.deprecated_call() as ctx:
+            exec(f"from git import {name}")
 
-    assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
+        assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")

From 19acd4cf551dfd6c7774e1ca7794ef83b321c8b6 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 22 Mar 2024 01:33:00 -0400
Subject: [PATCH 060/332] Add FIXME for what to do next

---
 test/deprecation/test_attributes.py | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 7af77000f..f249ae871 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -103,3 +103,14 @@ def test_private_module_alias_import_warns(self, name: str, fullname: str) -> No
             exec(f"from git import {name}")
 
         assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
+
+
+reveal_type(git.util.git_working_dir)
+
+# FIXME: Add one or more test cases that access something like git.util.git_working_dir
+# to verify that it is available, and also use assert_type on it to ensure mypy knows
+# that accesses to expressions of the form git.util.XYZ resolve to git.index.util.XYZ.
+#
+# It may be necessary for GitPython, in git/__init__.py, to import util from git.index
+# explicitly before (still) deleting the util global, in order for mypy to know what is
+# going on. Also check pyright.

From f39bbb5172987b0462b5d1845c0cb0cf3824b3d5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 22 Mar 2024 10:52:32 -0400
Subject: [PATCH 061/332] Fix a test docstring

---
 test/deprecation/test_attributes.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index f249ae871..69a1aa1f3 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -84,7 +84,7 @@ class TestPrivateModuleAliases:
     """Tests of the private module aliases' shared specific runtime behaviors."""
 
     def test_private_module_alias_access_resolves(self, name: str, fullname: str) -> None:
-        """These resolve for now, though they're private we do not guarantee this."""
+        """These resolve for now, though they're private and we do not guarantee this."""
         assert getattr(git, name) is importlib.import_module(fullname)
 
     def test_private_module_alias_import_resolves(self, name: str, fullname: str) -> None:

From aee7078e28b5de9bb5cd605ff23f37d7328dccc9 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 22 Mar 2024 18:56:02 -0400
Subject: [PATCH 062/332] Test resolution into git.index.util using git.util

---
 test/deprecation/test_attributes.py | 29 ++++++++++++++++++-----------
 1 file changed, 18 insertions(+), 11 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 69a1aa1f3..74f51a09e 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -1,6 +1,7 @@
 """Tests for dynamic and static attribute errors."""
 
 import importlib
+from typing import Type
 
 import pytest
 
@@ -28,6 +29,23 @@ def test_util_alias_import_resolves() -> None:
     assert util is git.index.util
 
 
+def test_util_alias_members_resolve() -> None:
+    """git.index.util members can be accessed via git.util, and mypy recognizes it."""
+    # TODO: When typing_extensions is made a test dependency, use assert_type for this.
+    gu_tfs = git.util.TemporaryFileSwap
+    from git.index.util import TemporaryFileSwap
+
+    def accepts_tfs_type(t: Type[TemporaryFileSwap]) -> None:
+        pass
+
+    def rejects_tfs_type(t: Type[git.Git]) -> None:
+        pass
+
+    accepts_tfs_type(gu_tfs)
+    rejects_tfs_type(gu_tfs)  # type: ignore[arg-type]
+    assert gu_tfs is TemporaryFileSwap
+
+
 def test_util_alias_access_warns() -> None:
     with pytest.deprecated_call() as ctx:
         git.util
@@ -103,14 +121,3 @@ def test_private_module_alias_import_warns(self, name: str, fullname: str) -> No
             exec(f"from git import {name}")
 
         assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
-
-
-reveal_type(git.util.git_working_dir)
-
-# FIXME: Add one or more test cases that access something like git.util.git_working_dir
-# to verify that it is available, and also use assert_type on it to ensure mypy knows
-# that accesses to expressions of the form git.util.XYZ resolve to git.index.util.XYZ.
-#
-# It may be necessary for GitPython, in git/__init__.py, to import util from git.index
-# explicitly before (still) deleting the util global, in order for mypy to know what is
-# going on. Also check pyright.

From 7f4a19135c755065538d13ed4faeb9c10cc203c8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 22 Mar 2024 19:01:11 -0400
Subject: [PATCH 063/332] Fix brittle way of checking warning messages

Which was causing a type error.
---
 test/deprecation/test_attributes.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 74f51a09e..0f142fbe7 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -51,7 +51,7 @@ def test_util_alias_access_warns() -> None:
         git.util
 
     assert len(ctx) == 1
-    message = ctx[0].message.args[0]
+    message = str(ctx[0].message)
     assert "git.util" in message
     assert "git.index.util" in message
     assert "should not be relied on" in message
@@ -61,7 +61,7 @@ def test_util_alias_import_warns() -> None:
     with pytest.deprecated_call() as ctx:
         from git import util  # noqa: F401
 
-    message = ctx[0].message.args[0]
+    message = str(ctx[0].message)
     assert "git.util" in message
     assert "git.index.util" in message
     assert "should not be relied on" in message
@@ -114,10 +114,12 @@ def test_private_module_alias_access_warns(self, name: str, fullname: str) -> No
             getattr(git, name)
 
         assert len(ctx) == 1
-        assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
+        message = str(ctx[0].message)
+        assert message.endswith(f"Use {fullname} instead.")
 
     def test_private_module_alias_import_warns(self, name: str, fullname: str) -> None:
         with pytest.deprecated_call() as ctx:
             exec(f"from git import {name}")
 
-        assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.")
+        message = str(ctx[0].message)
+        assert message.endswith(f"Use {fullname} instead.")

From d08a5768f6e8aa78de5f10c7e7a0777d2e4dfec3 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 22 Mar 2024 19:09:44 -0400
Subject: [PATCH 064/332] Clarify todo

---
 test/deprecation/test_attributes.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 0f142fbe7..829ff29d2 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -31,7 +31,6 @@ def test_util_alias_import_resolves() -> None:
 
 def test_util_alias_members_resolve() -> None:
     """git.index.util members can be accessed via git.util, and mypy recognizes it."""
-    # TODO: When typing_extensions is made a test dependency, use assert_type for this.
     gu_tfs = git.util.TemporaryFileSwap
     from git.index.util import TemporaryFileSwap
 
@@ -41,8 +40,10 @@ def accepts_tfs_type(t: Type[TemporaryFileSwap]) -> None:
     def rejects_tfs_type(t: Type[git.Git]) -> None:
         pass
 
+    # TODO: When typing_extensions is made a test dependency, use assert_type for this.
     accepts_tfs_type(gu_tfs)
     rejects_tfs_type(gu_tfs)  # type: ignore[arg-type]
+
     assert gu_tfs is TemporaryFileSwap
 
 

From 9d58e6d367dc4651213e674b9f586e0a18d787bc Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 14:35:36 -0400
Subject: [PATCH 065/332] Start reorganizing new tests more in the GitPython
 style

---
 test/deprecation/test_attributes.py | 142 +++++++++++++++-------------
 1 file changed, 77 insertions(+), 65 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 829ff29d2..97aa9bc50 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -1,4 +1,8 @@
-"""Tests for dynamic and static attribute errors."""
+"""Tests for dynamic and static attribute errors in GitPython's top-level git module.
+
+Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases
+checks static typing of the code under test. (Running pytest checks dynamic behavior.)
+"""
 
 import importlib
 from typing import Type
@@ -18,17 +22,6 @@ def test_cannot_import_undefined() -> None:
         from git import foo  # type: ignore[attr-defined]  # noqa: F401
 
 
-def test_util_alias_access_resolves() -> None:
-    """These resolve for now, though they're private and we do not guarantee this."""
-    assert git.util is git.index.util
-
-
-def test_util_alias_import_resolves() -> None:
-    from git import util
-
-    assert util is git.index.util
-
-
 def test_util_alias_members_resolve() -> None:
     """git.index.util members can be accessed via git.util, and mypy recognizes it."""
     gu_tfs = git.util.TemporaryFileSwap
@@ -68,59 +61,78 @@ def test_util_alias_import_warns() -> None:
     assert "should not be relied on" in message
 
 
-def test_private_module_aliases_exist_dynamically() -> None:
-    """These exist at runtime (for now) but mypy treats them as absent (intentionally).
-
-    This code verifies the effect of static type checking when analyzed by mypy, if mypy
-    is configured with ``warn_unused_ignores = true``.
-
-    More detailed dynamic behavior is examined in the subsequent test cases.
-    """
-    git.head  # type: ignore[attr-defined]
-    git.log  # type: ignore[attr-defined]
-    git.reference  # type: ignore[attr-defined]
-    git.symbolic  # type: ignore[attr-defined]
-    git.tag  # type: ignore[attr-defined]
-    git.base  # type: ignore[attr-defined]
-    git.fun  # type: ignore[attr-defined]
-    git.typ  # type: ignore[attr-defined]
-
-
-@pytest.mark.parametrize(
-    "name, fullname",
-    [
-        ("head", "git.refs.head"),
-        ("log", "git.refs.log"),
-        ("reference", "git.refs.reference"),
-        ("symbolic", "git.refs.symbolic"),
-        ("tag", "git.refs.tag"),
-        ("base", "git.index.base"),
-        ("fun", "git.index.fun"),
-        ("typ", "git.index.typ"),
-    ],
+# Split out util and have all its tests be separate, above.
+_MODULE_ALIAS_TARGETS = (
+    git.refs.head,
+    git.refs.log,
+    git.refs.reference,
+    git.refs.symbolic,
+    git.refs.tag,
+    git.index.base,
+    git.index.fun,
+    git.index.typ,
+    git.index.util,
 )
-class TestPrivateModuleAliases:
-    """Tests of the private module aliases' shared specific runtime behaviors."""
 
-    def test_private_module_alias_access_resolves(self, name: str, fullname: str) -> None:
-        """These resolve for now, though they're private and we do not guarantee this."""
-        assert getattr(git, name) is importlib.import_module(fullname)
 
-    def test_private_module_alias_import_resolves(self, name: str, fullname: str) -> None:
-        exec(f"from git import {name}")
-        assert locals()[name] is importlib.import_module(fullname)
-
-    def test_private_module_alias_access_warns(self, name: str, fullname: str) -> None:
-        with pytest.deprecated_call() as ctx:
-            getattr(git, name)
-
-        assert len(ctx) == 1
-        message = str(ctx[0].message)
-        assert message.endswith(f"Use {fullname} instead.")
-
-    def test_private_module_alias_import_warns(self, name: str, fullname: str) -> None:
-        with pytest.deprecated_call() as ctx:
-            exec(f"from git import {name}")
-
-        message = str(ctx[0].message)
-        assert message.endswith(f"Use {fullname} instead.")
+def test_private_module_alias_access_on_git_module() -> None:
+    """Private alias access works, warns, and except for util is a mypy error."""
+    with pytest.deprecated_call() as ctx:
+        assert (
+            git.head,  # type: ignore[attr-defined]
+            git.log,  # type: ignore[attr-defined]
+            git.reference,  # type: ignore[attr-defined]
+            git.symbolic,  # type: ignore[attr-defined]
+            git.tag,  # type: ignore[attr-defined]
+            git.base,  # type: ignore[attr-defined]
+            git.fun,  # type: ignore[attr-defined]
+            git.typ,  # type: ignore[attr-defined]
+            git.util,
+        ) == _MODULE_ALIAS_TARGETS
+
+    messages = [str(w.message) for w in ctx]
+    for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True):
+        assert message.endswith(f"Use {target.__name__} instead.")
+
+    util_message = messages[-1]
+    assert "git.util" in util_message
+    assert "git.index.util" in util_message
+    assert "should not be relied on" in util_message
+
+
+def test_private_module_alias_import_from_git_module() -> None:
+    """Private alias import works, warns, and except for util is a mypy error."""
+    with pytest.deprecated_call() as ctx:
+        from git import head  # type: ignore[attr-defined]
+        from git import log  # type: ignore[attr-defined]
+        from git import reference  # type: ignore[attr-defined]
+        from git import symbolic  # type: ignore[attr-defined]
+        from git import tag  # type: ignore[attr-defined]
+        from git import base  # type: ignore[attr-defined]
+        from git import fun  # type: ignore[attr-defined]
+        from git import typ  # type: ignore[attr-defined]
+        from git import util
+
+    assert (
+        head,
+        log,
+        reference,
+        symbolic,
+        tag,
+        base,
+        fun,
+        typ,
+        util,
+    ) == _MODULE_ALIAS_TARGETS
+
+    # FIXME: This fails because, with imports, multiple consecutive accesses may occur.
+    # In practice, with CPython, it is always exactly two accesses, the first from the
+    # equivalent of a hasattr, and the second to fetch the attribute intentionally.
+    messages = [str(w.message) for w in ctx]
+    for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True):
+        assert message.endswith(f"Use {target.__name__} instead.")
+
+    util_message = messages[-1]
+    assert "git.util" in util_message
+    assert "git.index.util" in util_message
+    assert "should not be relied on" in util_message

From 45c128bcd82cd278d7926425179503c22ce1271c Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 15:50:39 -0400
Subject: [PATCH 066/332] Finish reorganizing; fix assertion for duplicated
 messages

To support the changes, this adds typing-extensions as a test
dependency, since we are now importing from it in a test module.
But this should probably be required and used conditionally based
on whether the Python version has assert_type in its typing module.
---
 test-requirements.txt               |  1 +
 test/deprecation/test_attributes.py | 98 ++++++++++++++---------------
 2 files changed, 50 insertions(+), 49 deletions(-)

diff --git a/test-requirements.txt b/test-requirements.txt
index e1f5e2ed4..106b46aee 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -8,3 +8,4 @@ pytest-cov
 pytest-instafail
 pytest-mock
 pytest-sugar
+typing-extensions
diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 97aa9bc50..35e2e48bb 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -4,61 +4,72 @@
 checks static typing of the code under test. (Running pytest checks dynamic behavior.)
 """
 
-import importlib
+from itertools import groupby
 from typing import Type
 
 import pytest
+from typing_extensions import assert_type
 
 import git
 
 
-def test_cannot_get_undefined() -> None:
+def test_cannot_access_undefined() -> None:
+    """Accessing a bogus attribute in git remains both a dynamic and static error."""
     with pytest.raises(AttributeError):
         git.foo  # type: ignore[attr-defined]
 
 
 def test_cannot_import_undefined() -> None:
+    """Importing a bogus attribute from git remains both a dynamic and static error."""
     with pytest.raises(ImportError):
         from git import foo  # type: ignore[attr-defined]  # noqa: F401
 
 
-def test_util_alias_members_resolve() -> None:
-    """git.index.util members can be accessed via git.util, and mypy recognizes it."""
-    gu_tfs = git.util.TemporaryFileSwap
-    from git.index.util import TemporaryFileSwap
+def test_util_alias_access() -> None:
+    """Accessing util in git works, warns, and mypy verifies it and its attributes."""
+    # The attribute access should succeed.
+    with pytest.deprecated_call() as ctx:
+        util = git.util
 
-    def accepts_tfs_type(t: Type[TemporaryFileSwap]) -> None:
-        pass
+    # There should be exactly one warning and it should have our util-specific message.
+    (message,) = [str(entry.message) for entry in ctx]
+    assert "git.util" in message
+    assert "git.index.util" in message
+    assert "should not be relied on" in message
 
-    def rejects_tfs_type(t: Type[git.Git]) -> None:
-        pass
+    # We check access through the util alias to the TemporaryFileSwap member, since it
+    # is slightly simpler to validate and reason about than the other public members,
+    # which are functions (specifically, higher-order functions for use as decorators).
+    from git.index.util import TemporaryFileSwap
 
-    # TODO: When typing_extensions is made a test dependency, use assert_type for this.
-    accepts_tfs_type(gu_tfs)
-    rejects_tfs_type(gu_tfs)  # type: ignore[arg-type]
+    assert_type(util.TemporaryFileSwap, Type[TemporaryFileSwap])
 
-    assert gu_tfs is TemporaryFileSwap
+    # This comes after the static assertion, just in case it would affect the inference.
+    assert util.TemporaryFileSwap is TemporaryFileSwap
 
 
-def test_util_alias_access_warns() -> None:
+def test_util_alias_import() -> None:
+    """Importing util from git works, warns, and mypy verifies it and its attributes."""
+    # The import should succeed.
     with pytest.deprecated_call() as ctx:
-        git.util
+        from git import util
 
-    assert len(ctx) == 1
-    message = str(ctx[0].message)
+    # There may be multiple warnings. In CPython there will be currently always be
+    # exactly two, possibly due to the equivalent of calling hasattr to do a pre-check
+    # prior to retrieving the attribute for actual use. However, all warnings should
+    # have the same message, and it should be our util-specific message.
+    (message,) = {str(entry.message) for entry in ctx}
     assert "git.util" in message
     assert "git.index.util" in message
     assert "should not be relied on" in message
 
+    # As above, we check access through the util alias to the TemporaryFileSwap member.
+    from git.index.util import TemporaryFileSwap
 
-def test_util_alias_import_warns() -> None:
-    with pytest.deprecated_call() as ctx:
-        from git import util  # noqa: F401
+    assert_type(util.TemporaryFileSwap, Type[TemporaryFileSwap])
 
-    message = str(ctx[0].message)
-    assert "git.util" in message
-    assert "git.index.util" in message
-    assert "should not be relied on" in message
+    # This comes after the static assertion, just in case it would affect the inference.
+    assert util.TemporaryFileSwap is TemporaryFileSwap
 
 
 # Split out util and have all its tests be separate, above.
@@ -71,12 +82,11 @@ def test_util_alias_import_warns() -> None:
     git.index.base,
     git.index.fun,
     git.index.typ,
-    git.index.util,
 )
 
 
-def test_private_module_alias_access_on_git_module() -> None:
-    """Private alias access works, warns, and except for util is a mypy error."""
+def test_private_module_alias_access() -> None:
+    """Non-util private alias access works, warns, but is a deliberate mypy error."""
     with pytest.deprecated_call() as ctx:
         assert (
             git.head,  # type: ignore[attr-defined]
@@ -87,21 +97,16 @@ def test_private_module_alias_access_on_git_module() -> None:
             git.base,  # type: ignore[attr-defined]
             git.fun,  # type: ignore[attr-defined]
             git.typ,  # type: ignore[attr-defined]
-            git.util,
         ) == _MODULE_ALIAS_TARGETS
 
+    # Each should have warned exactly once, and note what to use instead.
     messages = [str(w.message) for w in ctx]
-    for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True):
+    for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True):
         assert message.endswith(f"Use {target.__name__} instead.")
 
-    util_message = messages[-1]
-    assert "git.util" in util_message
-    assert "git.index.util" in util_message
-    assert "should not be relied on" in util_message
 
-
-def test_private_module_alias_import_from_git_module() -> None:
-    """Private alias import works, warns, and except for util is a mypy error."""
+def test_private_module_alias_import() -> None:
+    """Non-util private alias access works, warns, but is a deliberate mypy error."""
     with pytest.deprecated_call() as ctx:
         from git import head  # type: ignore[attr-defined]
         from git import log  # type: ignore[attr-defined]
@@ -111,7 +116,6 @@ def test_private_module_alias_import_from_git_module() -> None:
         from git import base  # type: ignore[attr-defined]
         from git import fun  # type: ignore[attr-defined]
         from git import typ  # type: ignore[attr-defined]
-        from git import util
 
     assert (
         head,
@@ -122,17 +126,13 @@ def test_private_module_alias_import_from_git_module() -> None:
         base,
         fun,
         typ,
-        util,
     ) == _MODULE_ALIAS_TARGETS
 
-    # FIXME: This fails because, with imports, multiple consecutive accesses may occur.
-    # In practice, with CPython, it is always exactly two accesses, the first from the
-    # equivalent of a hasattr, and the second to fetch the attribute intentionally.
-    messages = [str(w.message) for w in ctx]
-    for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True):
+    # Each import may warn multiple times. In CPython there will be currently always be
+    # exactly two warnings per import, possibly due to the equivalent of calling hasattr
+    # to do a pre-check prior to retrieving the attribute for actual use. However, for
+    # each import, all messages should be the same and should note what to use instead.
+    messages_with_duplicates = [str(w.message) for w in ctx]
+    messages = [message for message, _ in groupby(messages_with_duplicates)]
+    for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True):
         assert message.endswith(f"Use {target.__name__} instead.")
-
-    util_message = messages[-1]
-    assert "git.util" in util_message
-    assert "git.index.util" in util_message
-    assert "should not be relied on" in util_message

From 247dc15fd81ecc806be732d7f1ef0c12e26920d8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 15:55:57 -0400
Subject: [PATCH 067/332] Add imports so pyright recognizes refs and index

pyright still reports git.util as private, as it should.

(mypy does not, or does not by default, report private member
access. GitPython does not generally use pyright as part of
development at this time, but I am checking some code with it
during the process of writing the new tests.)
---
 test/deprecation/test_attributes.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 35e2e48bb..95feaaf8e 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -11,6 +11,14 @@
 from typing_extensions import assert_type
 
 import git
+import git.index.base
+import git.index.fun
+import git.index.typ
+import git.refs.head
+import git.refs.log
+import git.refs.reference
+import git.refs.symbolic
+import git.refs.tag
 
 
 def test_cannot_access_undefined() -> None:

From b05963c3749494f633117316a493ab2b179e0069 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 16:05:53 -0400
Subject: [PATCH 068/332] Expand and clarify test module docstring

About why there are so many separate mypy suppressions even when
they could be consolidated into a smaller number in some places.
---
 test/deprecation/test_attributes.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 95feaaf8e..218b9dd13 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -1,7 +1,11 @@
 """Tests for dynamic and static attribute errors in GitPython's top-level git module.
 
 Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases
-checks static typing of the code under test. (Running pytest checks dynamic behavior.)
+checks static typing of the code under test. This is the reason for the many separate
+single-line attr-defined suppressions, so those should not be replaced with a smaller
+number of more broadly scoped suppressions, even where it is feasible to do so.
+
+Running pytest checks dynamic behavior as usual.
 """
 
 from itertools import groupby

From 074bbc72a84db7605f3136dc9f4b4ad822a3b481 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 16:08:37 -0400
Subject: [PATCH 069/332] Tiny import tweak

---
 test/deprecation/test_attributes.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 218b9dd13..1bcca44d5 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -8,7 +8,7 @@
 Running pytest checks dynamic behavior as usual.
 """
 
-from itertools import groupby
+import itertools
 from typing import Type
 
 import pytest
@@ -145,6 +145,6 @@ def test_private_module_alias_import() -> None:
     # to do a pre-check prior to retrieving the attribute for actual use. However, for
     # each import, all messages should be the same and should note what to use instead.
     messages_with_duplicates = [str(w.message) for w in ctx]
-    messages = [message for message, _ in groupby(messages_with_duplicates)]
+    messages = [message for message, _ in itertools.groupby(messages_with_duplicates)]
     for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True):
         assert message.endswith(f"Use {target.__name__} instead.")

From 18608e472535149e542cc30ff95755ed962c9156 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 16:12:59 -0400
Subject: [PATCH 070/332] Pick a better name for _MODULE_ALIAS_TARGETS

And add a docstring to document it, mainly to clarify that util is
intentionally omitted from that constant.
---
 test/deprecation/test_attributes.py | 11 ++++++-----
 1 file changed, 6 insertions(+), 5 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 1bcca44d5..6e98a5e09 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -85,7 +85,7 @@ def test_util_alias_import() -> None:
 
 
 # Split out util and have all its tests be separate, above.
-_MODULE_ALIAS_TARGETS = (
+_PRIVATE_MODULE_ALIAS_TARGETS = (
     git.refs.head,
     git.refs.log,
     git.refs.reference,
@@ -95,6 +95,7 @@ def test_util_alias_import() -> None:
     git.index.fun,
     git.index.typ,
 )
+"""Targets of private aliases in the git module to some modules, not including util."""
 
 
 def test_private_module_alias_access() -> None:
@@ -109,11 +110,11 @@ def test_private_module_alias_access() -> None:
             git.base,  # type: ignore[attr-defined]
             git.fun,  # type: ignore[attr-defined]
             git.typ,  # type: ignore[attr-defined]
-        ) == _MODULE_ALIAS_TARGETS
+        ) == _PRIVATE_MODULE_ALIAS_TARGETS
 
     # Each should have warned exactly once, and note what to use instead.
     messages = [str(w.message) for w in ctx]
-    for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True):
+    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True):
         assert message.endswith(f"Use {target.__name__} instead.")
 
 
@@ -138,7 +139,7 @@ def test_private_module_alias_import() -> None:
         base,
         fun,
         typ,
-    ) == _MODULE_ALIAS_TARGETS
+    ) == _PRIVATE_MODULE_ALIAS_TARGETS
 
     # Each import may warn multiple times. In CPython there will be currently always be
     # exactly two warnings per import, possibly due to the equivalent of calling hasattr
@@ -146,5 +147,5 @@ def test_private_module_alias_import() -> None:
     # each import, all messages should be the same and should note what to use instead.
     messages_with_duplicates = [str(w.message) for w in ctx]
     messages = [message for message, _ in itertools.groupby(messages_with_duplicates)]
-    for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True):
+    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True):
         assert message.endswith(f"Use {target.__name__} instead.")

From 1f290f17943be338bb79a54ce1bef21e90da4402 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 16:36:22 -0400
Subject: [PATCH 071/332] Use typing_extensions only if needed

This makes the typing-extensions test dependency < 3.11 only, and
conditionally imports assert_type from typing or typing_extensions
depending on the Python version.
---
 test-requirements.txt               | 2 +-
 test/deprecation/test_attributes.py | 6 ++++++
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/test-requirements.txt b/test-requirements.txt
index 106b46aee..75e9e81fa 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -8,4 +8,4 @@ pytest-cov
 pytest-instafail
 pytest-mock
 pytest-sugar
-typing-extensions
+typing-extensions ; python_version < "3.11"
diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 6e98a5e09..2150186f9 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -9,8 +9,14 @@
 """
 
 import itertools
+import sys
 from typing import Type
 
+if sys.version_info >= (3, 11):
+    from typing import assert_type
+else:
+    from typing_extensions import assert_type
+
 import pytest
 from typing_extensions import assert_type
 

From 7a4f7eb092fbf68b058de38ee2df193c86d4e34e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 17:07:32 -0400
Subject: [PATCH 072/332] Fix zip calls

This omits strict=True, which is only supported in Python 3.10 and
later, and instead explicitly asserts that the arguments are the
same length (which is arguably better for its explicitness anyway).
---
 test/deprecation/test_attributes.py | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index 2150186f9..cd26f602b 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -120,7 +120,10 @@ def test_private_module_alias_access() -> None:
 
     # Each should have warned exactly once, and note what to use instead.
     messages = [str(w.message) for w in ctx]
-    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True):
+
+    assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS)
+
+    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages):
         assert message.endswith(f"Use {target.__name__} instead.")
 
 
@@ -153,5 +156,8 @@ def test_private_module_alias_import() -> None:
     # each import, all messages should be the same and should note what to use instead.
     messages_with_duplicates = [str(w.message) for w in ctx]
     messages = [message for message, _ in itertools.groupby(messages_with_duplicates)]
-    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True):
+
+    assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS)
+
+    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages):
         assert message.endswith(f"Use {target.__name__} instead.")

From 5977a6ec9e1646ac94514428a0ad2363be8da2f9 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 17:19:39 -0400
Subject: [PATCH 073/332] Fix (and improve wording) of docstrings

---
 test/deprecation/test_attributes.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index cd26f602b..e4fb39975 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -105,7 +105,7 @@ def test_util_alias_import() -> None:
 
 
 def test_private_module_alias_access() -> None:
-    """Non-util private alias access works, warns, but is a deliberate mypy error."""
+    """Non-util private alias access works but warns and is a deliberate mypy error."""
     with pytest.deprecated_call() as ctx:
         assert (
             git.head,  # type: ignore[attr-defined]
@@ -128,7 +128,7 @@ def test_private_module_alias_access() -> None:
 
 
 def test_private_module_alias_import() -> None:
-    """Non-util private alias access works, warns, but is a deliberate mypy error."""
+    """Non-util private alias import works but warns and is a deliberate mypy error."""
     with pytest.deprecated_call() as ctx:
         from git import head  # type: ignore[attr-defined]
         from git import log  # type: ignore[attr-defined]

From 5b1fa580400c386932eb9f66c568e4e0090e2779 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 17:46:57 -0400
Subject: [PATCH 074/332] Remove extra import "from typing_extensions"

---
 test/deprecation/test_attributes.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py
index e4fb39975..eb909b299 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_attributes.py
@@ -18,7 +18,6 @@
     from typing_extensions import assert_type
 
 import pytest
-from typing_extensions import assert_type
 
 import git
 import git.index.base

From a07be0e35118874f682fa2e2be3b793170bf4853 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:12:19 -0400
Subject: [PATCH 075/332] Start on test_compat

And rename test_attributes to test_toplevel accordingly.
---
 test/deprecation/test_compat.py               | 33 +++++++++++++++++++
 .../{test_attributes.py => test_toplevel.py}  |  6 ++--
 2 files changed, 36 insertions(+), 3 deletions(-)
 create mode 100644 test/deprecation/test_compat.py
 rename test/deprecation/{test_attributes.py => test_toplevel.py} (95%)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
new file mode 100644
index 000000000..dd2f0b0c2
--- /dev/null
+++ b/test/deprecation/test_compat.py
@@ -0,0 +1,33 @@
+"""Tests for dynamic and static errors and warnings in GitPython's git.compat module.
+
+These tests verify that the is_<platform> aliases are available, and are even listed in
+the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized)
+attribute access is still an error both at runtime and with mypy. This is similar to
+some of the tests in test_toplevel, but the situation being tested here is simpler
+because it does not involve unintuitive module aliasing or import behavior. So this only
+tests attribute access, not "from" imports (whose behavior can be intuitively inferred).
+"""
+
+import os
+import sys
+
+import pytest
+
+import git.compat
+
+
+_MESSAGE_LEADER = "{} and other is_<platform> aliases are deprecated."
+
+
+def test_cannot_access_undefined() -> None:
+    """Accessing a bogus attribute in git.compat remains a dynamic and static error."""
+    with pytest.raises(AttributeError):
+        git.compat.foo  # type: ignore[attr-defined]
+
+
+def test_is_win() -> None:
+    with pytest.deprecated_call() as ctx:
+        value = git.compat.is_win
+    (message,) = [str(entry.message) for entry in ctx]  # Exactly one message.
+    assert message.startswith(_MESSAGE_LEADER.format("git.compat.is_win"))
+    assert value == (os.name == "nt")
diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_toplevel.py
similarity index 95%
rename from test/deprecation/test_attributes.py
rename to test/deprecation/test_toplevel.py
index eb909b299..2a662127b 100644
--- a/test/deprecation/test_attributes.py
+++ b/test/deprecation/test_toplevel.py
@@ -1,4 +1,4 @@
-"""Tests for dynamic and static attribute errors in GitPython's top-level git module.
+"""Tests for dynamic and static errors and warnings in GitPython's top-level git module.
 
 Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases
 checks static typing of the code under test. This is the reason for the many separate
@@ -31,13 +31,13 @@
 
 
 def test_cannot_access_undefined() -> None:
-    """Accessing a bogus attribute in git remains both a dynamic and static error."""
+    """Accessing a bogus attribute in git remains a dynamic and static error."""
     with pytest.raises(AttributeError):
         git.foo  # type: ignore[attr-defined]
 
 
 def test_cannot_import_undefined() -> None:
-    """Importing a bogus attribute from git remains both a dynamic and static error."""
+    """Importing a bogus attribute from git remains a dynamic and static error."""
     with pytest.raises(ImportError):
         from git import foo  # type: ignore[attr-defined]  # noqa: F401
 

From d4917d0a326d935ee0d6728af3268e9ece8b09df Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:19:36 -0400
Subject: [PATCH 076/332] Expand to test all three is_<platform> aliases

---
 test/deprecation/test_compat.py | 30 +++++++++++++++++++++++++-----
 1 file changed, 25 insertions(+), 5 deletions(-)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index dd2f0b0c2..6d2d87a39 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -25,9 +25,29 @@ def test_cannot_access_undefined() -> None:
         git.compat.foo  # type: ignore[attr-defined]
 
 
-def test_is_win() -> None:
+def test_is_platform() -> None:
+    """The is_<platform> aliases work, warn, and mypy accepts code accessing them."""
+    fully_qualified_names = [
+        "git.compat.is_win",
+        "git.compat.is_posix",
+        "git.compat.is_darwin",
+    ]
+
     with pytest.deprecated_call() as ctx:
-        value = git.compat.is_win
-    (message,) = [str(entry.message) for entry in ctx]  # Exactly one message.
-    assert message.startswith(_MESSAGE_LEADER.format("git.compat.is_win"))
-    assert value == (os.name == "nt")
+        is_win = git.compat.is_win
+        is_posix = git.compat.is_posix
+        is_darwin = git.compat.is_darwin
+
+    messages = [str(entry.message) for entry in ctx]
+    assert len(messages) == 3
+
+    for fullname, message in zip(fully_qualified_names, messages):
+        assert message.startswith(_MESSAGE_LEADER.format(fullname))
+
+    # These exactly reproduce the expressions in the code under test, so they are not
+    # good for testing that the values are correct. Instead, the purpose of this test is
+    # to ensure that any dynamic machinery put in place in git.compat to cause warnings
+    # to be issued does not get in the way of the intended values being accessed.
+    assert is_win == (os.name == "nt")
+    assert is_posix == (os.name == "posix")
+    assert is_darwin == (sys.platform == "darwin")

From f4e5f423f019c7d04798c896118f7fd48b8c3155 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:22:30 -0400
Subject: [PATCH 077/332] Slightly improve docstrings

---
 test/deprecation/test_compat.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 6d2d87a39..45d631e37 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -1,7 +1,7 @@
 """Tests for dynamic and static errors and warnings in GitPython's git.compat module.
 
-These tests verify that the is_<platform> aliases are available, and are even listed in
-the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized)
+These tests verify that the is_<platform> attributes are available, and are even listed
+in the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized)
 attribute access is still an error both at runtime and with mypy. This is similar to
 some of the tests in test_toplevel, but the situation being tested here is simpler
 because it does not involve unintuitive module aliasing or import behavior. So this only
@@ -15,8 +15,8 @@
 
 import git.compat
 
-
 _MESSAGE_LEADER = "{} and other is_<platform> aliases are deprecated."
+"""Form taken by the beginning of the warnings issues for is_<platform> access."""
 
 
 def test_cannot_access_undefined() -> None:
@@ -26,7 +26,7 @@ def test_cannot_access_undefined() -> None:
 
 
 def test_is_platform() -> None:
-    """The is_<platform> aliases work, warn, and mypy accepts code accessing them."""
+    """The is_<platform> attributes work, warn, and mypy accepts code accessing them."""
     fully_qualified_names = [
         "git.compat.is_win",
         "git.compat.is_posix",

From d54f851d52f4217e904cbd163032aabbf33ec394 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:27:55 -0400
Subject: [PATCH 078/332] Add test of dir() on git.compat

---
 test/deprecation/test_compat.py | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 45d631e37..08911d17c 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -51,3 +51,17 @@ def test_is_platform() -> None:
     assert is_win == (os.name == "nt")
     assert is_posix == (os.name == "posix")
     assert is_darwin == (sys.platform == "darwin")
+
+
+def test_dir() -> None:
+    """dir() on git.compat lists attributes meant to be public, even if deprecated."""
+    expected = {
+        "defenc",
+        "safe_decode",
+        "safe_encode",
+        "win_encode",
+        "is_darwin",
+        "is_win",
+        "is_posix",
+    }
+    assert expected <= set(dir(git.compat))

From aaf046aba4b46fbe0dddf8c10d31f42b6c4d7c57 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:28:05 -0400
Subject: [PATCH 079/332] Add static type assertions to is_platform test

---
 test/deprecation/test_compat.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 08911d17c..c3cc5b0dd 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -11,6 +11,11 @@
 import os
 import sys
 
+if sys.version_info >= (3, 11):
+    from typing import assert_type
+else:
+    from typing_extensions import assert_type
+
 import pytest
 
 import git.compat
@@ -38,6 +43,10 @@ def test_is_platform() -> None:
         is_posix = git.compat.is_posix
         is_darwin = git.compat.is_darwin
 
+    assert_type(is_win, bool)
+    assert_type(is_posix, bool)
+    assert_type(is_darwin, bool)
+
     messages = [str(entry.message) for entry in ctx]
     assert len(messages) == 3
 

From 84d734d5b88cb8a03ab723f92bf83593d53d030f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:29:23 -0400
Subject: [PATCH 080/332] Refactor test_compat.test_dir for clarity

---
 test/deprecation/test_compat.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index c3cc5b0dd..6e42d0209 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -64,7 +64,7 @@ def test_is_platform() -> None:
 
 def test_dir() -> None:
     """dir() on git.compat lists attributes meant to be public, even if deprecated."""
-    expected = {
+    expected_subset = {
         "defenc",
         "safe_decode",
         "safe_encode",
@@ -73,4 +73,5 @@ def test_dir() -> None:
         "is_win",
         "is_posix",
     }
-    assert expected <= set(dir(git.compat))
+    actual = set(dir(git.compat))
+    assert expected_subset <= actual

From 3a621b38ee98a1d1413a12fcb68aae17d102f396 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:45:31 -0400
Subject: [PATCH 081/332] Add top-level dir() tests

---
 test/deprecation/test_toplevel.py | 49 +++++++++++++++++++++++++++++++
 1 file changed, 49 insertions(+)

diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py
index 2a662127b..f74f09457 100644
--- a/test/deprecation/test_toplevel.py
+++ b/test/deprecation/test_toplevel.py
@@ -160,3 +160,52 @@ def test_private_module_alias_import() -> None:
 
     for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages):
         assert message.endswith(f"Use {target.__name__} instead.")
+
+
+def test_dir_contains_public_attributes() -> None:
+    """All public attributes of the git module are present when dir() is called on it.
+
+    This is naturally the case, but some ways of adding dynamic attribute access
+    behavior can change it, especially if __dir__ is defined but care is not taken to
+    preserve the contents that should already be present.
+
+    Note that dir() should usually automatically list non-public attributes if they are
+    actually "physically" present as well, so the approach taken here to test it should
+    not be reproduced if __dir__ is added (instead, a call to globals() could be used,
+    as its keys list the automatic values).
+    """
+    expected_subset = set(git.__all__)
+    actual = set(dir(git))
+    assert expected_subset <= actual
+
+
+def test_dir_does_not_contain_util() -> None:
+    """The util attribute is absent from the dir() of git.
+
+    Because this behavior is less confusing than including it, where its meaning would
+    be assumed by users examining the dir() for what is available.
+    """
+    assert "util" not in dir(git)
+
+
+def test_dir_does_not_contain_private_module_aliases() -> None:
+    """Names from inside index and refs only pretend to be there and are not in dir().
+
+    The reason for omitting these is not that they are private, since private members
+    are usually included in dir() when actually present. Instead, these are only sort
+    of even there, no longer being imported and only being resolved dynamically for the
+    time being. In addition, it would be confusing to list these because doing so would
+    obscure the module structure of GitPython.
+    """
+    expected_absent = {
+        "head",
+        "log",
+        "reference",
+        "symbolic",
+        "tag",
+        "base",
+        "fun",
+        "typ",
+    }
+    actual = set(dir(git))
+    assert not (expected_absent & actual), "They should be completely disjoint."

From 05e0878aef38a5fb1cb3d5860b3649cf7c1d4fda Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 18:47:27 -0400
Subject: [PATCH 082/332] Remove old comment meant as todo (that was done)

---
 test/deprecation/test_toplevel.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py
index f74f09457..fe7045d46 100644
--- a/test/deprecation/test_toplevel.py
+++ b/test/deprecation/test_toplevel.py
@@ -89,7 +89,6 @@ def test_util_alias_import() -> None:
     assert util.TemporaryFileSwap is TemporaryFileSwap
 
 
-# Split out util and have all its tests be separate, above.
 _PRIVATE_MODULE_ALIAS_TARGETS = (
     git.refs.head,
     git.refs.log,

From 3fe2f15d218744496e4af77b6a7926791480adfe Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 19:01:24 -0400
Subject: [PATCH 083/332] Test that top-level aliases point to modules with
 normal __name__

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

diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py
index fe7045d46..135cc53cc 100644
--- a/test/deprecation/test_toplevel.py
+++ b/test/deprecation/test_toplevel.py
@@ -102,6 +102,26 @@ def test_util_alias_import() -> None:
 """Targets of private aliases in the git module to some modules, not including util."""
 
 
+_PRIVATE_MODULE_ALIAS_TARGET_NAMES = (
+    "git.refs.head",
+    "git.refs.log",
+    "git.refs.reference",
+    "git.refs.symbolic",
+    "git.refs.tag",
+    "git.index.base",
+    "git.index.fun",
+    "git.index.typ",
+)
+"""Expected ``__name__`` attributes of targets of private aliases in the git module."""
+
+
+def test_alias_target_module_names_are_by_location() -> None:
+    """The aliases are weird, but their targets are normal, even in ``__name__``."""
+    actual = [module.__name__ for module in _PRIVATE_MODULE_ALIAS_TARGETS]
+    expected = list(_PRIVATE_MODULE_ALIAS_TARGET_NAMES)
+    assert actual == expected
+
+
 def test_private_module_alias_access() -> None:
     """Non-util private alias access works but warns and is a deliberate mypy error."""
     with pytest.deprecated_call() as ctx:

From 246cc1703f69e8eba791915bb025945b03abc86b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 19:02:56 -0400
Subject: [PATCH 084/332] Use names directly on other tests

The tests are written broadly (per the style elsewhere in this test
suite), but narrowing the message-checking tests in this specific
way has the further advantage that the logic of the code under test
will be less reflected in the logic of the tests, so that bugs are
less likely to be missed by being duplicated across code and tests.
---
 test/deprecation/test_toplevel.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py
index 135cc53cc..16c41d4e8 100644
--- a/test/deprecation/test_toplevel.py
+++ b/test/deprecation/test_toplevel.py
@@ -141,8 +141,8 @@ def test_private_module_alias_access() -> None:
 
     assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS)
 
-    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages):
-        assert message.endswith(f"Use {target.__name__} instead.")
+    for fullname, message in zip(_PRIVATE_MODULE_ALIAS_TARGET_NAMES, messages):
+        assert message.endswith(f"Use {fullname} instead.")
 
 
 def test_private_module_alias_import() -> None:
@@ -177,8 +177,8 @@ def test_private_module_alias_import() -> None:
 
     assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS)
 
-    for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages):
-        assert message.endswith(f"Use {target.__name__} instead.")
+    for fullname, message in zip(_PRIVATE_MODULE_ALIAS_TARGET_NAMES, messages):
+        assert message.endswith(f"Use {fullname} instead.")
 
 
 def test_dir_contains_public_attributes() -> None:

From d7b6b31f632593bf9e280fbb2be87dd4e16ef7c5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 19:07:35 -0400
Subject: [PATCH 085/332] Fix a small docstring typo

---
 test/deprecation/test_compat.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 6e42d0209..0da5462b2 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -21,7 +21,7 @@
 import git.compat
 
 _MESSAGE_LEADER = "{} and other is_<platform> aliases are deprecated."
-"""Form taken by the beginning of the warnings issues for is_<platform> access."""
+"""Form taken by the beginning of the warnings issued for is_<platform> access."""
 
 
 def test_cannot_access_undefined() -> None:

From 96089c82c0d8982935bbd3326ccfea36ce72e43b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 19:17:15 -0400
Subject: [PATCH 086/332] Improve description in test module docstrings

---
 test/deprecation/test_compat.py   | 2 +-
 test/deprecation/test_toplevel.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 0da5462b2..5007fa1cc 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -1,4 +1,4 @@
-"""Tests for dynamic and static errors and warnings in GitPython's git.compat module.
+"""Tests for dynamic and static characteristics of git.compat module attributes.
 
 These tests verify that the is_<platform> attributes are available, and are even listed
 in the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized)
diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py
index 16c41d4e8..398938616 100644
--- a/test/deprecation/test_toplevel.py
+++ b/test/deprecation/test_toplevel.py
@@ -1,4 +1,4 @@
-"""Tests for dynamic and static errors and warnings in GitPython's top-level git module.
+"""Tests for dynamic and static characteristics of top-level git module attributes.
 
 Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases
 checks static typing of the code under test. This is the reason for the many separate

From a0ef53778d4ae665474ebd96bd25ebbb340a8a16 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 23:12:12 -0400
Subject: [PATCH 087/332] Start on test_types

---
 test/deprecation/test_types.py | 39 ++++++++++++++++++++++++++++++++++
 1 file changed, 39 insertions(+)
 create mode 100644 test/deprecation/test_types.py

diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py
new file mode 100644
index 000000000..a2ac45829
--- /dev/null
+++ b/test/deprecation/test_types.py
@@ -0,0 +1,39 @@
+"""Tests for dynamic and static characteristics of git.types module attributes."""
+
+import sys
+
+if sys.version_info >= (3, 8):
+    from typing import Literal
+else:
+    from typing_extensions import Literal
+
+import pytest
+
+import git.types
+
+
+def test_cannot_access_undefined() -> None:
+    """Accessing a bogus attribute in git.types remains a dynamic and static error."""
+    with pytest.raises(AttributeError):
+        git.types.foo  # type: ignore[attr-defined]
+
+
+def test_lit_commit_ish() -> None:
+    """ """
+    # It would be fine to test attribute access rather than a "from" import. But a
+    # "from" import is more likely to appear in actual usage, so it is used here.
+    with pytest.deprecated_call() as ctx:
+        from git.types import Lit_commit_ish
+
+    # As noted in test_toplevel.test_util_alias_import, there may be multiple warnings,
+    # but all with the same message.
+    (message,) = {str(entry.message) for entry in ctx}
+    assert "Lit_commit_ish is deprecated." in message
+    assert 'Literal["commit", "tag", "blob", "tree"]' in message, "Has old definition."
+    assert 'Literal["commit", "tag"]' in message, "Has new definition."
+    assert "GitObjectTypeString" in message, "Has new type name for old definition."
+
+    _: Lit_commit_ish = "commit"  # type: ignore[valid-type]
+
+    # It should be as documented (even though deliberately unusable in static checks).
+    assert Lit_commit_ish == Literal["commit", "tag"]

From 52e7360cad2ebde77a1302b205eb7cf9182a75c2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 23:13:53 -0400
Subject: [PATCH 088/332] Explain substring assertions in test_toplevel

---
 test/deprecation/test_toplevel.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py
index 398938616..54dc8e358 100644
--- a/test/deprecation/test_toplevel.py
+++ b/test/deprecation/test_toplevel.py
@@ -76,9 +76,9 @@ def test_util_alias_import() -> None:
     # prior to retrieving the attribute for actual use. However, all warnings should
     # have the same message, and it should be our util-specific message.
     (message,) = {str(entry.message) for entry in ctx}
-    assert "git.util" in message
-    assert "git.index.util" in message
-    assert "should not be relied on" in message
+    assert "git.util" in message, "Has alias."
+    assert "git.index.util" in message, "Has target."
+    assert "should not be relied on" in message, "Distinct from other messages."
 
     # As above, we check access through the util alias to the TemporaryFileSwap member.
     from git.index.util import TemporaryFileSwap

From e3675a086fe6ddfb6f8e4050497d47a74e851ed7 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 23:17:55 -0400
Subject: [PATCH 089/332] Expand Lit_commit_ish test name and write docstring

---
 test/deprecation/test_types.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py
index a2ac45829..419435964 100644
--- a/test/deprecation/test_types.py
+++ b/test/deprecation/test_types.py
@@ -18,8 +18,8 @@ def test_cannot_access_undefined() -> None:
         git.types.foo  # type: ignore[attr-defined]
 
 
-def test_lit_commit_ish() -> None:
-    """ """
+def test_can_access_lit_commit_ish_but_it_is_not_usable() -> None:
+    """Lit_commit_ish_can be accessed, but warns and is an invalid type annotation."""
     # It would be fine to test attribute access rather than a "from" import. But a
     # "from" import is more likely to appear in actual usage, so it is used here.
     with pytest.deprecated_call() as ctx:

From 4857ff08fc9ae0183b65a4b94bd0813bd08a74b4 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 23:26:43 -0400
Subject: [PATCH 090/332] Clarify test_compat.test_dir

---
 test/deprecation/test_compat.py | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 5007fa1cc..6699a24d5 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -63,15 +63,19 @@ def test_is_platform() -> None:
 
 
 def test_dir() -> None:
-    """dir() on git.compat lists attributes meant to be public, even if deprecated."""
+    """dir() on git.compat includes all public attributes, even if deprecated.
+
+    As dir() usually does, it also has nonpublic attributes, which should also not be
+    removed by a custom __dir__ function, but those are less important to test.
+    """
     expected_subset = {
+        "is_win",
+        "is_posix",
+        "is_darwin",
         "defenc",
         "safe_decode",
         "safe_encode",
         "win_encode",
-        "is_darwin",
-        "is_win",
-        "is_posix",
     }
     actual = set(dir(git.compat))
     assert expected_subset <= actual

From 488cc13a5b125be886bb361342b8e4709c7944ba Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 23 Mar 2024 23:32:11 -0400
Subject: [PATCH 091/332] Add test of dir() on git.types

---
 test/deprecation/test_types.py | 27 +++++++++++++++++++++++++++
 1 file changed, 27 insertions(+)

diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py
index 419435964..cd53fa210 100644
--- a/test/deprecation/test_types.py
+++ b/test/deprecation/test_types.py
@@ -37,3 +37,30 @@ def test_can_access_lit_commit_ish_but_it_is_not_usable() -> None:
 
     # It should be as documented (even though deliberately unusable in static checks).
     assert Lit_commit_ish == Literal["commit", "tag"]
+
+
+def test_dir() -> None:
+    """dir() on git.types includes public names, even ``Lit_commit_ish``.
+
+    It also contains private names that we don't test. See test_compat.test_dir.
+    """
+    expected_subset = {
+        "PathLike",
+        "TBD",
+        "AnyGitObject",
+        "Tree_ish",
+        "Commit_ish",
+        "GitObjectTypeString",
+        "Lit_commit_ish",
+        "Lit_config_levels",
+        "ConfigLevels_Tup",
+        "CallableProgress",
+        "assert_never",
+        "Files_TD",
+        "Total_TD",
+        "HSH_TD",
+        "Has_Repo",
+        "Has_id_attribute",
+    }
+    actual = set(dir(git.types))
+    assert expected_subset <= actual

From 19b3c0820b607558b3bc52d3d9f3841295f04ac5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 24 Mar 2024 01:31:33 -0400
Subject: [PATCH 092/332] Clarify comment about is_<platform> value assertions

---
 test/deprecation/test_compat.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 6699a24d5..22f733083 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -53,10 +53,10 @@ def test_is_platform() -> None:
     for fullname, message in zip(fully_qualified_names, messages):
         assert message.startswith(_MESSAGE_LEADER.format(fullname))
 
-    # These exactly reproduce the expressions in the code under test, so they are not
-    # good for testing that the values are correct. Instead, the purpose of this test is
-    # to ensure that any dynamic machinery put in place in git.compat to cause warnings
-    # to be issued does not get in the way of the intended values being accessed.
+    # These assertions exactly reproduce the expressions in the code under test, so they
+    # are not good for testing that the values are correct. Instead, their purpose is to
+    # ensure that any dynamic machinery put in place in git.compat to cause warnings to
+    # be issued does not get in the way of the intended values being accessed.
     assert is_win == (os.name == "nt")
     assert is_posix == (os.name == "posix")
     assert is_darwin == (sys.platform == "darwin")

From 28bd4a3f6e562eadc1018ee7c4d561a3c4352fb5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 20 Mar 2024 14:31:58 -0400
Subject: [PATCH 093/332] Issue warnings for some deprecated attributes of
 modules

---
 git/__init__.py | 97 ++++++++++++++++++++++++++++++++++++-------------
 git/compat.py   | 40 ++++++++++++++++++--
 git/types.py    | 39 +++++++++++++++-----
 3 files changed, 138 insertions(+), 38 deletions(-)

diff --git a/git/__init__.py b/git/__init__.py
index a13030456..aa838d4f5 100644
--- a/git/__init__.py
+++ b/git/__init__.py
@@ -88,7 +88,10 @@
 
 __version__ = "git"
 
-from typing import List, Optional, Sequence, TYPE_CHECKING, Tuple, Union
+from typing import Any, List, Optional, Sequence, TYPE_CHECKING, Tuple, Union
+
+if TYPE_CHECKING:
+    from types import ModuleType
 
 from gitdb.util import to_hex_sha
 
@@ -144,11 +147,6 @@
         SymbolicReference,
         Tag,
         TagReference,
-        head,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.head.
-        log,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.log.
-        reference,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.reference.
-        symbolic,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.symbolic.
-        tag,  # noqa: F401  # Nonpublic. May disappear! Use git.refs.tag.
     )
     from git.diff import (  # @NoMove
         INDEX,
@@ -169,21 +167,6 @@
         IndexEntry,
         IndexFile,
         StageType,
-        base,  # noqa: F401  # Nonpublic. May disappear! Use git.index.base.
-        fun,  # noqa: F401  # Nonpublic. May disappear! Use git.index.fun.
-        typ,  # noqa: F401  # Nonpublic. May disappear! Use git.index.typ.
-        #
-        # NOTE: The expression `git.util` evaluates to git.index.util, and the import
-        # `from git import util` imports git.index.util, NOT git.util. It may not be
-        # feasible to change this until the next major version, to avoid breaking code
-        # inadvertently relying on it. If git.index.util really is what you want, use or
-        # import from that name, to avoid confusion. To use the "real" git.util module,
-        # write `from git.util import ...`, or access it as `sys.modules["git.util"]`.
-        # (This differs from other historical indirect-submodule imports that are
-        # unambiguously nonpublic and are subject to immediate removal. Here, the public
-        # git.util module, even though different, makes it less discoverable that the
-        # expression `git.util` refers to a non-public attribute of the git module.)
-        util,  # noqa: F401
     )
     from git.util import (  # @NoMove
         Actor,
@@ -196,7 +179,72 @@
 except GitError as _exc:
     raise ImportError("%s: %s" % (_exc.__class__.__name__, _exc)) from _exc
 
+
+# NOTE: The expression `git.util` evaluates to git.index.util and `from git import util`
+# imports git.index.util, NOT git.util. It may not be feasible to change this until the
+# next major version, to avoid breaking code inadvertently relying on it.
+#
+# - If git.index.util *is* what you want, use or import from that, to avoid confusion.
+#
+# - To use the "real" git.util module, write `from git.util import ...`, or if necessary
+#   access it as `sys.modules["git.util"]`.
+#
+# (This differs from other indirect-submodule imports that are unambiguously non-public
+# and subject to immediate removal. Here, the public git.util module, though different,
+# makes less discoverable that the expression `git.util` refers to a non-public
+# attribute of the git module.)
+#
+# This had come about by a wildcard import. Now that all intended imports are explicit,
+# the intuitive but potentially incompatible binding occurs due to the usual rules for
+# Python submodule bindings. So for now we delete that and let __getattr__ handle it.
+#
+del util  # type: ignore[name-defined]  # noqa: F821
+
+
+def _warned_import(message: str, fullname: str) -> "ModuleType":
+    import importlib
+    import warnings
+
+    warnings.warn(message, DeprecationWarning, stacklevel=3)
+    return importlib.import_module(fullname)
+
+
+def _getattr(name: str) -> Any:
+    # TODO: If __version__ is made dynamic and lazily fetched, put that case right here.
+
+    if name == "util":
+        return _warned_import(
+            "The expression `git.util` and the import `from git import util` actually "
+            "reference git.index.util, and not the git.util module accessed in "
+            '`from git.util import XYZ` or `sys.modules["git.util"]`. This potentially '
+            "confusing behavior is currently preserved for compatibility, but may be "
+            "changed in the future and should not be relied on.",
+            fullname="git.index.util",
+        )
+
+    for names, prefix in (
+        ({"head", "log", "reference", "symbolic", "tag"}, "git.refs"),
+        ({"base", "fun", "typ"}, "git.index"),
+    ):
+        if name not in names:
+            continue
+
+        fullname = f"{prefix}.{name}"
+
+        return _warned_import(
+            f"{__name__}.{name} is a private alias of {fullname} and subject to "
+            f"immediate removal. Use {fullname} instead.",
+            fullname=fullname,
+        )
+
+    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+if not TYPE_CHECKING:  # Preserve static checking for undefined/misspelled attributes.
+    __getattr__ = _getattr
+
 # { Initialize git executable path
+
 GIT_OK = None
 
 
@@ -232,12 +280,9 @@ def refresh(path: Optional[PathLike] = None) -> None:
     GIT_OK = True
 
 
-# } END initialize git executable path
-
-
-#################
 try:
     refresh()
 except Exception as _exc:
     raise ImportError("Failed to initialize: {0}".format(_exc)) from _exc
-#################
+
+# } END initialize git executable path
diff --git a/git/compat.py b/git/compat.py
index 4ede8c985..f1b95a80e 100644
--- a/git/compat.py
+++ b/git/compat.py
@@ -23,7 +23,9 @@
     AnyStr,
     Dict,  # noqa: F401
     IO,  # noqa: F401
+    List,
     Optional,
+    TYPE_CHECKING,
     Tuple,  # noqa: F401
     Type,  # noqa: F401
     Union,
@@ -33,7 +35,39 @@
 # ---------------------------------------------------------------------------
 
 
-is_win = os.name == "nt"
+_deprecated_platform_aliases = {
+    "is_win": os.name == "nt",
+    "is_posix": os.name == "posix",
+    "is_darwin": sys.platform == "darwin",
+}
+
+
+def _getattr(name: str) -> Any:
+    try:
+        value = _deprecated_platform_aliases[name]
+    except KeyError:
+        raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
+
+    import warnings
+
+    warnings.warn(
+        f"{__name__}.{name} and other is_<platform> aliases are deprecated. "
+        "Write the desired os.name or sys.platform check explicitly instead.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return value
+
+
+if not TYPE_CHECKING:  # Preserve static checking for undefined/misspelled attributes.
+    __getattr__ = _getattr
+
+
+def __dir__() -> List[str]:
+    return [*globals(), *_deprecated_platform_aliases]
+
+
+is_win: bool
 """Deprecated alias for ``os.name == "nt"`` to check for native Windows.
 
 This is deprecated because it is clearer to write out :attr:`os.name` or
@@ -45,7 +79,7 @@
     Cygwin, use ``sys.platform == "cygwin"``.
 """
 
-is_posix = os.name == "posix"
+is_posix: bool
 """Deprecated alias for ``os.name == "posix"`` to check for Unix-like ("POSIX") systems.
 
 This is deprecated because it clearer to write out :attr:`os.name` or
@@ -58,7 +92,7 @@
     (Darwin).
 """
 
-is_darwin = sys.platform == "darwin"
+is_darwin: bool
 """Deprecated alias for ``sys.platform == "darwin"`` to check for macOS (Darwin).
 
 This is deprecated because it clearer to write out :attr:`os.name` or
diff --git a/git/types.py b/git/types.py
index a93ebdb4f..1be32de1d 100644
--- a/git/types.py
+++ b/git/types.py
@@ -7,11 +7,13 @@
     Any,
     Callable,
     Dict,
+    List,
     NoReturn,
     Optional,
     Sequence as Sequence,
     Tuple,
     TYPE_CHECKING,
+    Type,
     TypeVar,
     Union,
 )
@@ -127,21 +129,40 @@
 https://git-scm.com/docs/gitglossary#def_object_type
 """
 
-Lit_commit_ish = Literal["commit", "tag"]
-"""Deprecated. Type of literal strings identifying sometimes-commitish git object types.
+Lit_commit_ish: Type[Literal["commit", "tag"]]
+"""Deprecated. Type of literal strings identifying typically-commitish git object types.
 
 Prior to a bugfix, this type had been defined more broadly. Any usage is in practice
-ambiguous and likely to be incorrect. Instead of this type:
+ambiguous and likely to be incorrect. This type has therefore been made a static type
+error to appear in annotations. It is preserved, with a deprecated status, to avoid
+introducing runtime errors in code that refers to it, but it should not be used.
+
+Instead of this type:
 
 * For the type of the string literals associated with :class:`Commit_ish`, use
   ``Literal["commit", "tag"]`` or create a new type alias for it. That is equivalent to
-  this type as currently defined.
+  this type as currently defined (but usable in statically checked type annotations).
 
 * For the type of all four string literals associated with :class:`AnyGitObject`, use
   :class:`GitObjectTypeString`. That is equivalent to the old definition of this type
-  prior to the bugfix.
+  prior to the bugfix (and is also usable in statically checked type annotations).
 """
 
+
+def _getattr(name: str) -> Any:
+    if name == "Lit_commit_ish":
+        return Literal["commit", "tag"]
+    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+if not TYPE_CHECKING:  # Preserve static checking for undefined/misspelled attributes.
+    __getattr__ = _getattr
+
+
+def __dir__() -> List[str]:
+    return [*globals(), "Lit_commit_ish"]
+
+
 # Config_levels ---------------------------------------------------------
 
 Lit_config_levels = Literal["system", "global", "user", "repository"]
@@ -188,12 +209,12 @@ def assert_never(inp: NoReturn, raise_error: bool = True, exc: Union[Exception,
 
     :param inp:
         If all members are handled, the argument for `inp` will have the
-        :class:`~typing.Never`/:class:`~typing.NoReturn` type. Otherwise, the type will
-        mismatch and cause a mypy error.
+        :class:`~typing.Never`/:class:`~typing.NoReturn` type.
+        Otherwise, the type will mismatch and cause a mypy error.
 
     :param raise_error:
-        If ``True``, will also raise :exc:`ValueError` with a general "unhandled
-        literal" message, or the exception object passed as `exc`.
+        If ``True``, will also raise :exc:`ValueError` with a general
+        "unhandled literal" message, or the exception object passed as `exc`.
 
     :param exc:
         It not ``None``, this should be an already-constructed exception object, to be

From dffa930a426747d9b30496fceb6efd621ab8795b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 24 Mar 2024 01:38:26 -0400
Subject: [PATCH 094/332] Refine deprecated module attributes and their
 warnings

- In the top-level git module, import util from git.index so there
  is no ambiguity for tools in detecting which module the util
  attribute of the top-level git module (i.e., git.util in an
  *expression*) is, even though it's subsequently deleted (and then
  dynamically supplied when requested, in a way that is opaque to
  static type checkers due to being only when `not TYPE_CHECKING`).
  This seems to be necessary for some tools.

  Curiously, guarding the del statement under `not TYPE_CHECKING`
  seems *not* to be needed by any tools of any kind. It should
  still possibly be done, but that's not included in these changes.

- Add the missing deprecation warning for git.types.Lit_commit_ish.

- When importing the warnings module, do so with a top-level import
  as in the other GitPython modules that have long (and reasonably)
  done so.

  In git/__init__.py, there already had to be an import of
  importlib, which seemed like it should be done locally in case of
  delays. Neither the importlib module nor any of its submodules
  were already imported anywhere in GitPython, and the code that
  uses it will most often not be exercised. So there is a potential
  benefit to avoiding loading it when not needed.

  When writing a local import for that, I had included the warnings
  module as a local import as well. But this obscures the potential
  benefit of locally importing importlib, and could lead to
  ill-advised changes in the future based on the idea that the
  degree of justification to be local imports was the same for them
  both.
---
 git/__init__.py |  6 ++++--
 git/compat.py   |  3 +--
 git/types.py    | 17 ++++++++++++++---
 3 files changed, 19 insertions(+), 7 deletions(-)

diff --git a/git/__init__.py b/git/__init__.py
index aa838d4f5..fd1b87707 100644
--- a/git/__init__.py
+++ b/git/__init__.py
@@ -93,6 +93,8 @@
 if TYPE_CHECKING:
     from types import ModuleType
 
+import warnings
+
 from gitdb.util import to_hex_sha
 
 from git.exc import (
@@ -167,6 +169,7 @@
         IndexEntry,
         IndexFile,
         StageType,
+        util,
     )
     from git.util import (  # @NoMove
         Actor,
@@ -198,12 +201,11 @@
 # the intuitive but potentially incompatible binding occurs due to the usual rules for
 # Python submodule bindings. So for now we delete that and let __getattr__ handle it.
 #
-del util  # type: ignore[name-defined]  # noqa: F821
+del util
 
 
 def _warned_import(message: str, fullname: str) -> "ModuleType":
     import importlib
-    import warnings
 
     warnings.warn(message, DeprecationWarning, stacklevel=3)
     return importlib.import_module(fullname)
diff --git a/git/compat.py b/git/compat.py
index f1b95a80e..d7d9a55a9 100644
--- a/git/compat.py
+++ b/git/compat.py
@@ -13,6 +13,7 @@
 import locale
 import os
 import sys
+import warnings
 
 from gitdb.utils.encoding import force_bytes, force_text  # noqa: F401
 
@@ -48,8 +49,6 @@ def _getattr(name: str) -> Any:
     except KeyError:
         raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
 
-    import warnings
-
     warnings.warn(
         f"{__name__}.{name} and other is_<platform> aliases are deprecated. "
         "Write the desired os.name or sys.platform check explicitly instead.",
diff --git a/git/types.py b/git/types.py
index 1be32de1d..584450146 100644
--- a/git/types.py
+++ b/git/types.py
@@ -17,6 +17,7 @@
     TypeVar,
     Union,
 )
+import warnings
 
 if sys.version_info >= (3, 8):
     from typing import (
@@ -150,9 +151,19 @@
 
 
 def _getattr(name: str) -> Any:
-    if name == "Lit_commit_ish":
-        return Literal["commit", "tag"]
-    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+    if name != "Lit_commit_ish":
+        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+    warnings.warn(
+        "Lit_commit_ish is deprecated. It is currently defined as "
+        '`Literal["commit", "tag"]`, which should be used in its place if desired. It '
+        'had previously been defined as `Literal["commit", "tag", "blob", "tree"]`, '
+        "covering all four git object type strings including those that are never "
+        "commit-ish. For that, use the GitObjectTypeString type instead.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return Literal["commit", "tag"]
 
 
 if not TYPE_CHECKING:  # Preserve static checking for undefined/misspelled attributes.

From 7ab27c5bb1891af9eec7a99e33ea35e234e322d5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 24 Mar 2024 19:31:00 -0400
Subject: [PATCH 095/332] Start on test module about Git.USE_SHELL and Git
 attributes

---
 test/deprecation/test_cmd_git.py | 168 +++++++++++++++++++++++++++++++
 1 file changed, 168 insertions(+)
 create mode 100644 test/deprecation/test_cmd_git.py

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
new file mode 100644
index 000000000..fc8bab6ee
--- /dev/null
+++ b/test/deprecation/test_cmd_git.py
@@ -0,0 +1,168 @@
+"""Tests for static and dynamic characteristics of Git class and instance attributes.
+
+Currently this all relates to the deprecated :class:`Git.USE_SHELL` class attribute,
+which can also be accessed through instances. Some tests directly verify its behavior,
+including deprecation warnings, while others verify that other aspects of attribute
+access are not inadvertently broken by mechanisms introduced to issue the warnings.
+"""
+
+import contextlib
+import sys
+from typing import Generator
+
+if sys.version_info >= (3, 11):
+    from typing import assert_type
+else:
+    from typing_extensions import assert_type
+
+import pytest
+
+from git.cmd import Git
+
+_USE_SHELL_DEPRECATED_FRAGMENT = "Git.USE_SHELL is deprecated"
+"""Text contained in all USE_SHELL deprecation warnings, and starting most of them."""
+
+_USE_SHELL_DANGEROUS_FRAGMENT = "Setting Git.USE_SHELL to True is unsafe and insecure"
+"""Beginning text of USE_SHELL deprecation warnings when USE_SHELL is set True."""
+
+
+@pytest.fixture
+def reset_backing_attribute() -> Generator[None, None, None]:
+    """Fixture to reset the private ``_USE_SHELL`` attribute.
+
+    This is used to decrease the likelihood of state changes leaking out and affecting
+    other tests. But the goal is not to assert that ``_USE_SHELL`` is used, nor anything
+    about how or when it is used, which is an implementation detail subject to change.
+
+    This is possible but inelegant to do with pytest's monkeypatch fixture, which only
+    restores attributes that it has previously been used to change, create, or remove.
+    """
+    no_value = object()
+    try:
+        old_value = Git._USE_SHELL
+    except AttributeError:
+        old_value = no_value
+
+    yield
+
+    if old_value is no_value:
+        with contextlib.suppress(AttributeError):
+            del Git._USE_SHELL
+    else:
+        Git._USE_SHELL = old_value
+
+
+def test_cannot_access_undefined_on_git_class() -> None:
+    """Accessing a bogus attribute on the Git class remains a dynamic and static error.
+
+    This differs from Git instances, where most attribute names will dynamically
+    synthesize a "bound method" that runs a git subcommand when called.
+    """
+    with pytest.raises(AttributeError):
+        Git.foo  # type: ignore[attr-defined]
+
+
+def test_get_use_shell_on_class_default() -> None:
+    """USE_SHELL can be read as a class attribute, defaulting to False and warning."""
+    with pytest.deprecated_call() as ctx:
+        use_shell = Git.USE_SHELL
+
+    (message,) = [str(entry.message) for entry in ctx]  # Exactly one warning.
+    assert message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
+
+    assert_type(use_shell, bool)
+
+    # This comes after the static assertion, just in case it would affect the inference.
+    assert not use_shell
+
+
+# FIXME: More robustly check that each operation really issues exactly one deprecation
+# warning, even if this requires relying more on reset_backing_attribute doing its job.
+def test_use_shell_on_class(reset_backing_attribute) -> None:
+    """USE_SHELL can be written and re-read as a class attribute, always warning."""
+    # We assert in a "safe" order, using reset_backing_attribute only as a backstop.
+    with pytest.deprecated_call() as ctx:
+        Git.USE_SHELL = True
+        set_value = Git.USE_SHELL
+        Git.USE_SHELL = False
+        reset_value = Git.USE_SHELL
+
+    # The attribute should take on the values set to it.
+    assert set_value is True
+    assert reset_value is False
+
+    messages = [str(entry.message) for entry in ctx]
+    set_message, check_message, reset_message, recheck_message = messages
+
+    # Setting it to True should produce the special warning for that.
+    assert _USE_SHELL_DEPRECATED_FRAGMENT in set_message
+    assert set_message.startswith(_USE_SHELL_DANGEROUS_FRAGMENT)
+
+    # All other operations should produce a usual warning.
+    assert check_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
+    assert reset_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
+    assert recheck_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
+
+
+# FIXME: Test behavior on instances (where we can get but not set).
+
+# FIXME: Test behavior with multiprocessing (the attribute needs to pickle properly).
+
+
+_EXPECTED_DIR_SUBSET = {
+    "cat_file_all",
+    "cat_file_header",
+    "GIT_PYTHON_TRACE",
+    "USE_SHELL",  # The attribute we get deprecation warnings for.
+    "GIT_PYTHON_GIT_EXECUTABLE",
+    "refresh",
+    "is_cygwin",
+    "polish_url",
+    "check_unsafe_protocols",
+    "check_unsafe_options",
+    "AutoInterrupt",
+    "CatFileContentStream",
+    "__init__",
+    "__getattr__",
+    "set_persistent_git_options",
+    "working_dir",
+    "version_info",
+    "execute",
+    "environment",
+    "update_environment",
+    "custom_environment",
+    "transform_kwarg",
+    "transform_kwargs",
+    "__call__",
+    "_call_process",  # Not currently considered public, but unlikely to change.
+    "get_object_header",
+    "get_object_data",
+    "stream_object_data",
+    "clear_cache",
+}
+"""Some stable attributes dir() should include on the Git class and its instances.
+
+This is intentionally incomplete, but includes substantial variety. Most importantly, it
+includes both ``USE_SHELL`` and a wide sampling of other attributes.
+"""
+
+
+def test_class_dir() -> None:
+    """dir() on the Git class includes its statically known attributes.
+
+    This tests that the mechanism that adds dynamic behavior to USE_SHELL accesses so
+    that all accesses issue warnings does not break dir() for the class, neither for
+    USE_SHELL nor for ordinary (non-deprecated) attributes.
+    """
+    actual = set(dir(Git))
+    assert _EXPECTED_DIR_SUBSET <= actual
+
+
+def test_instance_dir() -> None:
+    """dir() on Git objects includes its statically known attributes.
+
+    This is like test_class_dir, but for Git instance rather than the class itself.
+    """
+    instance = Git()
+    actual = set(dir(instance))
+    assert _EXPECTED_DIR_SUBSET <= actual

From af723d5eb04dc919f76369c0b44a087638217352 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 24 Mar 2024 21:05:57 -0400
Subject: [PATCH 096/332] Make test_use_shell_on_class more robust

---
 test/deprecation/test_cmd_git.py | 54 ++++++++++++++++++++++----------
 1 file changed, 37 insertions(+), 17 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index fc8bab6ee..319bf7865 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -9,6 +9,7 @@
 import contextlib
 import sys
 from typing import Generator
+import warnings
 
 if sys.version_info >= (3, 11):
     from typing import assert_type
@@ -26,9 +27,16 @@
 """Beginning text of USE_SHELL deprecation warnings when USE_SHELL is set True."""
 
 
+@contextlib.contextmanager
+def _suppress_deprecation_warning() -> Generator[None, None, None]:
+    with warnings.catch_warnings():
+        warnings.filterwarnings("ignore", category=DeprecationWarning)
+        yield
+
+
 @pytest.fixture
-def reset_backing_attribute() -> Generator[None, None, None]:
-    """Fixture to reset the private ``_USE_SHELL`` attribute.
+def try_restore_use_shell_state() -> Generator[None, None, None]:
+    """Fixture to attempt to restore state associated with the ``USE_SHELL`` attribute.
 
     This is used to decrease the likelihood of state changes leaking out and affecting
     other tests. But the goal is not to assert that ``_USE_SHELL`` is used, nor anything
@@ -38,18 +46,27 @@ def reset_backing_attribute() -> Generator[None, None, None]:
     restores attributes that it has previously been used to change, create, or remove.
     """
     no_value = object()
+
     try:
-        old_value = Git._USE_SHELL
+        old_backing_value = Git._USE_SHELL
     except AttributeError:
-        old_value = no_value
+        old_backing_value = no_value
+    try:
+        with _suppress_deprecation_warning():
+            old_public_value = Git.USE_SHELL
 
-    yield
+        # This doesn't have its own try-finally because pytest catches exceptions raised
+        # during the yield. (The outer try-finally catches exceptions in this fixture.)
+        yield
 
-    if old_value is no_value:
-        with contextlib.suppress(AttributeError):
-            del Git._USE_SHELL
-    else:
-        Git._USE_SHELL = old_value
+        with _suppress_deprecation_warning():
+            Git.USE_SHELL = old_public_value
+    finally:
+        if old_backing_value is no_value:
+            with contextlib.suppress(AttributeError):
+                del Git._USE_SHELL
+        else:
+            Git._USE_SHELL = old_backing_value
 
 
 def test_cannot_access_undefined_on_git_class() -> None:
@@ -76,23 +93,26 @@ def test_get_use_shell_on_class_default() -> None:
     assert not use_shell
 
 
-# FIXME: More robustly check that each operation really issues exactly one deprecation
-# warning, even if this requires relying more on reset_backing_attribute doing its job.
-def test_use_shell_on_class(reset_backing_attribute) -> None:
+def test_use_shell_on_class(try_restore_use_shell_state) -> None:
     """USE_SHELL can be written and re-read as a class attribute, always warning."""
-    # We assert in a "safe" order, using reset_backing_attribute only as a backstop.
-    with pytest.deprecated_call() as ctx:
+    with pytest.deprecated_call() as setting:
         Git.USE_SHELL = True
+    with pytest.deprecated_call() as checking:
         set_value = Git.USE_SHELL
+    with pytest.deprecated_call() as resetting:
         Git.USE_SHELL = False
+    with pytest.deprecated_call() as rechecking:
         reset_value = Git.USE_SHELL
 
     # The attribute should take on the values set to it.
     assert set_value is True
     assert reset_value is False
 
-    messages = [str(entry.message) for entry in ctx]
-    set_message, check_message, reset_message, recheck_message = messages
+    # Each access should warn exactly once.
+    (set_message,) = [str(entry.message) for entry in setting]
+    (check_message,) = [str(entry.message) for entry in checking]
+    (reset_message,) = [str(entry.message) for entry in resetting]
+    (recheck_message,) = [str(entry.message) for entry in rechecking]
 
     # Setting it to True should produce the special warning for that.
     assert _USE_SHELL_DEPRECATED_FRAGMENT in set_message

From bf1388896ac2d052cf956123513f0c8b33f34dd6 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 25 Mar 2024 00:06:40 -0400
Subject: [PATCH 097/332] Write most remaining Git attribute/deprecation tests

---
 test/deprecation/test_cmd_git.py | 99 ++++++++++++++++++++++++++++----
 1 file changed, 88 insertions(+), 11 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 319bf7865..ef6f5b5a6 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -17,6 +17,7 @@
     from typing_extensions import assert_type
 
 import pytest
+from pytest import WarningsRecorder
 
 from git.cmd import Git
 
@@ -93,17 +94,34 @@ def test_get_use_shell_on_class_default() -> None:
     assert not use_shell
 
 
-def test_use_shell_on_class(try_restore_use_shell_state) -> None:
-    """USE_SHELL can be written and re-read as a class attribute, always warning."""
-    with pytest.deprecated_call() as setting:
-        Git.USE_SHELL = True
-    with pytest.deprecated_call() as checking:
-        set_value = Git.USE_SHELL
-    with pytest.deprecated_call() as resetting:
-        Git.USE_SHELL = False
-    with pytest.deprecated_call() as rechecking:
-        reset_value = Git.USE_SHELL
+def test_get_use_shell_on_instance_default() -> None:
+    """USE_SHELL can be read as an instance attribute, defaulting to False and warning.
+
+    This is the same as test_get_use_shell_on_class_default above, but for instances.
+    The test is repeated, instead of using parametrization, for clearer static analysis.
+    """
+    instance = Git()
 
+    with pytest.deprecated_call() as ctx:
+        use_shell = instance.USE_SHELL
+
+    (message,) = [str(entry.message) for entry in ctx]  # Exactly one warning.
+    assert message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
+
+    assert_type(use_shell, bool)
+
+    # This comes after the static assertion, just in case it would affect the inference.
+    assert not use_shell
+
+
+def _assert_use_shell_full_results(
+    set_value: bool,
+    reset_value: bool,
+    setting: WarningsRecorder,
+    checking: WarningsRecorder,
+    resetting: WarningsRecorder,
+    rechecking: WarningsRecorder,
+) -> None:
     # The attribute should take on the values set to it.
     assert set_value is True
     assert reset_value is False
@@ -124,7 +142,66 @@ def test_use_shell_on_class(try_restore_use_shell_state) -> None:
     assert recheck_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
 
 
-# FIXME: Test behavior on instances (where we can get but not set).
+def test_use_shell_set_and_get_on_class(try_restore_use_shell_state: None) -> None:
+    """USE_SHELL can be set and re-read as a class attribute, always warning."""
+    with pytest.deprecated_call() as setting:
+        Git.USE_SHELL = True
+    with pytest.deprecated_call() as checking:
+        set_value = Git.USE_SHELL
+    with pytest.deprecated_call() as resetting:
+        Git.USE_SHELL = False
+    with pytest.deprecated_call() as rechecking:
+        reset_value = Git.USE_SHELL
+
+    _assert_use_shell_full_results(
+        set_value,
+        reset_value,
+        setting,
+        checking,
+        resetting,
+        rechecking,
+    )
+
+
+def test_use_shell_set_on_class_get_on_instance(try_restore_use_shell_state: None) -> None:
+    """USE_SHELL can be set on the class and read on an instance, always warning.
+
+    This is like test_use_shell_set_and_get_on_class but it performs reads on an
+    instance. There is some redundancy here in assertions about warnings when the
+    attribute is set, but it is a separate test so that any bugs where a read on the
+    class (or an instance) is needed first before a read on an instance (or the class)
+    are detected.
+    """
+    instance = Git()
+
+    with pytest.deprecated_call() as setting:
+        Git.USE_SHELL = True
+    with pytest.deprecated_call() as checking:
+        set_value = instance.USE_SHELL
+    with pytest.deprecated_call() as resetting:
+        Git.USE_SHELL = False
+    with pytest.deprecated_call() as rechecking:
+        reset_value = instance.USE_SHELL
+
+    _assert_use_shell_full_results(
+        set_value,
+        reset_value,
+        setting,
+        checking,
+        resetting,
+        rechecking,
+    )
+
+
+@pytest.mark.parametrize("value", [False, True])
+def test_use_shell_cannot_set_on_instance(
+    value: bool,
+    try_restore_use_shell_state: None,  # In case of a bug where it does set USE_SHELL.
+) -> None:
+    instance = Git()
+    with pytest.raises(AttributeError):
+        instance.USE_SHELL = value
+
 
 # FIXME: Test behavior with multiprocessing (the attribute needs to pickle properly).
 

From 602de0c93572972d29d33c4ecacfd9c6e192484a Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 25 Mar 2024 14:59:13 -0400
Subject: [PATCH 098/332] Begin multiprocessing misadventure

There is no per-instance state involved in USE_SHELL, so pickling
is far less directly relevant than usual to multiprocessing: the
spawn and forkserver methods will not preserve a subsequently
changed attribute value unless side effects of loading a module (or
other unpickling of a function or its arguments that are submitted
to run on a worker subprocess) causes it to run again; the fork
method will.

This will be (automatically) the same with any combination of
metaclasses, properties, and custom descriptors as in the more
straightforward case of a simple class attribute. Subtleties arise
in the code that uses GitPython and multiprocessing, but should not
arise unintentionally from the change in implementation of USE_SHELL
done to add deprecation warnings, except possibly with respect to
whether warnings will be repeated in worker processes, which is
less important than whether the actual state is preserved.
---
 test/deprecation/test_cmd_git.py | 28 +++++++++++++++++++++++-----
 1 file changed, 23 insertions(+), 5 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index ef6f5b5a6..72c1f7cd8 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -6,6 +6,7 @@
 access are not inadvertently broken by mechanisms introduced to issue the warnings.
 """
 
+from concurrent.futures import ProcessPoolExecutor
 import contextlib
 import sys
 from typing import Generator
@@ -36,7 +37,7 @@ def _suppress_deprecation_warning() -> Generator[None, None, None]:
 
 
 @pytest.fixture
-def try_restore_use_shell_state() -> Generator[None, None, None]:
+def restore_use_shell_state() -> Generator[None, None, None]:
     """Fixture to attempt to restore state associated with the ``USE_SHELL`` attribute.
 
     This is used to decrease the likelihood of state changes leaking out and affecting
@@ -142,7 +143,7 @@ def _assert_use_shell_full_results(
     assert recheck_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
 
 
-def test_use_shell_set_and_get_on_class(try_restore_use_shell_state: None) -> None:
+def test_use_shell_set_and_get_on_class(restore_use_shell_state: None) -> None:
     """USE_SHELL can be set and re-read as a class attribute, always warning."""
     with pytest.deprecated_call() as setting:
         Git.USE_SHELL = True
@@ -163,7 +164,7 @@ def test_use_shell_set_and_get_on_class(try_restore_use_shell_state: None) -> No
     )
 
 
-def test_use_shell_set_on_class_get_on_instance(try_restore_use_shell_state: None) -> None:
+def test_use_shell_set_on_class_get_on_instance(restore_use_shell_state: None) -> None:
     """USE_SHELL can be set on the class and read on an instance, always warning.
 
     This is like test_use_shell_set_and_get_on_class but it performs reads on an
@@ -196,14 +197,31 @@ class (or an instance) is needed first before a read on an instance (or the clas
 @pytest.mark.parametrize("value", [False, True])
 def test_use_shell_cannot_set_on_instance(
     value: bool,
-    try_restore_use_shell_state: None,  # In case of a bug where it does set USE_SHELL.
+    restore_use_shell_state: None,  # In case of a bug where it does set USE_SHELL.
 ) -> None:
     instance = Git()
     with pytest.raises(AttributeError):
         instance.USE_SHELL = value
 
 
-# FIXME: Test behavior with multiprocessing (the attribute needs to pickle properly).
+def _check_use_shell_in_worker(value: bool) -> None:
+    # USE_SHELL should have the value set in the parent before starting the worker.
+    assert Git.USE_SHELL is value
+
+    # FIXME: Check that mutation still works and raises the warning.
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+@pytest.mark.parametrize("value", [False, True])
+def test_use_shell_preserved_in_multiprocessing(
+    value: bool,
+    restore_use_shell_state: None,
+) -> None:
+    """The USE_SHELL class attribute pickles accurately for multiprocessing."""
+    Git.USE_SHELL = value
+    with ProcessPoolExecutor(max_workers=1) as executor:
+        # Calling result() marshals any exception back to this process and raises it.
+        executor.submit(_check_use_shell_in_worker, value).result()
 
 
 _EXPECTED_DIR_SUBSET = {

From d4b50c94ff3694e3285a1ea8ed9b42c685726baf Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 25 Mar 2024 15:01:23 -0400
Subject: [PATCH 099/332] Somewhat clarify multiprocessing misadventure

---
 test/deprecation/test_cmd_git.py | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 72c1f7cd8..04fb0747c 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -204,11 +204,8 @@ def test_use_shell_cannot_set_on_instance(
         instance.USE_SHELL = value
 
 
-def _check_use_shell_in_worker(value: bool) -> None:
-    # USE_SHELL should have the value set in the parent before starting the worker.
-    assert Git.USE_SHELL is value
-
-    # FIXME: Check that mutation still works and raises the warning.
+def _get_value_in_current_process() -> bool:
+    return Git.USE_SHELL
 
 
 @pytest.mark.filterwarnings("ignore::DeprecationWarning")
@@ -220,8 +217,8 @@ def test_use_shell_preserved_in_multiprocessing(
     """The USE_SHELL class attribute pickles accurately for multiprocessing."""
     Git.USE_SHELL = value
     with ProcessPoolExecutor(max_workers=1) as executor:
-        # Calling result() marshals any exception back to this process and raises it.
-        executor.submit(_check_use_shell_in_worker, value).result()
+        marshaled_value = executor.submit(_get_value_in_current_process).result()
+    assert marshaled_value is value
 
 
 _EXPECTED_DIR_SUBSET = {

From 02c2f0008082e710ddb73f1c4ff784b74eed4f8f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 26 Mar 2024 13:09:08 -0400
Subject: [PATCH 100/332] Discuss multiprocessing in test module docstring;
 remove bad test

---
 test/deprecation/test_cmd_git.py | 44 ++++++++++++++++++--------------
 1 file changed, 25 insertions(+), 19 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 04fb0747c..9dfe37220 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -1,12 +1,35 @@
 """Tests for static and dynamic characteristics of Git class and instance attributes.
 
-Currently this all relates to the deprecated :class:`Git.USE_SHELL` class attribute,
+Currently this all relates to the deprecated :attr:`Git.USE_SHELL` class attribute,
 which can also be accessed through instances. Some tests directly verify its behavior,
 including deprecation warnings, while others verify that other aspects of attribute
 access are not inadvertently broken by mechanisms introduced to issue the warnings.
+
+A note on multiprocessing: Because USE_SHELL has no instance state, this module does not
+include tests of pickling and multiprocessing.
+
+- Just as with a simple class attribute, when a class attribute with custom logic is
+  later set to a new value, it may have either its initial value or the new value when
+  accessed from a worker process, depending on the process start method. With "fork",
+  changes are preserved. With "spawn" or "forkserver", re-importing the modules causes
+  initial values to be set. Then the value in the parent at the time it dispatches the
+  task is only set in the children if the parent has the task set it, or if it is set as
+  a side effect of importing needed modules, or of unpickling objects passed to the
+  child (for example, if it is set in a top-level statement of the module that defines
+  the function submitted for the child worker process to call).
+
+- When an attribute gains new logic provided by a property or custom descriptor, and the
+  attribute involves instance-level state, incomplete or corrupted pickling can break
+  multiprocessing. (For example, if an instance attribute is reimplemented using a
+  descriptor that stores data in a global WeakKeyDictionary, pickled instances should be
+  tested to ensure they are still working correctly.) But nothing like that applies
+  here, because instance state is not involved. Although the situation is inherently
+  complex as described above, it is independent of the attribute implementation.
+
+- That USE_SHELL cannot be set on instances, and that when retrieved on instances it
+  always gives the same value as on the class, is covered in the tests here.
 """
 
-from concurrent.futures import ProcessPoolExecutor
 import contextlib
 import sys
 from typing import Generator
@@ -204,23 +227,6 @@ def test_use_shell_cannot_set_on_instance(
         instance.USE_SHELL = value
 
 
-def _get_value_in_current_process() -> bool:
-    return Git.USE_SHELL
-
-
-@pytest.mark.filterwarnings("ignore::DeprecationWarning")
-@pytest.mark.parametrize("value", [False, True])
-def test_use_shell_preserved_in_multiprocessing(
-    value: bool,
-    restore_use_shell_state: None,
-) -> None:
-    """The USE_SHELL class attribute pickles accurately for multiprocessing."""
-    Git.USE_SHELL = value
-    with ProcessPoolExecutor(max_workers=1) as executor:
-        marshaled_value = executor.submit(_get_value_in_current_process).result()
-    assert marshaled_value is value
-
-
 _EXPECTED_DIR_SUBSET = {
     "cat_file_all",
     "cat_file_header",

From 46df79f75e6ee4db9852c398ec99a5788fd966b2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 26 Mar 2024 16:03:58 -0400
Subject: [PATCH 101/332] Discuss metaclass conflicts in test module docstring

---
 test/deprecation/test_cmd_git.py | 27 +++++++++++++++++++++++++--
 1 file changed, 25 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 9dfe37220..a5d241cc4 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -5,8 +5,11 @@
 including deprecation warnings, while others verify that other aspects of attribute
 access are not inadvertently broken by mechanisms introduced to issue the warnings.
 
-A note on multiprocessing: Because USE_SHELL has no instance state, this module does not
-include tests of pickling and multiprocessing.
+A note on multiprocessing
+=========================
+
+Because USE_SHELL has no instance state, this module does not include tests of pickling
+and multiprocessing:
 
 - Just as with a simple class attribute, when a class attribute with custom logic is
   later set to a new value, it may have either its initial value or the new value when
@@ -28,6 +31,26 @@
 
 - That USE_SHELL cannot be set on instances, and that when retrieved on instances it
   always gives the same value as on the class, is covered in the tests here.
+
+A note on metaclass conflicts
+=============================
+
+The most important DeprecationWarning is for the code ``Git.USE_SHELL = True``, which is
+a security risk. But this warning may not be possible to implement without a custom
+metaclass. This is because a descriptor in a class can customize all forms of attribute
+access on its instances, but can only customize getting an attribute on the class.
+Retrieving a descriptor from a class calls its ``__get__`` method (if defined), but
+replacing or deleting it does not call its ``__set__`` or ``__delete__`` methods.
+
+Adding a metaclass is a potentially breaking change. This is because derived classes
+that use an unrelated metaclass, whether directly or by inheriting from a class such as
+abc.ABC that uses one, will raise TypeError when defined. These would have to be
+modified to use a newly introduced metaclass that is a lower bound of both. Subclasses
+remain unbroken in the far more typical case that they use no custom metaclass.
+
+The tests in this module do not establish whether the danger of setting Git.USE_SHELL to
+True is high enough, and applications of deriving from Git and using an unrelated custom
+metaclass marginal enough, to justify introducing a metaclass to issue the warnings.
 """
 
 import contextlib

From 40ed842cf35b0c280dc408d77c764988a2d2746a Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 26 Mar 2024 18:30:34 -0400
Subject: [PATCH 102/332] Revise test module docstring for clarity

---
 test/deprecation/test_cmd_git.py | 24 ++++++++++++------------
 1 file changed, 12 insertions(+), 12 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index a5d241cc4..2ebcb3ad7 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -11,19 +11,19 @@
 Because USE_SHELL has no instance state, this module does not include tests of pickling
 and multiprocessing:
 
-- Just as with a simple class attribute, when a class attribute with custom logic is
-  later set to a new value, it may have either its initial value or the new value when
-  accessed from a worker process, depending on the process start method. With "fork",
-  changes are preserved. With "spawn" or "forkserver", re-importing the modules causes
-  initial values to be set. Then the value in the parent at the time it dispatches the
-  task is only set in the children if the parent has the task set it, or if it is set as
-  a side effect of importing needed modules, or of unpickling objects passed to the
-  child (for example, if it is set in a top-level statement of the module that defines
-  the function submitted for the child worker process to call).
+- Just as with a simple class attribute, when a class attribute with custom logic is set
+  to another value, even before a worker process is created that uses the class, the
+  worker process may see either the initial or new value, depending on the process start
+  method. With "fork", changes are preserved. With "spawn" or "forkserver", re-importing
+  the modules causes initial values to be set. Then the value in the parent at the time
+  it dispatches the task is only set in the children if the parent has the task set it,
+  or if it is set as a side effect of importing needed modules, or of unpickling objects
+  passed to the child (for example, if it is set in a top-level statement of the module
+  that defines the function submitted for the child worker process to call).
 
 - When an attribute gains new logic provided by a property or custom descriptor, and the
   attribute involves instance-level state, incomplete or corrupted pickling can break
-  multiprocessing. (For example, if an instance attribute is reimplemented using a
+  multiprocessing. (For example, when an instance attribute is reimplemented using a
   descriptor that stores data in a global WeakKeyDictionary, pickled instances should be
   tested to ensure they are still working correctly.) But nothing like that applies
   here, because instance state is not involved. Although the situation is inherently
@@ -35,8 +35,8 @@
 A note on metaclass conflicts
 =============================
 
-The most important DeprecationWarning is for the code ``Git.USE_SHELL = True``, which is
-a security risk. But this warning may not be possible to implement without a custom
+The most important DeprecationWarning is for code like ``Git.USE_SHELL = True``, which
+is a security risk. But this warning may not be possible to implement without a custom
 metaclass. This is because a descriptor in a class can customize all forms of attribute
 access on its instances, but can only customize getting an attribute on the class.
 Retrieving a descriptor from a class calls its ``__get__`` method (if defined), but

From 6a35261ab6a58bfd8fbb2b008aacfb2decf27053 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 03:28:38 -0400
Subject: [PATCH 103/332] Test that USE_SHELL is unittest.mock.patch patchable

---
 test/deprecation/test_cmd_git.py | 36 ++++++++++++++++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 2ebcb3ad7..5d710b0d4 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -56,6 +56,7 @@
 import contextlib
 import sys
 from typing import Generator
+import unittest.mock
 import warnings
 
 if sys.version_info >= (3, 11):
@@ -250,6 +251,41 @@ def test_use_shell_cannot_set_on_instance(
         instance.USE_SHELL = value
 
 
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+@pytest.mark.parametrize("original_value", [False, True])
+def test_use_shell_is_mock_patchable_on_class_as_object_attribute(
+    original_value: bool,
+    restore_use_shell_state: None,
+) -> None:
+    """Asymmetric patching looking up USE_SHELL in ``__dict__`` doesn't corrupt state.
+
+    Code using GitPython may temporarily set Git.USE_SHELL to a different value. Ideally
+    it does not use unittest.mock.patch to do so, because that makes subtle assumptions
+    about the relationship between attributes and dictionaries. If the attribute can be
+    retrieved from the ``__dict__`` rather than directly, that value is assumed the
+    correct one to restore, even by a normal setattr.
+
+    The effect is that some ways of simulating a class attribute with added behavior can
+    cause a descriptor, such as a property, to be set to its own backing attribute
+    during unpatching; then subsequent reads raise RecursionError. This happens if both
+    (a) setting it on the class is customized in a metaclass and (b) getting it on
+    instances is customized with a descriptor (such as a property) in the class itself.
+
+    Although ideally code outside GitPython would not rely on being able to patch
+    Git.USE_SHELL with unittest.mock.patch, the technique is widespread. Thus, USE_SHELL
+    should be implemented in some way compatible with it. This test checks for that.
+    """
+    Git.USE_SHELL = original_value
+    if Git.USE_SHELL is not original_value:
+        raise RuntimeError(f"Can't set up the test")
+    new_value = not original_value
+
+    with unittest.mock.patch.object(Git, "USE_SHELL", new_value):
+        assert Git.USE_SHELL is new_value
+
+    assert Git.USE_SHELL is original_value
+
+
 _EXPECTED_DIR_SUBSET = {
     "cat_file_all",
     "cat_file_header",

From e725c8254cccd88d02b38b414bac875212a7074b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 11:45:25 -0400
Subject: [PATCH 104/332] Make the restore_use_shell_state fixture more robust

---
 test/deprecation/test_cmd_git.py | 52 ++++++++++++++++++++++++--------
 1 file changed, 39 insertions(+), 13 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 5d710b0d4..4183018e6 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -54,6 +54,7 @@
 """
 
 import contextlib
+import logging
 import sys
 from typing import Generator
 import unittest.mock
@@ -75,6 +76,8 @@
 _USE_SHELL_DANGEROUS_FRAGMENT = "Setting Git.USE_SHELL to True is unsafe and insecure"
 """Beginning text of USE_SHELL deprecation warnings when USE_SHELL is set True."""
 
+_logger = logging.getLogger(__name__)
+
 
 @contextlib.contextmanager
 def _suppress_deprecation_warning() -> Generator[None, None, None]:
@@ -85,22 +88,44 @@ def _suppress_deprecation_warning() -> Generator[None, None, None]:
 
 @pytest.fixture
 def restore_use_shell_state() -> Generator[None, None, None]:
-    """Fixture to attempt to restore state associated with the ``USE_SHELL`` attribute.
+    """Fixture to attempt to restore state associated with the USE_SHELL attribute.
 
     This is used to decrease the likelihood of state changes leaking out and affecting
-    other tests. But the goal is not to assert that ``_USE_SHELL`` is used, nor anything
-    about how or when it is used, which is an implementation detail subject to change.
+    other tests. But the goal is not to assert implementation details of USE_SHELL.
+
+    This covers two of the common implementation strategies, for convenience in testing
+    both. USE_SHELL could be implemented in the metaclass:
 
-    This is possible but inelegant to do with pytest's monkeypatch fixture, which only
-    restores attributes that it has previously been used to change, create, or remove.
+    * With a separate _USE_SHELL backing attribute. If using a property or other
+      descriptor, this is the natural way to do it, but custom __getattribute__ and
+      __setattr__ logic, if it does more than adding warnings, may also use that.
+    * Like a simple attribute, using USE_SHELL itself, stored as usual in the class
+      dictionary, with custom __getattribute__/__setattr__ logic only to warn.
+
+    This tries to save private state, tries to save the public attribute value, yields
+    to the test case, tries to restore the public attribute value, then tries to restore
+    private state. The idea is that if the getting or setting logic is wrong in the code
+    under test, the state will still most likely be reset successfully.
     """
     no_value = object()
 
+    # Try to save the original private state.
     try:
-        old_backing_value = Git._USE_SHELL
+        old_private_value = Git._USE_SHELL
     except AttributeError:
-        old_backing_value = no_value
+        separate_backing_attribute = False
+        try:
+            old_private_value = type.__getattribute__(Git, "USE_SHELL")
+        except AttributeError:
+            old_private_value = no_value
+            _logger.error("Cannot retrieve old private _USE_SHELL or USE_SHELL value")
+    else:
+        separate_backing_attribute = True
+
     try:
+        # Try to save the original public value. Rather than attempt to restore a state
+        # where the attribute is not set, if we cannot do this we allow AttributeError
+        # to propagate out of the fixture, erroring the test case before its code runs.
         with _suppress_deprecation_warning():
             old_public_value = Git.USE_SHELL
 
@@ -108,14 +133,15 @@ def restore_use_shell_state() -> Generator[None, None, None]:
         # during the yield. (The outer try-finally catches exceptions in this fixture.)
         yield
 
+        # Try to restore the original public value.
         with _suppress_deprecation_warning():
             Git.USE_SHELL = old_public_value
     finally:
-        if old_backing_value is no_value:
-            with contextlib.suppress(AttributeError):
-                del Git._USE_SHELL
-        else:
-            Git._USE_SHELL = old_backing_value
+        # Try to restore the original private state.
+        if separate_backing_attribute:
+            Git._USE_SHELL = old_private_value
+        elif old_private_value is not no_value:
+            type.__setattr__(Git, "USE_SHELL", old_private_value)
 
 
 def test_cannot_access_undefined_on_git_class() -> None:
@@ -277,7 +303,7 @@ def test_use_shell_is_mock_patchable_on_class_as_object_attribute(
     """
     Git.USE_SHELL = original_value
     if Git.USE_SHELL is not original_value:
-        raise RuntimeError(f"Can't set up the test")
+        raise RuntimeError("Can't set up the test")
     new_value = not original_value
 
     with unittest.mock.patch.object(Git, "USE_SHELL", new_value):

From 436bcaa85712f73640ac719679d6d1366c376483 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 12:05:52 -0400
Subject: [PATCH 105/332] Add `type: ignore` in test that we can't set
 USE_SHELL on instances

As with other `type: ignore` comments in the deprecation tests,
mypy will detect if it is superfluous (provided warn_unused_ignores
is set to true), thereby enforcing that such code is a static type
error.
---
 test/deprecation/test_cmd_git.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 4183018e6..4925fada8 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -274,7 +274,7 @@ def test_use_shell_cannot_set_on_instance(
 ) -> None:
     instance = Git()
     with pytest.raises(AttributeError):
-        instance.USE_SHELL = value
+        instance.USE_SHELL = value  # type: ignore[misc]  # Name not in __slots__.
 
 
 @pytest.mark.filterwarnings("ignore::DeprecationWarning")

From febda6f6e9aa56f4b525020153ed88459906641f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 12:09:15 -0400
Subject: [PATCH 106/332] Clarify unittest.mock.patch patchability test
 docstring

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

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 4925fada8..b792fddb8 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -292,10 +292,11 @@ def test_use_shell_is_mock_patchable_on_class_as_object_attribute(
     correct one to restore, even by a normal setattr.
 
     The effect is that some ways of simulating a class attribute with added behavior can
-    cause a descriptor, such as a property, to be set to its own backing attribute
-    during unpatching; then subsequent reads raise RecursionError. This happens if both
-    (a) setting it on the class is customized in a metaclass and (b) getting it on
-    instances is customized with a descriptor (such as a property) in the class itself.
+    cause a descriptor, such as a property, to be set as the value of its own backing
+    attribute during unpatching; then subsequent reads raise RecursionError. This
+    happens if both (a) setting it on the class is customized in a metaclass and (b)
+    getting it on instances is customized with a descriptor (such as a property) in the
+    class itself.
 
     Although ideally code outside GitPython would not rely on being able to patch
     Git.USE_SHELL with unittest.mock.patch, the technique is widespread. Thus, USE_SHELL

From 40371087ac83497f307d2239f36646a321028829 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 13:31:22 -0400
Subject: [PATCH 107/332] Test that Git.execute's own read of USE_SHELL does
 not warn

+ Use simplefilter where we can (separate from this test).
---
 test/deprecation/test_cmd_git.py | 20 +++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index b792fddb8..f17756fb6 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -82,7 +82,7 @@
 @contextlib.contextmanager
 def _suppress_deprecation_warning() -> Generator[None, None, None]:
     with warnings.catch_warnings():
-        warnings.filterwarnings("ignore", category=DeprecationWarning)
+        warnings.simplefilter("ignore", DeprecationWarning)
         yield
 
 
@@ -313,6 +313,24 @@ class itself.
     assert Git.USE_SHELL is original_value
 
 
+def test_execute_without_shell_arg_does_not_warn() -> None:
+    """No deprecation warning is issued from operations implemented using Git.execute().
+
+    When no ``shell`` argument is passed to Git.execute, which is when the value of
+    USE_SHELL is to be used, the way Git.execute itself accesses USE_SHELL does not
+    issue a deprecation warning.
+    """
+    with warnings.catch_warnings():
+        for category in DeprecationWarning, PendingDeprecationWarning:
+            warnings.filterwarnings(
+                action="error",
+                category=category,
+                module=r"git(?:\.|$)",
+            )
+
+        Git().version()
+
+
 _EXPECTED_DIR_SUBSET = {
     "cat_file_all",
     "cat_file_header",

From 0e311bf5da4332acd314aa6927138b36bbd9e527 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 14:42:26 -0400
Subject: [PATCH 108/332] Suppress type errors in restore_use_shell_state
 _USE_SHELL branches

These conditional branches are kept so alternative implementations
can be examined, including if they need to be investigated to
satisfy some future requirement. But to be unittest.mock.patch
patchable, the approaches that would have a _USE_SHELL backing
attribute would be difficult to implement in a straightforward way,
which seems not to be needed or justified at this time.

Since that is not anticipated (except as an intermediate step in
development), these suppressions make sense, and they will also be
reported by mypy if the implementation changes to benefit from them
(so long as it is configured with warn_unused_ignores set to true).
---
 test/deprecation/test_cmd_git.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index f17756fb6..b6af5c770 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -111,7 +111,7 @@ def restore_use_shell_state() -> Generator[None, None, None]:
 
     # Try to save the original private state.
     try:
-        old_private_value = Git._USE_SHELL
+        old_private_value = Git._USE_SHELL  # type: ignore[attr-defined]
     except AttributeError:
         separate_backing_attribute = False
         try:
@@ -139,7 +139,7 @@ def restore_use_shell_state() -> Generator[None, None, None]:
     finally:
         # Try to restore the original private state.
         if separate_backing_attribute:
-            Git._USE_SHELL = old_private_value
+            Git._USE_SHELL = old_private_value  # type: ignore[attr-defined]
         elif old_private_value is not no_value:
             type.__setattr__(Git, "USE_SHELL", old_private_value)
 

From df4c5c03d14a53448a232a78eeab94017ac7e7ba Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 28 Mar 2024 15:33:47 -0400
Subject: [PATCH 109/332] Fix wrong/unclear grammar in test_instance_dir
 docstring

---
 test/deprecation/test_cmd_git.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index b6af5c770..d312b8778 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -383,7 +383,7 @@ def test_class_dir() -> None:
 def test_instance_dir() -> None:
     """dir() on Git objects includes its statically known attributes.
 
-    This is like test_class_dir, but for Git instance rather than the class itself.
+    This is like test_class_dir, but for Git instances rather than the class itself.
     """
     instance = Git()
     actual = set(dir(instance))

From d38e721c1f35695ecdaf6e88b721881fb7b3ed6f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Tue, 26 Mar 2024 21:55:04 -0400
Subject: [PATCH 110/332] Issue warnings whenever Git.USE_SHELL is accessed

With a special message when it is assigned a True value, which is
the dangerous use that underlies its deprecation.

The warnings are all DeprecationWarning.
---
 git/cmd.py | 78 ++++++++++++++++++++++++++++++++++++++++++------------
 1 file changed, 61 insertions(+), 17 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 03e5d7ffc..0b7536d91 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -19,6 +19,7 @@
 import sys
 from textwrap import dedent
 import threading
+import warnings
 
 from git.compat import defenc, force_bytes, safe_decode
 from git.exc import (
@@ -54,6 +55,7 @@
     TYPE_CHECKING,
     TextIO,
     Tuple,
+    Type,
     Union,
     cast,
     overload,
@@ -307,8 +309,45 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc
 
 ## -- End Utilities -- @}
 
+_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."
+)
+
+_USE_SHELL_DANGER_MESSAGE = (
+    "Setting Git.USE_SHELL to True is unsafe and insecure, and should be avoided, "
+    "because the effect of shell metacharacters and shell expansions cannot usually be "
+    "accounted for. In addition, Git.USE_SHELL is deprecated and will be removed in a "
+    "future release."
+)
+
+
+def _warn_use_shell(extra_danger: bool) -> None:
+    warnings.warn(
+        _USE_SHELL_DANGER_MESSAGE if extra_danger else _USE_SHELL_DEFAULT_MESSAGE,
+        DeprecationWarning,
+        stacklevel=3,
+    )
+
+
+class _GitMeta(type):
+    """Metaclass for :class:`Git`.
+
+    This helps issue :class:`DeprecationWarning` if :attr:`Git.USE_SHELL` is used.
+    """
 
-class Git:
+    @property
+    def USE_SHELL(cls: Type[Git]) -> bool:
+        _warn_use_shell(False)
+        return cls._USE_SHELL
+
+    @USE_SHELL.setter
+    def USE_SHELL(cls: Type[Git], value: bool) -> None:
+        _warn_use_shell(value)
+        cls._USE_SHELL = value
+
+
+class Git(metaclass=_GitMeta):
     """The Git class manages communication with the Git binary.
 
     It provides a convenient interface to calling the Git binary, such as in::
@@ -358,25 +397,30 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
     GIT_PYTHON_TRACE = os.environ.get("GIT_PYTHON_TRACE", False)
     """Enables debugging of GitPython's git commands."""
 
-    USE_SHELL = False
-    """Deprecated. If set to ``True``, a shell will be used when executing git commands.
+    _USE_SHELL: bool = False
 
-    Prior to GitPython 2.0.8, this had a narrow purpose in suppressing console windows
-    in graphical Windows applications. In 2.0.8 and higher, it provides no benefit, as
-    GitPython solves that problem more robustly and safely by using the
-    ``CREATE_NO_WINDOW`` process creation flag on Windows.
+    @property
+    def USE_SHELL(self) -> bool:
+        """Deprecated. If set to ``True``, a shell will be used to execute git commands.
 
-    Code that uses ``USE_SHELL = True`` or that passes ``shell=True`` to any GitPython
-    functions should be updated to use the default value of ``False`` instead. ``True``
-    is unsafe unless the effect of shell expansions is fully considered and accounted
-    for, which is not possible under most circumstances.
+        Prior to GitPython 2.0.8, this had a narrow purpose in suppressing console
+        windows in graphical Windows applications. In 2.0.8 and higher, it provides no
+        benefit, as GitPython solves that problem more robustly and safely by using the
+        ``CREATE_NO_WINDOW`` process creation flag on Windows.
 
-    See:
+        Code that uses ``USE_SHELL = True`` or that passes ``shell=True`` to any
+        GitPython functions should be updated to use the default value of ``False``
+        instead. ``True`` is unsafe unless the effect of shell expansions is fully
+        considered and accounted for, which is not possible under most circumstances.
 
-    - :meth:`Git.execute` (on the ``shell`` parameter).
-    - https://github.com/gitpython-developers/GitPython/commit/0d9390866f9ce42870d3116094cd49e0019a970a
-    - https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
-    """
+        See:
+
+        - :meth:`Git.execute` (on the ``shell`` parameter).
+        - https://github.com/gitpython-developers/GitPython/commit/0d9390866f9ce42870d3116094cd49e0019a970a
+        - https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
+        """
+        _warn_use_shell(False)
+        return self._USE_SHELL
 
     _git_exec_env_var = "GIT_PYTHON_GIT_EXECUTABLE"
     _refresh_env_var = "GIT_PYTHON_REFRESH"
@@ -1138,7 +1182,7 @@ def execute(
 
         stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
         if shell is None:
-            shell = self.USE_SHELL
+            shell = self._USE_SHELL
         _logger.debug(
             "Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)",
             redacted_command,

From 05de5c0d3bc81f4d56283399417db2f6927fe233 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 03:29:41 -0400
Subject: [PATCH 111/332] Implement instance USE_SHELL lookup in __getattr__

This is with the intention of making it so that Git.USE_SHELL is
unittest.mock.patch patchable. However, while this moves in the
right direction, it can't be done this way, as the attribute is
found to be absent on the class, so when unittest.mock.patch
unpatches, it tries to delete the attribute it has set, which
fails due to the metaclass's USE_SHELL instance property having no
deleter.
---
 git/cmd.py | 38 ++++++++++++++++++++------------------
 1 file changed, 20 insertions(+), 18 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 0b7536d91..d01c15b04 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -399,28 +399,25 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
 
     _USE_SHELL: bool = False
 
-    @property
-    def USE_SHELL(self) -> bool:
-        """Deprecated. If set to ``True``, a shell will be used to execute git commands.
+    USE_SHELL: bool
+    """Deprecated. If set to ``True``, a shell will be used to execute git commands.
 
-        Prior to GitPython 2.0.8, this had a narrow purpose in suppressing console
-        windows in graphical Windows applications. In 2.0.8 and higher, it provides no
-        benefit, as GitPython solves that problem more robustly and safely by using the
-        ``CREATE_NO_WINDOW`` process creation flag on Windows.
+    Prior to GitPython 2.0.8, this had a narrow purpose in suppressing console windows
+    in graphical Windows applications. In 2.0.8 and higher, it provides no benefit, as
+    GitPython solves that problem more robustly and safely by using the
+    ``CREATE_NO_WINDOW`` process creation flag on Windows.
 
-        Code that uses ``USE_SHELL = True`` or that passes ``shell=True`` to any
-        GitPython functions should be updated to use the default value of ``False``
-        instead. ``True`` is unsafe unless the effect of shell expansions is fully
-        considered and accounted for, which is not possible under most circumstances.
+    Code that uses ``USE_SHELL = True`` or that passes ``shell=True`` to any GitPython
+    functions should be updated to use the default value of ``False`` instead. ``True``
+    is unsafe unless the effect of shell expansions is fully considered and accounted
+    for, which is not possible under most circumstances.
 
-        See:
+    See:
 
-        - :meth:`Git.execute` (on the ``shell`` parameter).
-        - https://github.com/gitpython-developers/GitPython/commit/0d9390866f9ce42870d3116094cd49e0019a970a
-        - https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
-        """
-        _warn_use_shell(False)
-        return self._USE_SHELL
+    - :meth:`Git.execute` (on the ``shell`` parameter).
+    - https://github.com/gitpython-developers/GitPython/commit/0d9390866f9ce42870d3116094cd49e0019a970a
+    - https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
+    """
 
     _git_exec_env_var = "GIT_PYTHON_GIT_EXECUTABLE"
     _refresh_env_var = "GIT_PYTHON_REFRESH"
@@ -921,6 +918,11 @@ def __getattr__(self, name: str) -> Any:
         """
         if name.startswith("_"):
             return super().__getattribute__(name)
+
+        if name == "USE_SHELL":
+            _warn_use_shell(False)
+            return self._USE_SHELL
+
         return lambda *args, **kwargs: self._call_process(name, *args, **kwargs)
 
     def set_persistent_git_options(self, **kwargs: Any) -> None:

From 04eb09c728fbdc4501fbbba1d6f58a0bb7050470 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 03:40:14 -0400
Subject: [PATCH 112/332] Have USE_SHELL warn but work like normal via super()

This reimplements Git.USE_SHELL with no properties or descriptors.
The metaclass is still needed, but instad of defining properties it
defines __getattribute__ and __setattr__, which check for USE_SHELL
and warn, then invoke the default attribute access via super().

Likewise, in the Git class itself, a __getatttribute__ override is
introduced (not to be confused with __getattr__, which was already
present and handles attribute access when an attribute is otherwise
absent, unlike __getattribute__ which is always used). This checks
for reading USE_SHELL on an instance and warns, then invokes the
default attribute access via super().

Advantages:

- Git.USE_SHELL is again unittest.mock.patch patchable.

- AttributeError messages are automatically as before.

- It effectively is a simple attribute, yet with warning, so other
  unanticipated ways of accessing it may be less likely to break.

- The code is simpler, cleaner, and clearer. There is some
  overhead, but it is small, especially compared to a subprocess
  invocation as is done for performing most git operations.

However, this does introduce disadvantages that must be addressed:

- Although attribute access on Git instances was already highly
  dynamic, as "methods" are synthesized for git subcommands, this
  was and is not the case for the Git class itself, whose
  attributes remain exactly those that can be inferred without
  considering the existence of __getattribute__ and __setattr__ on
  the metaclass. So static type checkers need to be prevented from
  accounting for those metaclass methods in a way that causes them
  to infer that arbitrary class attribute access is allowed.

- The occurrence of Git.USE_SHELL in the Git.execute method (where
  the USE_SHELL attribute is actually examined) should be changed
  so it does not itself issue DeprecationWarning (it is not enough
  that by default a DeprecationWarning from there isn't displayed).
---
 git/cmd.py | 33 +++++++++++++++------------------
 1 file changed, 15 insertions(+), 18 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index d01c15b04..1c2aebd3c 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -55,7 +55,6 @@
     TYPE_CHECKING,
     TextIO,
     Tuple,
-    Type,
     Union,
     cast,
     overload,
@@ -336,15 +335,15 @@ class _GitMeta(type):
     This helps issue :class:`DeprecationWarning` if :attr:`Git.USE_SHELL` is used.
     """
 
-    @property
-    def USE_SHELL(cls: Type[Git]) -> bool:
-        _warn_use_shell(False)
-        return cls._USE_SHELL
+    def __getattribute__(cls, name: str) -> Any:
+        if name == "USE_SHELL":
+            _warn_use_shell(False)
+        return super().__getattribute__(name)
 
-    @USE_SHELL.setter
-    def USE_SHELL(cls: Type[Git], value: bool) -> None:
-        _warn_use_shell(value)
-        cls._USE_SHELL = value
+    def __setattr__(cls, name: str, value: Any) -> Any:
+        if name == "USE_SHELL":
+            _warn_use_shell(value)
+        super().__setattr__(name, value)
 
 
 class Git(metaclass=_GitMeta):
@@ -397,9 +396,7 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
     GIT_PYTHON_TRACE = os.environ.get("GIT_PYTHON_TRACE", False)
     """Enables debugging of GitPython's git commands."""
 
-    _USE_SHELL: bool = False
-
-    USE_SHELL: bool
+    USE_SHELL: bool = False
     """Deprecated. If set to ``True``, a shell will be used to execute git commands.
 
     Prior to GitPython 2.0.8, this had a narrow purpose in suppressing console windows
@@ -909,6 +906,11 @@ def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
         self.cat_file_header: Union[None, TBD] = None
         self.cat_file_all: Union[None, TBD] = None
 
+    def __getattribute__(self, name: str) -> Any:
+        if name == "USE_SHELL":
+            _warn_use_shell(False)
+        return super().__getattribute__(name)
+
     def __getattr__(self, name: str) -> Any:
         """A convenience method as it allows to call the command as if it was an object.
 
@@ -918,11 +920,6 @@ def __getattr__(self, name: str) -> Any:
         """
         if name.startswith("_"):
             return super().__getattribute__(name)
-
-        if name == "USE_SHELL":
-            _warn_use_shell(False)
-            return self._USE_SHELL
-
         return lambda *args, **kwargs: self._call_process(name, *args, **kwargs)
 
     def set_persistent_git_options(self, **kwargs: Any) -> None:
@@ -1184,7 +1181,7 @@ def execute(
 
         stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
         if shell is None:
-            shell = self._USE_SHELL
+            shell = self.USE_SHELL
         _logger.debug(
             "Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)",
             redacted_command,

From c6f518b40e42d394dfbcf338aacf101cad58b700 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 14:14:52 -0400
Subject: [PATCH 113/332] Keep mypy from thinking Git has arbitrary class
 attributes

The existence of __getattr__ or __getattribute__ on a class causes
static type checkers like mypy to stop inferring that reads of
unrecognized instance attributes are static type errors. When the
class is a metaclass, this causes static type checkers to stop
inferring that reads of unrecognized class attributes, on classes
that use (i.e., that have as their type) the metaclass, are static
type errors.

The Git class itself defines __getattr__ and __getattribute__, but
Git objects' instance attributes really are dynamically synthesized
(in __getattr__). However, class attributes of Git are not dynamic,
even though _GitMeta defines __getattribute__. Therefore, something
special is needed so mypy infers nothing about Git class attributes
from the existence of _GitMeta.__getattribute__.

This takes the same approach as taken to the analogous problem with
__getattr__ at module level, defining __getattribute__ with a
different name first and then assigning that to __getattribute__
under an `if not TYPE_CHECKING:`. (Allowing static type checkers to
see the original definition allows them to find some kinds of type
errors in the body of the method, which is why the method isn't
just defined in the `if not TYPE_CHECKING`.)

Although it may not currently be necessary to hide __setattr__ too,
the same is done with it in _GitMeta as well. The reason is that
the intent may otherwise be subtly amgiguous to human readers and
maybe future tools: when __setattr__ exists, the effect of setting
a class attribute that did not previously exist is in principle
unclear, and might not make the attribute readble. (I am unsure if
this is the right choice; either approach seems reasonable.)
---
 git/cmd.py | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 1c2aebd3c..ce19e733c 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -335,16 +335,23 @@ class _GitMeta(type):
     This helps issue :class:`DeprecationWarning` if :attr:`Git.USE_SHELL` is used.
     """
 
-    def __getattribute__(cls, name: str) -> Any:
+    def __getattribute(cls, name: str) -> Any:
         if name == "USE_SHELL":
             _warn_use_shell(False)
         return super().__getattribute__(name)
 
-    def __setattr__(cls, name: str, value: Any) -> Any:
+    def __setattr(cls, name: str, value: Any) -> Any:
         if name == "USE_SHELL":
             _warn_use_shell(value)
         super().__setattr__(name, value)
 
+    if not TYPE_CHECKING:
+        # To preserve static checking for undefined/misspelled attributes while letting
+        # the methods' bodies be type-checked, these are defined as non-special methods,
+        # then bound to special names out of view of static type checkers.
+        __getattribute__ = __getattribute
+        __setattr__ = __setattr
+
 
 class Git(metaclass=_GitMeta):
     """The Git class manages communication with the Git binary.

From c5d5b16bfb4829dd510e3ff258b3daca4cd3b57d Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 14:20:54 -0400
Subject: [PATCH 114/332] Clarify that the private name mangling is intentional

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

diff --git a/git/cmd.py b/git/cmd.py
index ce19e733c..de5c6929b 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -348,7 +348,8 @@ def __setattr(cls, name: str, value: Any) -> Any:
     if not TYPE_CHECKING:
         # To preserve static checking for undefined/misspelled attributes while letting
         # the methods' bodies be type-checked, these are defined as non-special methods,
-        # then bound to special names out of view of static type checkers.
+        # then bound to special names out of view of static type checkers. (The original
+        # names invoke name mangling (leading "__") to avoid confusion in other scopes.)
         __getattribute__ = __getattribute
         __setattr__ = __setattr
 

From 84bf2ca034721cd54e792f57585083a1dbffc6ea Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 15:25:20 -0400
Subject: [PATCH 115/332] Read USE_SHELL in Git.execute without
 DeprecationWarning

This changes how Git.execute itself accesses the USE_SHELL
attribute, so that its own read of it does not issue a warning.
---
 git/cmd.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/git/cmd.py b/git/cmd.py
index de5c6929b..eed82d7dd 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -1189,7 +1189,12 @@ def execute(
 
         stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
         if shell is None:
-            shell = self.USE_SHELL
+            # Get the value of USE_SHELL with no deprecation warning. Do this without
+            # warnings.catch_warnings, to avoid a race condition with application code
+            # configuring warnings. The value could be looked up in type(self).__dict__
+            # or Git.__dict__, but those can break under some circumstances. This works
+            # the same as self.USE_SHELL in more situations; see Git.__getattribute__.
+            shell = super().__getattribute__("USE_SHELL")
         _logger.debug(
             "Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)",
             redacted_command,

From 5bef7ed7369e1d6f93161607e01dc02bcd1720ab Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 28 Mar 2024 23:35:03 -0400
Subject: [PATCH 116/332] Add GitPython project top comments to new test
 modules

---
 test/deprecation/test_cmd_git.py  | 3 +++
 test/deprecation/test_compat.py   | 3 +++
 test/deprecation/test_toplevel.py | 3 +++
 test/deprecation/test_types.py    | 3 +++
 4 files changed, 12 insertions(+)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index d312b8778..b15f219d8 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -1,3 +1,6 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
 """Tests for static and dynamic characteristics of Git class and instance attributes.
 
 Currently this all relates to the deprecated :attr:`Git.USE_SHELL` class attribute,
diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py
index 22f733083..2d7805e61 100644
--- a/test/deprecation/test_compat.py
+++ b/test/deprecation/test_compat.py
@@ -1,3 +1,6 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
 """Tests for dynamic and static characteristics of git.compat module attributes.
 
 These tests verify that the is_<platform> attributes are available, and are even listed
diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py
index 54dc8e358..740408193 100644
--- a/test/deprecation/test_toplevel.py
+++ b/test/deprecation/test_toplevel.py
@@ -1,3 +1,6 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
 """Tests for dynamic and static characteristics of top-level git module attributes.
 
 Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases
diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py
index cd53fa210..f97375a85 100644
--- a/test/deprecation/test_types.py
+++ b/test/deprecation/test_types.py
@@ -1,3 +1,6 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
 """Tests for dynamic and static characteristics of git.types module attributes."""
 
 import sys

From 3da47c26db44f287fa2b10e95995689de1f578bc Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 29 Mar 2024 11:46:38 -0400
Subject: [PATCH 117/332] Hide `del util` from type checkers

Even though current type checkers don't seem to need it.

As noted in dffa930, it appears neither mypy nor pyright currently
needs `del util` to be guarded by `if not TYPE_CHECKING:` -- they
currently treat util as bound even without it, even with `del util`
present.

It is not obvious that this is the best type checker behavior or
that type checkers will continue to behave this way. (In addition,
it may help humans for all statements whose effects are intended
not to be considered for purposes of static typing to be guarded by
`if not TYPE_CHECKING:`.) So this guards the deletion of util the
same as the binding of __getattr__.

This also moves, clarifies, and expands the commented explanations
of what is going on.
---
 git/__init__.py | 54 +++++++++++++++++++++++++++++--------------------
 1 file changed, 32 insertions(+), 22 deletions(-)

diff --git a/git/__init__.py b/git/__init__.py
index fd1b87707..1b2360e3a 100644
--- a/git/__init__.py
+++ b/git/__init__.py
@@ -169,6 +169,8 @@
         IndexEntry,
         IndexFile,
         StageType,
+        # NOTE: This tells type checkers what util resolves to. We delete it, and it is
+        # really resolved by __getattr__, which warns. See below on what to use instead.
         util,
     )
     from git.util import (  # @NoMove
@@ -183,27 +185,6 @@
     raise ImportError("%s: %s" % (_exc.__class__.__name__, _exc)) from _exc
 
 
-# NOTE: The expression `git.util` evaluates to git.index.util and `from git import util`
-# imports git.index.util, NOT git.util. It may not be feasible to change this until the
-# next major version, to avoid breaking code inadvertently relying on it.
-#
-# - If git.index.util *is* what you want, use or import from that, to avoid confusion.
-#
-# - To use the "real" git.util module, write `from git.util import ...`, or if necessary
-#   access it as `sys.modules["git.util"]`.
-#
-# (This differs from other indirect-submodule imports that are unambiguously non-public
-# and subject to immediate removal. Here, the public git.util module, though different,
-# makes less discoverable that the expression `git.util` refers to a non-public
-# attribute of the git module.)
-#
-# This had come about by a wildcard import. Now that all intended imports are explicit,
-# the intuitive but potentially incompatible binding occurs due to the usual rules for
-# Python submodule bindings. So for now we delete that and let __getattr__ handle it.
-#
-del util
-
-
 def _warned_import(message: str, fullname: str) -> "ModuleType":
     import importlib
 
@@ -242,7 +223,36 @@ def _getattr(name: str) -> Any:
     raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
 
 
-if not TYPE_CHECKING:  # Preserve static checking for undefined/misspelled attributes.
+if not TYPE_CHECKING:
+    # NOTE: The expression `git.util` gives git.index.util and `from git import util`
+    # imports git.index.util, NOT git.util. It may not be feasible to change this until
+    # the next major version, to avoid breaking code inadvertently relying on it.
+    #
+    # - If git.index.util *is* what you want, use (or import from) that, to avoid
+    #   confusion.
+    #
+    # - To use the "real" git.util module, write `from git.util import ...`, or if
+    #   necessary access it as `sys.modules["git.util"]`.
+    #
+    # Note also that `import git.util` technically imports the "real" git.util... but
+    # the *expression* `git.util` after doing so is still git.index.util!
+    #
+    # (This situation differs from that of other indirect-submodule imports that are
+    # unambiguously non-public and subject to immediate removal. Here, the public
+    # git.util module, though different, makes less discoverable that the expression
+    # `git.util` refers to a non-public attribute of the git module.)
+    #
+    # This had originally come about by a wildcard import. Now that all intended imports
+    # are explicit, the intuitive but potentially incompatible binding occurs due to the
+    # usual rules for Python submodule bindings. So for now we replace that binding with
+    # git.index.util, delete that, and let __getattr__ handle it and issue a warning.
+    #
+    # For the same runtime behavior, it would be enough to forgo importing util, and
+    # delete util as created naturally; __getattr__ would behave the same. But type
+    # checkers would not know what util refers to when accessed as an attribute of git.
+    del util
+
+    # This is "hidden" to preserve static checking for undefined/misspelled attributes.
     __getattr__ = _getattr
 
 # { Initialize git executable path

From bdabb21fe62063ce51fcae6b5f499f10f55525c5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 29 Mar 2024 18:52:10 -0400
Subject: [PATCH 118/332] Expand USE_SHELL docstring; clarify a test usage

In the USE_SHELL docstring:

- Restore the older wording "when executing git commands" rather
  than "to execute git commands". I've realized that longer phrase,
  which dates back to the introduction of USE_SHELL in 1c2dd54, is
  clearer, because readers less familiar with GitPython's overall
  design and operation will still not be misled into thinking
  USE_SHELL ever affects whether GitPython uses an external git
  command, versus some other mechanism, to do something.

- Give some more information about why USE_SHELL = True is unsafe
  (though further revision or clarification may be called for).

- Note some non-obvious aspects of USE_SHELL, that some of the way
  it interacts with other aspects of GitPython is not part of what
  is or has been documented about it, and in practice changes over
  time. The first example relates to #1792; the second may help
  users understand why code that enables USE_SHELL on Windows, in
  addition to being unsafe there, often breaks immediately on other
  platforms; the third is included so the warnings in the expanded
  docstring are not interpreted as a new commitment that any shell
  syntax that may have a *desired* effect in some application will
  continue to have the same effect in the future.

- Cover a second application that might lead, or have led, to
  setting USE_SHELL to True, and explain what to do instead.

In test_successful_default_refresh_invalidates_cached_version_info:

- Rewrite the commented explanation of a special variant of that
  second application, where the usual easy alternatives are not
  used because part of the goal of the test is to check a "default"
  scenario that does not include either of them. This better
  explains why that choice is made in the test, and also hopefully
  will prevent anyone from thinking that test is a model for
  another situation (which, as noted, is unlikely to be the case
  even in unit tests).
---
 git/cmd.py       | 40 +++++++++++++++++++++++++++++++---------
 test/test_git.py | 14 +++++++-------
 2 files changed, 38 insertions(+), 16 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index eed82d7dd..42e6e927c 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -405,23 +405,45 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
     """Enables debugging of GitPython's git commands."""
 
     USE_SHELL: bool = False
-    """Deprecated. If set to ``True``, a shell will be used to execute git commands.
+    """Deprecated. If set to ``True``, a shell will be used when executing git commands.
+
+    Code that uses ``USE_SHELL = True`` or that passes ``shell=True`` to any GitPython
+    functions should be updated to use the default value of ``False`` instead. ``True``
+    is unsafe unless the effect of syntax treated specially by the shell is fully
+    considered and accounted for, which is not possible under most circumstances. As
+    detailed below, it is also no longer needed, even where it had been in the past.
+
+    In addition, how a value of ``True`` interacts with some aspects of GitPython's
+    operation is not precisely specified and may change without warning, even before
+    GitPython 4.0.0 when :attr:`USE_SHELL` may be removed. This includes:
+
+    * Whether or how GitPython automatically customizes the shell environment.
+
+    * Whether, outside of Windows (where :class:`subprocess.Popen` supports lists of
+      separate arguments even when ``shell=True``), this can be used with any GitPython
+      functionality other than direct calls to the :meth:`execute` method.
+
+    * Whether any GitPython feature that runs git commands ever attempts to partially
+      sanitize data a shell may treat specially. Currently this is not done.
 
     Prior to GitPython 2.0.8, this had a narrow purpose in suppressing console windows
     in graphical Windows applications. In 2.0.8 and higher, it provides no benefit, as
     GitPython solves that problem more robustly and safely by using the
     ``CREATE_NO_WINDOW`` process creation flag on Windows.
 
-    Code that uses ``USE_SHELL = True`` or that passes ``shell=True`` to any GitPython
-    functions should be updated to use the default value of ``False`` instead. ``True``
-    is unsafe unless the effect of shell expansions is fully considered and accounted
-    for, which is not possible under most circumstances.
+    Because Windows path search differs subtly based on whether a shell is used, in rare
+    cases changing this from ``True`` to ``False`` may keep an unusual git "executable",
+    such as a batch file, from being found. To fix this, set the command name or full
+    path in the :envvar:`GIT_PYTHON_GIT_EXECUTABLE` environment variable or pass the
+    full path to :func:`git.refresh` (or invoke the script using a ``.exe`` shim).
 
-    See:
+    Further reading:
 
-    - :meth:`Git.execute` (on the ``shell`` parameter).
-    - https://github.com/gitpython-developers/GitPython/commit/0d9390866f9ce42870d3116094cd49e0019a970a
-    - https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
+    * :meth:`Git.execute` (on the ``shell`` parameter).
+    * https://github.com/gitpython-developers/GitPython/commit/0d9390866f9ce42870d3116094cd49e0019a970a
+    * https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
+    * https://github.com/python/cpython/issues/91558#issuecomment-1100942950
+    * https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
     """
 
     _git_exec_env_var = "GIT_PYTHON_GIT_EXECUTABLE"
diff --git a/test/test_git.py b/test/test_git.py
index 112e2f0eb..94e68ecf0 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -669,13 +669,13 @@ def test_successful_default_refresh_invalidates_cached_version_info(self):
             stack.enter_context(_patch_out_env("GIT_PYTHON_GIT_EXECUTABLE"))
 
             if sys.platform == "win32":
-                # On Windows, use a shell so "git" finds "git.cmd". (In the infrequent
-                # case that this effect is desired in production code, it should not be
-                # done with this technique. USE_SHELL=True is less secure and reliable,
-                # as unintended shell expansions can occur, and is deprecated. Instead,
-                # use a custom command, by setting the GIT_PYTHON_GIT_EXECUTABLE
-                # environment variable to git.cmd or by passing git.cmd's full path to
-                # git.refresh. Or wrap the script with a .exe shim.)
+                # On Windows, use a shell so "git" finds "git.cmd". The correct and safe
+                # ways to do this straightforwardly are to set GIT_PYTHON_GIT_EXECUTABLE
+                # to git.cmd in the environment, or call git.refresh with the command's
+                # full path. See the Git.USE_SHELL docstring for deprecation details.
+                # But this tests a "default" scenario where neither is done. The
+                # approach used here, setting USE_SHELL to True so PATHEXT is honored,
+                # should not be used in production code (nor even in most test cases).
                 stack.enter_context(mock.patch.object(Git, "USE_SHELL", True))
 
             new_git = Git()

From b51b08052821f91a61a40808328aed0ac35935a8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 29 Mar 2024 19:51:32 -0400
Subject: [PATCH 119/332] Explain the approach in test.deprecation to static
 checking

And why this increases the importance of the warn_unused_ignored
mypy configuration option.
---
 pyproject.toml                   |  2 +-
 test/deprecation/__init__.py     | 17 +++++++++++++++++
 test/deprecation/test_cmd_git.py |  2 +-
 3 files changed, 19 insertions(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 5eac2be09..6cb05f96e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ files = ["git/", "test/deprecation/"]
 disallow_untyped_defs = true
 no_implicit_optional = true
 warn_redundant_casts = true
-warn_unused_ignores = true
+warn_unused_ignores = true  # Useful in general, but especially in test/deprecation.
 warn_unreachable = true
 implicit_reexport = true
 # strict = true
diff --git a/test/deprecation/__init__.py b/test/deprecation/__init__.py
index 56b5d89db..fec3126d2 100644
--- a/test/deprecation/__init__.py
+++ b/test/deprecation/__init__.py
@@ -1,2 +1,19 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
+"""Tests of deprecation warnings and possible related attribute bugs.
+
+Most deprecation warnings are "basic" in the sense that there is no special complexity
+to consider, in introducing them. However, to issue deprecation warnings on mere
+attribute access can involve adding new dynamic behavior. This can lead to subtle bugs
+or less useful dynamic metadata. It can also weaken static typing, as happens if a type
+checker sees a method like ``__getattr__`` in a module or class whose attributes it did
+not already judge to be dynamic. This test.deprecation submodule covers all three cases:
+the basic cases, subtle dynamic behavior, and subtle static type checking issues.
+
+Static type checking is "tested" by a combination of code that should not be treated as
+a type error but would be in the presence of particular bugs, and code that *should* be
+treated as a type error and is accordingly marked ``# type: ignore[REASON]`` (for
+specific ``REASON``. The latter will only produce mypy errors when the expectation is
+not met if it is configured with ``warn_unused_ignores = true``.
+"""
diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index b15f219d8..3f87037a0 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -1,7 +1,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-"""Tests for static and dynamic characteristics of Git class and instance attributes.
+"""Tests for dynamic and static characteristics of Git class and instance attributes.
 
 Currently this all relates to the deprecated :attr:`Git.USE_SHELL` class attribute,
 which can also be accessed through instances. Some tests directly verify its behavior,

From 7cd3aa913077d55bbdb7ca01e6b7df593121f643 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 29 Mar 2024 22:55:36 -0400
Subject: [PATCH 120/332] Make test.performance.lib docstring more specific

To distinguish it from the more general test.lib as well as from
the forthcoming test.deprecation.lib.
---
 test/performance/lib.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/performance/lib.py b/test/performance/lib.py
index 2ca3c409b..c24599986 100644
--- a/test/performance/lib.py
+++ b/test/performance/lib.py
@@ -1,7 +1,7 @@
 # This module is part of GitPython and is released under the
 # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
-"""Support library for tests."""
+"""Support library for performance tests."""
 
 import logging
 import os

From cf2576e406006ec28bcc85565a7ef85864cbd39e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 29 Mar 2024 23:15:52 -0400
Subject: [PATCH 121/332] Make/use test.deprecation.lib; abandon idea to filter
 by module

This creates a module test.deprecation.lib in the test suite for
a couple general helpers used in deprecation tests, one of which is
now used in two of the test modules and the other of which happens
only to be used in one but is concepually related to the first.

The assert_no_deprecation_warning context manager function fixes
two different flawed approaches to that, which were in use earlier:

- In test_basic, only DeprecationWarning and not the less
  significant PendingDeprecationWarning had been covere, which it
  should, at least for symmetry, since pytest.deprecated_call()
  treats it like DeprecationWarning.

  There was also a comment expressing a plan to make it filter only
  for warnings originating from GitPython, which was a flawed idea,
  as detailed below.

- In test_cmd_git, the flawed idea of attempting to filter only for
  warnings originating from GitPython was implemented. The problem
  with this is that it is heavily affected by stacklevel and its
  interaction with the pattern of calls through which the warning
  arises: warnings could actually emanate from code in GitPython's
  git module, but be registered as having come from test code, a
  callback in gitdb, smmap, or the standard library, or even the
  pytest test runner. Some of these are more likely than others,
  but all are possible especially considering the possibility of a
  bug in the value passed to warning.warn as stacklevel. (It may be
  valuable for such bugs to cause tests to fail, but they should not
  cause otherwise failing tests to wrongly pass.)

  It is probably feasible to implement such filtering successfully,
  but I don't think it's worthwhile for the small reduction in
  likelihood of failing later on an unrealted DeprecationWarning
  from somewhere else, especially considering that GitPython's main
  dependencies are so minimal.
---
 test/deprecation/lib.py          | 27 +++++++++++++++++++++++++++
 test/deprecation/test_basic.py   | 26 ++++++++------------------
 test/deprecation/test_cmd_git.py | 24 +++++-------------------
 3 files changed, 40 insertions(+), 37 deletions(-)
 create mode 100644 test/deprecation/lib.py

diff --git a/test/deprecation/lib.py b/test/deprecation/lib.py
new file mode 100644
index 000000000..9fe623a3a
--- /dev/null
+++ b/test/deprecation/lib.py
@@ -0,0 +1,27 @@
+# This module is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
+"""Support library for deprecation tests."""
+
+__all__ = ["assert_no_deprecation_warning", "suppress_deprecation_warning"]
+
+import contextlib
+import warnings
+
+from typing import Generator
+
+
+@contextlib.contextmanager
+def assert_no_deprecation_warning() -> Generator[None, None, None]:
+    """Context manager to assert that code does not issue any deprecation warnings."""
+    with warnings.catch_warnings():
+        warnings.simplefilter("error", DeprecationWarning)
+        warnings.simplefilter("error", PendingDeprecationWarning)
+        yield
+
+
+@contextlib.contextmanager
+def suppress_deprecation_warning() -> Generator[None, None, None]:
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", DeprecationWarning)
+        yield
diff --git a/test/deprecation/test_basic.py b/test/deprecation/test_basic.py
index 8ee7e72b1..6235a836c 100644
--- a/test/deprecation/test_basic.py
+++ b/test/deprecation/test_basic.py
@@ -15,9 +15,6 @@
 It is inapplicable to the deprecations whose warnings are tested in this module.
 """
 
-import contextlib
-import warnings
-
 import pytest
 
 from git.diff import NULL_TREE
@@ -25,6 +22,8 @@
 from git.repo import Repo
 from git.util import Iterable as _Iterable, IterableObj
 
+from .lib import assert_no_deprecation_warning
+
 # typing -----------------------------------------------------------------
 
 from typing import Generator, TYPE_CHECKING
@@ -38,15 +37,6 @@
 # ------------------------------------------------------------------------
 
 
-@contextlib.contextmanager
-def _assert_no_deprecation_warning() -> Generator[None, None, None]:
-    """Context manager to assert that code does not issue any deprecation warnings."""
-    with warnings.catch_warnings():
-        # FIXME: Refine this to filter for deprecation warnings from GitPython.
-        warnings.simplefilter("error", DeprecationWarning)
-        yield
-
-
 @pytest.fixture
 def commit(tmp_path: "Path") -> Generator["Commit", None, None]:
     """Fixture to supply a one-commit repo's commit, enough for deprecation tests."""
@@ -72,7 +62,7 @@ def test_diff_renamed_warns(diff: "Diff") -> None:
 
 def test_diff_renamed_file_does_not_warn(diff: "Diff") -> None:
     """The preferred Diff.renamed_file property issues no deprecation warning."""
-    with _assert_no_deprecation_warning():
+    with assert_no_deprecation_warning():
         diff.renamed_file
 
 
@@ -84,13 +74,13 @@ def test_commit_trailers_warns(commit: "Commit") -> None:
 
 def test_commit_trailers_list_does_not_warn(commit: "Commit") -> None:
     """The nondeprecated Commit.trailers_list property issues no deprecation warning."""
-    with _assert_no_deprecation_warning():
+    with assert_no_deprecation_warning():
         commit.trailers_list
 
 
 def test_commit_trailers_dict_does_not_warn(commit: "Commit") -> None:
     """The nondeprecated Commit.trailers_dict property issues no deprecation warning."""
-    with _assert_no_deprecation_warning():
+    with assert_no_deprecation_warning():
         commit.trailers_dict
 
 
@@ -102,7 +92,7 @@ def test_traverse_list_traverse_in_base_class_warns(commit: "Commit") -> None:
 
 def test_traversable_list_traverse_override_does_not_warn(commit: "Commit") -> None:
     """Calling list_traverse on concrete subclasses is not deprecated, does not warn."""
-    with _assert_no_deprecation_warning():
+    with assert_no_deprecation_warning():
         commit.list_traverse()
 
 
@@ -114,7 +104,7 @@ def test_traverse_traverse_in_base_class_warns(commit: "Commit") -> None:
 
 def test_traverse_traverse_override_does_not_warn(commit: "Commit") -> None:
     """Calling traverse on concrete subclasses is not deprecated, does not warn."""
-    with _assert_no_deprecation_warning():
+    with assert_no_deprecation_warning():
         commit.traverse()
 
 
@@ -128,7 +118,7 @@ class Derived(_Iterable):
 
 def test_iterable_obj_inheriting_does_not_warn() -> None:
     """Subclassing git.util.IterableObj is not deprecated, does not warn."""
-    with _assert_no_deprecation_warning():
+    with assert_no_deprecation_warning():
 
         class Derived(IterableObj):
             pass
diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 3f87037a0..6c592454a 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -56,12 +56,10 @@
 metaclass marginal enough, to justify introducing a metaclass to issue the warnings.
 """
 
-import contextlib
 import logging
 import sys
 from typing import Generator
 import unittest.mock
-import warnings
 
 if sys.version_info >= (3, 11):
     from typing import assert_type
@@ -73,6 +71,8 @@
 
 from git.cmd import Git
 
+from .lib import assert_no_deprecation_warning, suppress_deprecation_warning
+
 _USE_SHELL_DEPRECATED_FRAGMENT = "Git.USE_SHELL is deprecated"
 """Text contained in all USE_SHELL deprecation warnings, and starting most of them."""
 
@@ -82,13 +82,6 @@
 _logger = logging.getLogger(__name__)
 
 
-@contextlib.contextmanager
-def _suppress_deprecation_warning() -> Generator[None, None, None]:
-    with warnings.catch_warnings():
-        warnings.simplefilter("ignore", DeprecationWarning)
-        yield
-
-
 @pytest.fixture
 def restore_use_shell_state() -> Generator[None, None, None]:
     """Fixture to attempt to restore state associated with the USE_SHELL attribute.
@@ -129,7 +122,7 @@ def restore_use_shell_state() -> Generator[None, None, None]:
         # Try to save the original public value. Rather than attempt to restore a state
         # where the attribute is not set, if we cannot do this we allow AttributeError
         # to propagate out of the fixture, erroring the test case before its code runs.
-        with _suppress_deprecation_warning():
+        with suppress_deprecation_warning():
             old_public_value = Git.USE_SHELL
 
         # This doesn't have its own try-finally because pytest catches exceptions raised
@@ -137,7 +130,7 @@ def restore_use_shell_state() -> Generator[None, None, None]:
         yield
 
         # Try to restore the original public value.
-        with _suppress_deprecation_warning():
+        with suppress_deprecation_warning():
             Git.USE_SHELL = old_public_value
     finally:
         # Try to restore the original private state.
@@ -323,14 +316,7 @@ def test_execute_without_shell_arg_does_not_warn() -> None:
     USE_SHELL is to be used, the way Git.execute itself accesses USE_SHELL does not
     issue a deprecation warning.
     """
-    with warnings.catch_warnings():
-        for category in DeprecationWarning, PendingDeprecationWarning:
-            warnings.filterwarnings(
-                action="error",
-                category=category,
-                module=r"git(?:\.|$)",
-            )
-
+    with assert_no_deprecation_warning():
         Git().version()
 
 

From c7675d2cedcd737f20359a4a786e213510452413 Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Sat, 30 Mar 2024 08:56:41 +0100
Subject: [PATCH 122/332] update security policy, to use GitHub instead of
 email

---
 SECURITY.md | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/SECURITY.md b/SECURITY.md
index cbfaafdde..d39425b70 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,5 +11,4 @@ Only the latest version of GitPython can receive security updates. If a vulnerab
 
 ## Reporting a Vulnerability
 
-Please report private portions of a vulnerability to sebastian.thiel@icloud.com that would help to reproduce and fix it. To receive updates on progress and provide
-general information to the public, you can create an issue [on the issue tracker](https://github.com/gitpython-developers/GitPython/issues).
+Please report private portions of a vulnerability to <https://github.com/gitpython-developers/GitPython/security/advisories/new>. Doing so helps to receive updates and collaborate on the matter, without disclosing it publicliy right away.

From f92f4c3bf902c7fc3887cfd969b3e54f581f18f8 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 30 Mar 2024 20:16:45 -0400
Subject: [PATCH 123/332] Clarify security risk in USE_SHELL doc and warnings

---
 git/cmd.py | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/git/cmd.py b/git/cmd.py
index 42e6e927c..b2829801f 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -314,10 +314,10 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc
 )
 
 _USE_SHELL_DANGER_MESSAGE = (
-    "Setting Git.USE_SHELL to True is unsafe and insecure, and should be avoided, "
-    "because the effect of shell metacharacters and shell expansions cannot usually be "
-    "accounted for. In addition, Git.USE_SHELL is deprecated and will be removed in a "
-    "future release."
+    "Setting Git.USE_SHELL to True is unsafe and insecure, as the effect of special "
+    "shell syntax cannot usually be accounted for. This can result in a command "
+    "injection vulnerability and arbitrary code execution. Git.USE_SHELL is deprecated "
+    "and will be removed in a future release."
 )
 
 
@@ -413,6 +413,13 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
     considered and accounted for, which is not possible under most circumstances. As
     detailed below, it is also no longer needed, even where it had been in the past.
 
+    It is in many if not most cases a command injection vulnerability for an application
+    to set :attr:`USE_SHELL` to ``True``. Any attacker who can cause a specially crafted
+    fragment of text to make its way into any part of any argument to any git command
+    (including paths, branch names, etc.) can cause the shell to read and write
+    arbitrary files and execute arbitrary commands. Innocent input may also accidentally
+    contain special shell syntax, leading to inadvertent malfunctions.
+
     In addition, how a value of ``True`` interacts with some aspects of GitPython's
     operation is not precisely specified and may change without warning, even before
     GitPython 4.0.0 when :attr:`USE_SHELL` may be removed. This includes:

From 8327b458bdcf73895aa3b0a9a990a61ce2e54ee9 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 16:39:20 -0400
Subject: [PATCH 124/332] Test GitMeta alias

---
 test/deprecation/test_cmd_git.py | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py
index 6c592454a..e44490273 100644
--- a/test/deprecation/test_cmd_git.py
+++ b/test/deprecation/test_cmd_git.py
@@ -69,7 +69,7 @@
 import pytest
 from pytest import WarningsRecorder
 
-from git.cmd import Git
+from git.cmd import Git, GitMeta
 
 from .lib import assert_no_deprecation_warning, suppress_deprecation_warning
 
@@ -377,3 +377,15 @@ def test_instance_dir() -> None:
     instance = Git()
     actual = set(dir(instance))
     assert _EXPECTED_DIR_SUBSET <= actual
+
+
+def test_metaclass_alias() -> None:
+    """GitMeta aliases Git's metaclass, whether that is type or a custom metaclass."""
+
+    def accept_metaclass_instance(cls: GitMeta) -> None:
+        """Check that cls is statically recognizable as an instance of GitMeta."""
+
+    accept_metaclass_instance(Git)  # assert_type would expect Type[Git], not GitMeta.
+
+    # This comes after the static check, just in case it would affect the inference.
+    assert type(Git) is GitMeta

From f6060df576acda613227a57f03c01d235eceaeae Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 27 Mar 2024 17:38:31 -0400
Subject: [PATCH 125/332] Add GitMeta alias

Hopefully no one will ever need it.
---
 git/cmd.py | 28 +++++++++++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

diff --git a/git/cmd.py b/git/cmd.py
index b2829801f..90fc39cd6 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -5,7 +5,7 @@
 
 from __future__ import annotations
 
-__all__ = ["Git"]
+__all__ = ["GitMeta", "Git"]
 
 import contextlib
 import io
@@ -354,6 +354,32 @@ def __setattr(cls, name: str, value: Any) -> Any:
         __setattr__ = __setattr
 
 
+GitMeta = _GitMeta
+"""Alias of :class:`Git`'s metaclass, whether it is :class:`type` or a custom metaclass.
+
+Whether the :class:`Git` class has the default :class:`type` as its metaclass or uses a
+custom metaclass is not documented and may change at any time. This statically checkable
+metaclass alias is equivalent at runtime to ``type(Git)``. This should almost never be
+used. Code that benefits from it is likely to be remain brittle even if it is used.
+
+In view of the :class:`Git` class's intended use and :class:`Git` objects' dynamic
+callable attributes representing git subcommands, it rarely makes sense to inherit from
+:class:`Git` at all. Using :class:`Git` in multiple inheritance can be especially tricky
+to do correctly. Attempting uses of :class:`Git` where its metaclass is relevant, such
+as when a sibling class has an unrelated metaclass and a shared lower bound metaclass
+might have to be introduced to solve a metaclass conflict, is not recommended.
+
+:note:
+    The correct static type of the :class:`Git` class itself, and any subclasses, is
+    ``Type[Git]``. (This can be written as ``type[Git]`` in Python 3.9 later.)
+
+    :class:`GitMeta` should never be used in any annotation where ``Type[Git]`` is
+    intended or otherwise possible to use. This alias is truly only for very rare and
+    inherently precarious situations where it is necessary to deal with the metaclass
+    explicitly.
+"""
+
+
 class Git(metaclass=_GitMeta):
     """The Git class manages communication with the Git binary.
 

From 53640535cf8314366a01da081947dd8504a299cd Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Sun, 31 Mar 2024 10:05:27 +0200
Subject: [PATCH 126/332] bump version to 3.1.43

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

diff --git a/VERSION b/VERSION
index f69b7f25e..d1bf6638d 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.1.42
+3.1.43
diff --git a/doc/source/changes.rst b/doc/source/changes.rst
index c75e5eded..0bc757134 100644
--- a/doc/source/changes.rst
+++ b/doc/source/changes.rst
@@ -2,6 +2,15 @@
 Changelog
 =========
 
+3.1.43
+======
+
+A major visible change will be the added deprecation- or user-warnings,
+and greatly improved typing.
+
+See the following for all changes.
+https://github.com/gitpython-developers/GitPython/releases/tag/3.1.43
+
 3.1.42
 ======
 

From 83bed19f360b69dad26b7d9b00ffd837c8075b7a Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 31 Mar 2024 14:57:52 -0400
Subject: [PATCH 127/332] Fix wording of comment about the /cygdrive prefix

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

diff --git a/git/repo/fun.py b/git/repo/fun.py
index e44d9c644..182cf82ed 100644
--- a/git/repo/fun.py
+++ b/git/repo/fun.py
@@ -112,7 +112,7 @@ def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]:
             path = content[8:]
 
             if Git.is_cygwin():
-                # Cygwin creates submodules prefixed with `/cygdrive/...` suffixes.
+                # Cygwin creates submodules prefixed with `/cygdrive/...`.
                 # Cygwin git understands Cygwin paths much better than Windows ones.
                 # Also the Cygwin tests are assuming Cygwin paths.
                 path = cygpath(path)

From 988d97bf12c5b15ff4693a5893134271dd8d8a28 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 31 Mar 2024 15:40:47 -0400
Subject: [PATCH 128/332] Fix typo in _get_exe_extensions PATHEXT fallback

PATHEXT lists file extensions with the ".". In the fallback given
in _get_exe_extensions, the other extensions had this, but ".COM"
was listed without the ".". This fixes that.

This is very minor because _get_exe_extensions is nonpublic and not
currently used on native Windows, which is the platform where the
PATHEXT fallback code would be used.

Specifically, _get_exe_extensions is called only in py_where, which
while named with no leading underscore is nonpublic do not being
(and never having been) listed in __all__. As its docstring states,
it is an implementation detail of is_cygwin_git and not intended
for any other use. More specifically, is_cygwin_git currently
immediately returns False on *native* Windows (even if the git
executable GitPython is using is a Cygwin git executable). Only on
Cygwin, or other systems that are not native Windows, does it try
to check the git executable (by calling its _is_cygwin_git helper,
which uses py_where).
---
 git/util.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/util.py b/git/util.py
index 8c1c26012..11f963e02 100644
--- a/git/util.py
+++ b/git/util.py
@@ -339,7 +339,7 @@ def _get_exe_extensions() -> Sequence[str]:
     if PATHEXT:
         return tuple(p.upper() for p in PATHEXT.split(os.pathsep))
     elif sys.platform == "win32":
-        return (".BAT", "COM", ".EXE")
+        return (".BAT", ".COM", ".EXE")
     else:
         return ()
 

From f18df8edcf5de5971e4dd01f15ad32411e38244e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 31 Mar 2024 18:07:32 -0400
Subject: [PATCH 129/332] Don't pass --disable-warnings to pytest

---
 pyproject.toml | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 6cb05f96e..ee54edb78 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ requires = ["setuptools"]
 build-backend = "setuptools.build_meta"
 
 [tool.pytest.ini_options]
-addopts = "--cov=git --cov-report=term --disable-warnings -ra"
+addopts = "--cov=git --cov-report=term -ra"
 filterwarnings = "ignore::DeprecationWarning"
 python_files = "test_*.py"
 tmp_path_retention_policy = "failed"
@@ -13,7 +13,6 @@ testpaths = "test"  # Space separated list of paths from root e.g test tests doc
 # --cov-report term-missing # to terminal with line numbers
 # --cov-report html:path  # html file at path
 # --maxfail  # number of errors before giving up
-# -disable-warnings  # Disable pytest warnings (not codebase warnings)
 # -rfE  # default test summary: list fail and error
 # -ra   # test summary: list all non-passing (fail, error, skip, xfail, xpass)
 # --ignore-glob=**/gitdb/*  # ignore glob paths

From 0152b528a035566364fc8a0e1a80829c6d495301 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Mon, 1 Apr 2024 14:53:43 -0400
Subject: [PATCH 130/332] Update the comment about `--mixed` and paths

This updates the comment in HEAD.reset about why `--mixed` is
omitted from the git command constructed to perform a reset where
paths are being passed, adding specific information about the git
versions where this is deprecated, and changing the more-info link
from an old GitPython issue that is no longer retrievable to #1876.
---
 git/refs/head.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/git/refs/head.py b/git/refs/head.py
index 7e6fc3377..683634451 100644
--- a/git/refs/head.py
+++ b/git/refs/head.py
@@ -99,8 +99,8 @@ def reset(
         if index:
             mode = "--mixed"
 
-            # It appears some git versions declare mixed and paths deprecated.
-            # See http://github.com/Byron/GitPython/issues#issue/2.
+            # Explicit "--mixed" when passing paths is deprecated since git 1.5.4.
+            # See https://github.com/gitpython-developers/GitPython/discussions/1876.
             if paths:
                 mode = None
             # END special case

From a9593c7c56e76bdef35245be00bf23abdc3ba4c0 Mon Sep 17 00:00:00 2001
From: Eduard Talanov <89387701+EduardTalanov@users.noreply.github.com>
Date: Fri, 5 Apr 2024 10:10:53 +0200
Subject: [PATCH 131/332] Update remote.py

Fixed the error of updating shallow submodules
---
 git/remote.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/remote.py b/git/remote.py
index f2ecd0f36..37c991d27 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -316,7 +316,7 @@ class FetchInfo(IterableObj):
         ERROR,
     ) = [1 << x for x in range(8)]
 
-    _re_fetch_result = re.compile(r"^ *(.) (\[[\w \.$@]+\]|[\w\.$@]+) +(.+) -> ([^ ]+)(    \(.*\)?$)?")
+    _re_fetch_result = re.compile(r"^ *(?:.{0,3})(.) (\[[\w \.$@]+\]|[\w\.$@]+) +(.+) -> ([^ ]+)(    \(.*\)?$)?")
 
     _flag_map: Dict[flagKeyLiteral, int] = {
         "!": ERROR,

From 55c30a34185e13c31ab14c39f7a7dd0db3a494e4 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 11 Apr 2024 19:55:10 -0400
Subject: [PATCH 132/332] OSS-Fuzz test initial migration

Migrates the OSS-Fuzz tests and setup scripts from the OSS-Fuzz
repository to GitPython's repo as discussed here:
https://github.com/gitpython-developers/GitPython/issues/1887#issuecomment-2028599381

These files include the changes that were originally proposed in:
https://github.com/google/oss-fuzz/pull/11763

Additional changes include:
- A first pass at documenting the contents of the fuzzing set up in a
  dedicated README.md
- Adding the dictionary files to this repo for improved visibility. Seed
  corpra zips are still located in an external repo pending further
 discussion regarding where those should live in the long term.
---
 fuzzing/README.md                             | 172 ++++++++++++++++++
 fuzzing/dictionaries/fuzz_config.dict         |  56 ++++++
 fuzzing/dictionaries/fuzz_tree.dict           |  13 ++
 fuzzing/fuzz-targets/fuzz_config.py           |  51 ++++++
 fuzzing/fuzz-targets/fuzz_tree.py             |  59 ++++++
 fuzzing/oss-fuzz-scripts/build.sh             |  37 ++++
 .../container-environment-bootstrap.sh        |  56 ++++++
 7 files changed, 444 insertions(+)
 create mode 100644 fuzzing/README.md
 create mode 100644 fuzzing/dictionaries/fuzz_config.dict
 create mode 100644 fuzzing/dictionaries/fuzz_tree.dict
 create mode 100644 fuzzing/fuzz-targets/fuzz_config.py
 create mode 100644 fuzzing/fuzz-targets/fuzz_tree.py
 create mode 100644 fuzzing/oss-fuzz-scripts/build.sh
 create mode 100644 fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh

diff --git a/fuzzing/README.md b/fuzzing/README.md
new file mode 100644
index 000000000..6853c5002
--- /dev/null
+++ b/fuzzing/README.md
@@ -0,0 +1,172 @@
+# Fuzzing GitPython
+
+[![Fuzzing Status](https://oss-fuzz-build-logs.storage.googleapis.com/badges/gitpython.svg)][oss-fuzz-issue-tracker]
+
+This directory contains files related to GitPython's suite of fuzz tests that are executed daily on automated
+infrastructure provided by [OSS-Fuzz][oss-fuzz-repo]. This document aims to provide necessary information for working
+with fuzzing in GitPython.
+
+The details about the latest OSS-Fuzz test status, including build logs and coverage reports, is made available
+at [this link](https://introspector.oss-fuzz.com/project-profile?project=gitpython).
+
+## How to Contribute
+
+There are many ways to contribute to GitPython's fuzzing efforts! Contributions are welcomed through issues,
+discussions, or pull requests on this repository.
+
+Areas that are particularly appreciated include:
+
+- **Tackling the existing backlog of open issues**. While fuzzing is an effective way to identify bugs, that information
+  isn't useful unless they are fixed. If you are not sure where to start, the issues tab is a great place to get ideas!
+- **Improvements to this (or other) documentation** make it easier for new contributors to get involved, so even small
+  improvements can have a large impact over time. If you see something that could be made easier by a documentation
+  update of any size, please consider suggesting it!
+
+For everything else, such as expanding test coverage, optimizing test performance, or enhancing error detection
+capabilities, jump in to the "Getting Started" section below.
+
+## Getting Started with Fuzzing GitPython
+
+> [!TIP]
+> **New to fuzzing or unfamiliar with OSS-Fuzz?**
+>
+> These resources are an excellent place to start:
+>
+> - [OSS-Fuzz documentation][oss-fuzz-docs] - Continuous fuzzing service for open source software.
+> - [Google/fuzzing][google-fuzzing-repo] - Tutorials, examples, discussions, research proposals, and other resources
+    related to fuzzing.
+> - [CNCF Fuzzing Handbook](https://github.com/cncf/tag-security/blob/main/security-fuzzing-handbook/handbook-fuzzing.pdf) -
+    A comprehensive guide for fuzzing open source software.
+> - [Efficient Fuzzing Guide by The Chromium Project](https://chromium.googlesource.com/chromium/src/+/main/testing/libfuzzer/efficient_fuzzing.md) -
+    Explores strategies to enhance the effectiveness of your fuzz tests, recommended for those looking to optimize their
+    testing efforts.
+
+### Setting Up Your Local Environment
+
+Before contributing to fuzzing efforts, ensure Python and Docker are installed on your machine. Docker is required for
+running fuzzers in containers provided by OSS-Fuzz. [Install Docker](https://docs.docker.com/get-docker/) following the
+official guide if you do not already have it.
+
+### Understanding Existing Fuzz Targets
+
+Review the `fuzz-targets/` directory to familiarize yourself with how existing tests are implemented. See
+the [Files & Directories Overview](#files--directories-overview) for more details on the directory structure.
+
+### Contributing to Fuzz Tests
+
+Start by reviewing the [Atheris documentation][atheris-repo] and the section
+on [Running Fuzzers Locally](#running-fuzzers-locally) to begin writing or improving fuzz tests.
+
+## Files & Directories Overview
+
+The `fuzzing/` directory is organized into three key areas:
+
+### Fuzz Targets (`fuzz-targets/`)
+
+Contains Python files for each fuzz test, targeting specific functionalities of GitPython.
+
+**Things to Know**:
+
+- Each fuzz test targets a specific part of GitPython's functionality.
+- Test files adhere to the naming convention: `fuzz_<API Under Test>.py`, where `<API Under Test>` indicates the
+  functionality targeted by the test.
+- Any functionality that involves performing operations on input data is a possible candidate for fuzz testing, but
+  features that involve processing untrusted user input or parsing operations are typically going to be the most
+  interesting.
+- The goal of these tests is to identify previously unknown or unexpected error cases caused by a given input. For that
+  reason, fuzz tests should gracefully handle anticipated exception cases with a `try`/`except` block to avoid false
+  positives that halt the fuzzing engine.
+
+### Dictionaries (`dictionaries/`)
+
+Provides hints to the fuzzing engine about inputs that might trigger unique code paths. Each fuzz target may have a
+corresponding `.dict` file. For details on how these are used, refer
+to [LibFuzzer documentation](https://llvm.org/docs/LibFuzzer.html#dictionaries).
+
+**Things to Know**:
+
+- OSS-Fuzz loads dictionary files per fuzz target if one exists with the same name, all others are ignored.
+- Most entries in the dictionary files found here are escaped hex or Unicode values that were recommended by the fuzzing
+  engine after previous runs.
+- A default set of dictionary entries are created for all fuzz targets as part of the build process, regardless of an
+  existing file here.
+- Development or updates to dictionaries should reflect the varied formats and edge cases relevant to the
+  functionalities under test.
+- Example dictionaries (some of which are used to build the default dictionaries mentioned above) are can be found here:
+  - [AFL++ dictionary repository](https://github.com/AFLplusplus/AFLplusplus/tree/stable/dictionaries#readme)
+  - [Google/fuzzing dictionary repository](https://github.com/google/fuzzing/tree/master/dictionaries)
+
+### OSS-Fuzz Scripts (`oss-fuzz-scripts/`)
+
+Includes scripts for building and integrating fuzz targets with OSS-Fuzz:
+
+- **`container-environment-bootstrap.sh`** - Sets up the execution environment. It is responsible for fetching default
+  dictionary entries and ensuring all required build dependencies are installed and up-to-date.
+- **`build.sh`** - Executed within the Docker container, this script builds fuzz targets with necessary instrumentation
+  and prepares seed corpora and dictionaries for use.
+
+## Running Fuzzers Locally
+
+### Direct Execution of Fuzz Targets
+
+For quick testing of changes, [Atheris][atheris-repo] makes it possible to execute a fuzz target directly:
+
+1. Install Atheris following the [installation guide][atheris-repo] for your operating system.
+2. Execute a fuzz target, for example:
+
+```shell
+python fuzzing/fuzz-targets/fuzz_config.py
+```
+
+### Running OSS-Fuzz Locally
+
+This approach uses Docker images provided by OSS-Fuzz for building and running fuzz tests locally. It offers
+comprehensive features but requires a local clone of the OSS-Fuzz repository and sufficient disk space for Docker
+containers.
+
+#### Preparation
+
+Set environment variables to simplify command usage:
+
+```shell
+export SANITIZER=address  # Can be either 'address' or 'undefined'.
+export FUZZ_TARGET=fuzz_config # specify the fuzz target without the .py extension.
+```
+
+#### Build and Run
+
+Clone the OSS-Fuzz repository and prepare the Docker environment:
+
+```shell
+git clone --depth 1 https://github.com/google/oss-fuzz.git oss-fuzz
+cd oss-fuzz
+python infra/helper.py build_image gitpython
+python infra/helper.py build_fuzzers --sanitizer $SANITIZER gitpython
+```
+
+Verify the build of your fuzzers with the optional `check_build` command:
+
+```shell
+python infra/helper.py check_build gitpython
+```
+
+Execute the desired fuzz target:
+
+```shell
+python infra/helper.py run_fuzzer gitpython $FUZZ_TARGET
+```
+
+#### Next Steps
+
+For detailed instructions on advanced features like reproducing OSS-Fuzz issues or using the Fuzz Introspector, refer
+to [the official OSS-Fuzz documentation][oss-fuzz-docs].
+
+[oss-fuzz-repo]: https://github.com/google/oss-fuzz
+
+[oss-fuzz-docs]: https://google.github.io/oss-fuzz
+
+[oss-fuzz-issue-tracker]: https://bugs.chromium.org/p/oss-fuzz/issues/list?sort=-opened&can=1&q=proj:gitpython
+
+[google-fuzzing-repo]: https://github.com/google/fuzzing
+
+[atheris-repo]: https://github.com/google/atheris
diff --git a/fuzzing/dictionaries/fuzz_config.dict b/fuzzing/dictionaries/fuzz_config.dict
new file mode 100644
index 000000000..b545ddfc8
--- /dev/null
+++ b/fuzzing/dictionaries/fuzz_config.dict
@@ -0,0 +1,56 @@
+"\\004\\000\\000\\000\\000\\000\\000\\000"
+"\\006\\000\\000\\000\\000\\000\\000\\000"
+"_validate_value_"
+"\\000\\000\\000\\000\\000\\000\\000\\000"
+"rem"
+"__eq__"
+"\\001\\000\\000\\000"
+"__abstrac"
+"_mutating_methods_"
+"items"
+"\\0021\\""
+"\\001\\000"
+"\\000\\000\\000\\000"
+"DEFAULT"
+"getfloat"
+"\\004\\000\\000\\000\\000\\000\\000\\000"
+"news"
+"\\037\\000\\000\\000\\000\\000\\000\\000"
+"\\001\\000\\000\\000\\000\\000\\000\\037"
+"\\000\\000\\000\\000\\000\\000\\000\\014"
+"list"
+"\\376\\377\\377\\377\\377\\377\\377\\377"
+"items_all"
+"\\004\\000\\000\\000\\000\\000\\000\\000"
+"\\377\\377\\377\\377\\377\\377\\377\\014"
+"\\001\\000\\000\\000"
+"_acqui"
+"\\000\\000\\000\\000\\000\\000\\000\\000"
+"__ne__"
+"__exit__"
+"__modu"
+"uucp"
+"__str__"
+"\\001\\000\\000\\000"
+"\\017\\000\\000\\000\\000\\000\\000\\000"
+"_has_incl"
+"update"
+"\\377\\377\\377\\377\\377\\377\\377\\023"
+"setdef"
+"setdefaul"
+"\\000\\000\\000\\000"
+"\\001\\000\\000\\000"
+"\\001\\000"
+"\\022\\000\\000\\000\\000\\000\\000\\000"
+"_value_to_string"
+"__abstr"
+"\\001\\000\\000\\000\\000\\000\\000\\000"
+"\\000\\000\\000\\000\\000\\000\\000\\022"
+"\\377\\377\\377\\377"
+"\\004\\000\\000\\000\\000\\000\\000\\000"
+"\\000\\000\\000\\000\\000\\000\\000\\000"
+"\\000\\000\\000\\000\\000\\000\\000\\037"
+"\\001\\000\\000\\000\\000\\000\\000\\013"
+"_OPT_TM"
+"__name__"
+"_get_conv"
diff --git a/fuzzing/dictionaries/fuzz_tree.dict b/fuzzing/dictionaries/fuzz_tree.dict
new file mode 100644
index 000000000..3ebe52b7f
--- /dev/null
+++ b/fuzzing/dictionaries/fuzz_tree.dict
@@ -0,0 +1,13 @@
+"\\001\\000\\000\\000"
+"_join_multiline_va"
+"setdef"
+"1\\000\\000\\000\\000\\000\\000\\000"
+"\\000\\000\\000\\000\\000\\000\\000\\020"
+"\\377\\377\\377\\377\\377\\377\\377r"
+"\\001\\000\\000\\000\\000\\000\\000\\001"
+"\\000\\000\\000\\000\\000\\000\\000\\014"
+"\\000\\000\\000\\000\\000\\000\\000\\003"
+"\\001\\000"
+"\\032\\000\\000\\000\\000\\000\\000\\000"
+"-\\000\\000\\000\\000\\000\\000\\000"
+"__format"
diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py
new file mode 100644
index 000000000..1403c96e4
--- /dev/null
+++ b/fuzzing/fuzz-targets/fuzz_config.py
@@ -0,0 +1,51 @@
+#!/usr/bin/python3
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import atheris
+import sys
+import io
+from configparser import MissingSectionHeaderError, ParsingError
+
+with atheris.instrument_imports():
+    from git import GitConfigParser
+
+
+def TestOneInput(data):
+    sio = io.BytesIO(data)
+    sio.name = "/tmp/fuzzconfig.config"
+    git_config = GitConfigParser(sio)
+    try:
+        git_config.read()
+    except (MissingSectionHeaderError, ParsingError, UnicodeDecodeError):
+        return -1  # Reject inputs raising expected exceptions
+    except (IndexError, ValueError) as e:
+        if isinstance(e, IndexError) and "string index out of range" in str(e):
+            # Known possibility that might be patched
+            # See: https://github.com/gitpython-developers/GitPython/issues/1887
+            pass
+        elif isinstance(e, ValueError) and "embedded null byte" in str(e):
+            # The `os.path.expanduser` function, which does not accept strings
+            # containing null bytes might raise this.
+            return -1
+        else:
+            raise e  # Raise unanticipated exceptions as they might be bugs
+
+
+def main():
+    atheris.Setup(sys.argv, TestOneInput)
+    atheris.Fuzz()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/fuzzing/fuzz-targets/fuzz_tree.py b/fuzzing/fuzz-targets/fuzz_tree.py
new file mode 100644
index 000000000..53258fb1e
--- /dev/null
+++ b/fuzzing/fuzz-targets/fuzz_tree.py
@@ -0,0 +1,59 @@
+#!/usr/bin/python3
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import atheris
+import io
+import sys
+import os
+import shutil
+
+with atheris.instrument_imports():
+    from git.objects import Tree
+    from git.repo import Repo
+
+
+def TestOneInput(data):
+    fdp = atheris.FuzzedDataProvider(data)
+    git_dir = "/tmp/.git"
+    head_file = os.path.join(git_dir, "HEAD")
+    refs_dir = os.path.join(git_dir, "refs")
+    common_dir = os.path.join(git_dir, "commondir")
+    objects_dir = os.path.join(git_dir, "objects")
+
+    if os.path.isdir(git_dir):
+        shutil.rmtree(git_dir)
+
+    os.mkdir(git_dir)
+    with open(head_file, "w") as f:
+        f.write(fdp.ConsumeUnicodeNoSurrogates(1024))
+    os.mkdir(refs_dir)
+    os.mkdir(common_dir)
+    os.mkdir(objects_dir)
+
+    _repo = Repo("/tmp/")
+
+    fuzz_tree = Tree(_repo, Tree.NULL_BIN_SHA, 0, "")
+    try:
+        fuzz_tree._deserialize(io.BytesIO(data))
+    except IndexError:
+        return -1
+
+
+def main():
+    atheris.Setup(sys.argv, TestOneInput)
+    atheris.Fuzz()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
new file mode 100644
index 000000000..fdab7a1e0
--- /dev/null
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+python3 -m pip install .
+
+# Directory to look in for dictionaries, options files, and seed corpa:
+SEED_DATA_DIR="$SRC/seed_data"
+
+find "$SEED_DATA_DIR" \( -name '*_seed_corpus.zip' -o -name '*.options' -o -name '*.dict' \) \
+  ! \( -name '__base.*' \) -exec printf 'Copying: %s\n' {} \; \
+  -exec chmod a-x {} \; \
+  -exec cp {} "$OUT" \;
+
+# Build fuzzers in $OUT.
+find "$SRC" -name 'fuzz_*.py' -print0 | while IFS= read -r -d $'\0' fuzz_harness; do
+  compile_python_fuzzer "$fuzz_harness"
+
+  common_base_dictionary_filename="$SEED_DATA_DIR/__base.dict"
+  if [[ -r "$common_base_dictionary_filename" ]]; then
+    # Strip the `.py` extension from the filename and replace it with `.dict`.
+    fuzz_harness_dictionary_filename="$(basename "$fuzz_harness" .py).dict"
+    output_file="$OUT/$fuzz_harness_dictionary_filename"
+
+    printf 'Appending %s to %s\n' "$common_base_dictionary_filename" "$output_file"
+    if [[ -s "$output_file" ]]; then
+      # If a dictionary file for this fuzzer already exists and is not empty,
+      # we append a new line to the end of it before appending any new entries.
+      #
+      # libfuzzer will happily ignore multiple empty lines in a dictionary but crash
+      # if any single line has incorrect syntax (e.g., if we accidentally add two entries to the same line.)
+      # See docs for valid syntax: https://llvm.org/docs/LibFuzzer.html#id32
+      echo >>"$output_file"
+    fi
+    cat "$common_base_dictionary_filename" >>"$output_file"
+  fi
+done
diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
new file mode 100644
index 000000000..43c21a8da
--- /dev/null
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+#################
+# Prerequisites #
+#################
+
+for cmd in python3 git wget rsync; do
+  command -v "$cmd" >/dev/null 2>&1 || {
+    printf '[%s] Required command %s not found, exiting.\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$cmd" >&2
+    exit 1
+  }
+done
+
+SEED_DATA_DIR="$SRC/seed_data"
+mkdir -p "$SEED_DATA_DIR"
+
+#############
+# Functions #
+#############
+
+download_and_concatenate_common_dictionaries() {
+  # Assign the first argument as the target file where all contents will be concatenated
+  target_file="$1"
+
+  # Shift the arguments so the first argument (target_file path) is removed
+  # and only URLs are left for the loop below.
+  shift
+
+  for url in "$@"; do
+    wget -qO- "$url" >>"$target_file"
+    # Ensure there's a newline between each file's content
+    echo >>"$target_file"
+  done
+}
+
+fetch_seed_corpra() {
+  # Seed corpus zip files are hosted in a separate repository to avoid additional bloat in this repo.
+  git clone --depth 1 https://github.com/DaveLak/oss-fuzz-inputs.git oss-fuzz-inputs &&
+    rsync -avc oss-fuzz-inputs/gitpython/corpra/ "$SEED_DATA_DIR/" &&
+    rm -rf oss-fuzz-inputs; # Clean up the cloned repo to keep the Docker image as slim as possible.
+}
+
+########################
+# Main execution logic #
+########################
+
+fetch_seed_corpra;
+
+download_and_concatenate_common_dictionaries "$SEED_DATA_DIR/__base.dict" \
+  "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/utf8.dict" \
+  "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/url.dict";
+
+# The OSS-Fuzz base image has outdated dependencies by default so we upgrade them below.
+python3 -m pip install --upgrade pip;
+python3 -m pip install 'setuptools~=69.0' 'pyinstaller~=6.0'; # Uses the latest versions know to work at the time of this commit.

From d0c6ee62ad64a074ca885c0c2edbbc5074542b6e Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 11 Apr 2024 20:11:34 -0400
Subject: [PATCH 133/332] Update documentation to include fuzzing specific info

As per discussion in https://github.com/gitpython-developers/GitPython/discussions/1889
---
 CONTRIBUTING.md |  5 +++++
 README.md       | 12 ++++++++++++
 2 files changed, 17 insertions(+)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e108f1b80..8536d7f73 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -8,3 +8,8 @@ The following is a short step-by-step rundown of what one typically would do to
 - Try to avoid massive commits and prefer to take small steps, with one commit for each.
 - Feel free to add yourself to AUTHORS file.
 - Create a pull request.
+
+## Fuzzing Test Specific Documentation
+
+For details related to contributing to the fuzzing test suite and OSS-Fuzz integration, please 
+refer to the dedicated [fuzzing README](./fuzzing/README.md).
diff --git a/README.md b/README.md
index 9bedaaae7..39f5496dc 100644
--- a/README.md
+++ b/README.md
@@ -240,5 +240,17 @@ Please have a look at the [contributions file][contributing].
 
 [3-Clause BSD License](https://opensource.org/license/bsd-3-clause/), also known as the New BSD License. See the [LICENSE file][license].
 
+> [!NOTE]
+> There are two special case files located in the `fuzzzing/` directory that are licensed differently:
+>
+> `fuzz_config.py` and `fuzz_tree.py` were migrated here from the OSS-Fuzz project repository where they were initially
+> created and retain the original licence and copyright notice (Apache License, Version 2.0 and Copyright 2023 Google
+> LLC respectively.)
+>
+> - **These files do not impact the licence under which GitPython releases or source code are distributed.**
+> - The files located in the `fuzzzing/` directory are part of the project test suite and neither packaged nor distributed as
+    part of any release.
+
+
 [contributing]: https://github.com/gitpython-developers/GitPython/blob/main/CONTRIBUTING.md
 [license]: https://github.com/gitpython-developers/GitPython/blob/main/LICENSE

From 1bc9a1a8250aa7291255cf389be2fa871c9049db Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 11 Apr 2024 21:19:24 -0400
Subject: [PATCH 134/332] Improve fuzzing README

Adds additional documentation links and fixes some typos.
---
 fuzzing/README.md | 27 ++++++++++++++++++++++-----
 1 file changed, 22 insertions(+), 5 deletions(-)

diff --git a/fuzzing/README.md b/fuzzing/README.md
index 6853c5002..97a62724a 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -6,7 +6,7 @@ This directory contains files related to GitPython's suite of fuzz tests that ar
 infrastructure provided by [OSS-Fuzz][oss-fuzz-repo]. This document aims to provide necessary information for working
 with fuzzing in GitPython.
 
-The details about the latest OSS-Fuzz test status, including build logs and coverage reports, is made available
+The latest details regarding OSS-Fuzz test status, including build logs and coverage reports, is made available
 at [this link](https://introspector.oss-fuzz.com/project-profile?project=gitpython).
 
 ## How to Contribute
@@ -23,7 +23,7 @@ Areas that are particularly appreciated include:
   update of any size, please consider suggesting it!
 
 For everything else, such as expanding test coverage, optimizing test performance, or enhancing error detection
-capabilities, jump in to the "Getting Started" section below.
+capabilities, jump into the "Getting Started" section below.
 
 ## Getting Started with Fuzzing GitPython
 
@@ -63,7 +63,7 @@ The `fuzzing/` directory is organized into three key areas:
 
 ### Fuzz Targets (`fuzz-targets/`)
 
-Contains Python files for each fuzz test, targeting specific functionalities of GitPython.
+Contains Python files for each fuzz test.
 
 **Things to Know**:
 
@@ -81,7 +81,7 @@ Contains Python files for each fuzz test, targeting specific functionalities of
 
 Provides hints to the fuzzing engine about inputs that might trigger unique code paths. Each fuzz target may have a
 corresponding `.dict` file. For details on how these are used, refer
-to [LibFuzzer documentation](https://llvm.org/docs/LibFuzzer.html#dictionaries).
+to the [LibFuzzer documentation on the subject](https://llvm.org/docs/LibFuzzer.html#dictionaries).
 
 **Things to Know**:
 
@@ -105,6 +105,11 @@ Includes scripts for building and integrating fuzz targets with OSS-Fuzz:
 - **`build.sh`** - Executed within the Docker container, this script builds fuzz targets with necessary instrumentation
   and prepares seed corpora and dictionaries for use.
 
+**Where to learn more:**
+
+- [OSS-Fuzz documentation on the build.sh](https://google.github.io/oss-fuzz/getting-started/new-project-guide/#buildsh)
+- [See GitPython's build.sh and Dockerfile in the OSS-Fuzz repository](https://github.com/google/oss-fuzz/tree/master/projects/gitpython)
+
 ## Running Fuzzers Locally
 
 ### Direct Execution of Fuzz Targets
@@ -153,9 +158,21 @@ python infra/helper.py check_build gitpython
 Execute the desired fuzz target:
 
 ```shell
-python infra/helper.py run_fuzzer gitpython $FUZZ_TARGET
+python infra/helper.py run_fuzzer gitpython $FUZZ_TARGET -- -max_total_time=60 -print_final_stats=1
 ```
 
+> [!TIP]
+> In the example above, the "`-- -max_total_time=60 -print_final_stats=1`" portion of the command is optional but quite
+> useful.
+>
+> Every argument provided after "`--`" in the above command is passed to the fuzzing engine directly. In this case:
+> - `-max_total_time=60` tells the LibFuzzer to stop execution after 60 seconds have elapsed.
+> - `-print_final_stats=1` tells the LibFuzzer to print a summary of useful metrics about the target run upon
+    completion.
+>
+> But almost any [LibFuzzer option listed in the documentation](https://llvm.org/docs/LibFuzzer.html#options) should
+> work as well.
+
 #### Next Steps
 
 For detailed instructions on advanced features like reproducing OSS-Fuzz issues or using the Fuzz Introspector, refer

From 576a858b7298bd14b1e87b118150df963af447dd Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 11 Apr 2024 22:06:05 -0400
Subject: [PATCH 135/332] Updates to support easily running OSS-Fuzz using
 local repo sources

- Updates the fuzzing documentation to include steps for working with
  locally modified versions of the gitpython repository.
- Updates the build.sh script to make the fuzz target search path more
  specific, reducing the risk of local OSS-Fuzz builds picking up
  files located outside of where we expect them (for example, in a .venv
  directory.)
- add artifacts produced by local OSS-Fuzz runs to gitignore
---
 .gitignore                        |  3 +++
 fuzzing/README.md                 | 19 +++++++++++++++++--
 fuzzing/oss-fuzz-scripts/build.sh |  2 +-
 3 files changed, 21 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index 7765293d8..d85569405 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,6 @@ output.txt
 
 # Finder metadata
 .DS_Store
+
+# Files created by OSS-Fuzz when running locally
+fuzz_*.pkg.spec
diff --git a/fuzzing/README.md b/fuzzing/README.md
index 97a62724a..acaf17d06 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -134,8 +134,10 @@ containers.
 Set environment variables to simplify command usage:
 
 ```shell
-export SANITIZER=address  # Can be either 'address' or 'undefined'.
-export FUZZ_TARGET=fuzz_config # specify the fuzz target without the .py extension.
+# $SANITIZER can be either 'address' or 'undefined':
+export SANITIZER=address
+# specify the fuzz target without the .py extension:
+export FUZZ_TARGET=fuzz_config
 ```
 
 #### Build and Run
@@ -149,6 +151,19 @@ python infra/helper.py build_image gitpython
 python infra/helper.py build_fuzzers --sanitizer $SANITIZER gitpython
 ```
 
+> [!TIP]
+> The `build_fuzzers` command above accepts a local file path pointing to your gitpython repository clone as the last
+> argument.
+> This makes it easy to build fuzz targets you are developing locally in this repository without changing anything in
+> the OSS-Fuzz repo!
+> For example, if you have cloned this repository (or a fork of it) into: `~/code/GitPython`
+> Then running this command would build new or modified fuzz targets using the `~/code/GitPython/fuzzing/fuzz-targets`
+> directory:
+> ```shell
+> python infra/helper.py build_fuzzers --sanitizer $SANITIZER gitpython ~/code/GitPython
+> ```
+
+
 Verify the build of your fuzzers with the optional `check_build` command:
 
 ```shell
diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index fdab7a1e0..aff1c4347 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -13,7 +13,7 @@ find "$SEED_DATA_DIR" \( -name '*_seed_corpus.zip' -o -name '*.options' -o -name
   -exec cp {} "$OUT" \;
 
 # Build fuzzers in $OUT.
-find "$SRC" -name 'fuzz_*.py' -print0 | while IFS= read -r -d $'\0' fuzz_harness; do
+find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d $'\0' fuzz_harness; do
   compile_python_fuzzer "$fuzz_harness"
 
   common_base_dictionary_filename="$SEED_DATA_DIR/__base.dict"

From 5e56e96821878bd2808c640b8b39f84738ed8cf8 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 11 Apr 2024 23:37:54 -0400
Subject: [PATCH 136/332] Clarify documentation

- Fix typos in the documentation on dictionaries
- Link to the fuzzing directory in the main README where it is
  referenced.
---
 README.md         | 2 +-
 fuzzing/README.md | 6 +++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 39f5496dc..b4cdc0a97 100644
--- a/README.md
+++ b/README.md
@@ -241,7 +241,7 @@ Please have a look at the [contributions file][contributing].
 [3-Clause BSD License](https://opensource.org/license/bsd-3-clause/), also known as the New BSD License. See the [LICENSE file][license].
 
 > [!NOTE]
-> There are two special case files located in the `fuzzzing/` directory that are licensed differently:
+> There are two special case files located in the [`fuzzzing/` directory](./fuzzing) that are licensed differently:
 >
 > `fuzz_config.py` and `fuzz_tree.py` were migrated here from the OSS-Fuzz project repository where they were initially
 > created and retain the original licence and copyright notice (Apache License, Version 2.0 and Copyright 2023 Google
diff --git a/fuzzing/README.md b/fuzzing/README.md
index acaf17d06..c57e31d86 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -80,8 +80,8 @@ Contains Python files for each fuzz test.
 ### Dictionaries (`dictionaries/`)
 
 Provides hints to the fuzzing engine about inputs that might trigger unique code paths. Each fuzz target may have a
-corresponding `.dict` file. For details on how these are used, refer
-to the [LibFuzzer documentation on the subject](https://llvm.org/docs/LibFuzzer.html#dictionaries).
+corresponding `.dict` file. For information about dictionary syntax, refer to
+the [LibFuzzer documentation on the subject](https://llvm.org/docs/LibFuzzer.html#dictionaries).
 
 **Things to Know**:
 
@@ -92,7 +92,7 @@ to the [LibFuzzer documentation on the subject](https://llvm.org/docs/LibFuzzer.
   existing file here.
 - Development or updates to dictionaries should reflect the varied formats and edge cases relevant to the
   functionalities under test.
-- Example dictionaries (some of which are used to build the default dictionaries mentioned above) are can be found here:
+- Example dictionaries (some of which are used to build the default dictionaries mentioned above) can be found here:
   - [AFL++ dictionary repository](https://github.com/AFLplusplus/AFLplusplus/tree/stable/dictionaries#readme)
   - [Google/fuzzing dictionary repository](https://github.com/google/fuzzing/tree/master/dictionaries)
 

From 2041ba9972e7720f05bf570e2304fc0a5a2463d7 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Sun, 14 Apr 2024 22:59:01 -0400
Subject: [PATCH 137/332] Use gitpython-developers org ownd repository for seed
 corpra

This repo was created after discussion in PR #1901.
---
 fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index 43c21a8da..881161fae 100644
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -36,9 +36,9 @@ download_and_concatenate_common_dictionaries() {
 
 fetch_seed_corpra() {
   # Seed corpus zip files are hosted in a separate repository to avoid additional bloat in this repo.
-  git clone --depth 1 https://github.com/DaveLak/oss-fuzz-inputs.git oss-fuzz-inputs &&
-    rsync -avc oss-fuzz-inputs/gitpython/corpra/ "$SEED_DATA_DIR/" &&
-    rm -rf oss-fuzz-inputs; # Clean up the cloned repo to keep the Docker image as slim as possible.
+  git clone --depth 1 https://github.com/gitpython-developers/qa-assets.git qa-assets &&
+    rsync -avc qa-assets/gitpython/corpra/ "$SEED_DATA_DIR/" &&
+    rm -rf qa-assets; # Clean up the cloned repo to keep the Docker image as slim as possible.
 }
 
 ########################

From 945a767ccd13c84946b2a49fbde4227fdfc84a26 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 02:06:50 -0400
Subject: [PATCH 138/332] Updates to comply with the terms of the Apache
 License

Addresses feedback and encorperates suggestions from PR #1901 to ensure
that the Apache License requirements are met for the two files that they
apply to, and the documentation pertaining to licensing of the files in
this repository is clear and concise.

The contects of LICENSE-APACHE were coppied from the LICENSE file of the
OSS-Fuzz repository that the two fuzz harnesses came from as of commit:
https://github.com/google/oss-fuzz/blob/c2c0632831767ff05c568e7b552cef2801d739ff/LICENSE
---
 README.md                           |  13 +-
 fuzzing/LICENSE-APACHE              | 201 ++++++++++++++++++++++++++++
 fuzzing/README.md                   |  12 ++
 fuzzing/fuzz-targets/fuzz_config.py |   6 +
 fuzzing/fuzz-targets/fuzz_tree.py   |   6 +
 5 files changed, 227 insertions(+), 11 deletions(-)
 create mode 100644 fuzzing/LICENSE-APACHE

diff --git a/README.md b/README.md
index b4cdc0a97..987e40e6c 100644
--- a/README.md
+++ b/README.md
@@ -240,17 +240,8 @@ Please have a look at the [contributions file][contributing].
 
 [3-Clause BSD License](https://opensource.org/license/bsd-3-clause/), also known as the New BSD License. See the [LICENSE file][license].
 
-> [!NOTE]
-> There are two special case files located in the [`fuzzzing/` directory](./fuzzing) that are licensed differently:
->
-> `fuzz_config.py` and `fuzz_tree.py` were migrated here from the OSS-Fuzz project repository where they were initially
-> created and retain the original licence and copyright notice (Apache License, Version 2.0 and Copyright 2023 Google
-> LLC respectively.)
->
-> - **These files do not impact the licence under which GitPython releases or source code are distributed.**
-> - The files located in the `fuzzzing/` directory are part of the project test suite and neither packaged nor distributed as
-    part of any release.
-
+Two files exclusively used for fuzz testing are subject to [a separate license, detailed here](./fuzzing/README.md#license).
+These files are not included in the wheel or sdist packages published by the maintainers of GitPython.
 
 [contributing]: https://github.com/gitpython-developers/GitPython/blob/main/CONTRIBUTING.md
 [license]: https://github.com/gitpython-developers/GitPython/blob/main/LICENSE
diff --git a/fuzzing/LICENSE-APACHE b/fuzzing/LICENSE-APACHE
new file mode 100644
index 000000000..8dada3eda
--- /dev/null
+++ b/fuzzing/LICENSE-APACHE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright {yyyy} {name of copyright owner}
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/fuzzing/README.md b/fuzzing/README.md
index c57e31d86..09d6fc003 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -193,6 +193,18 @@ python infra/helper.py run_fuzzer gitpython $FUZZ_TARGET -- -max_total_time=60 -
 For detailed instructions on advanced features like reproducing OSS-Fuzz issues or using the Fuzz Introspector, refer
 to [the official OSS-Fuzz documentation][oss-fuzz-docs].
 
+## LICENSE
+
+All files located within the `fuzzing/` directory are subject to [the same license](../LICENSE)
+as [the other files in this repository](../README.md#license) with two exceptions:
+
+Two files located in this directory, [`fuzz_config.py`](./fuzz-targets/fuzz_config.py)
+and [`fuzz_tree.py`](./fuzz-targets/fuzz_tree.py), have been migrated here from the OSS-Fuzz project repository where
+they were originally created. As such, these two files retain their original license and copyright notice (Apache
+License, Version 2.0 and Copyright 2023 Google LLC respectively.) Each file includes a notice in their respective header
+comments stating that they have been modified. [LICENSE-APACHE](./LICENSE-APACHE) contains the original license used by
+the OSS-Fuzz project repository at the time they were migrated.
+
 [oss-fuzz-repo]: https://github.com/google/oss-fuzz
 
 [oss-fuzz-docs]: https://google.github.io/oss-fuzz
diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py
index 1403c96e4..fc2f0960a 100644
--- a/fuzzing/fuzz-targets/fuzz_config.py
+++ b/fuzzing/fuzz-targets/fuzz_config.py
@@ -12,6 +12,12 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+#
+###############################################################################
+# Note: This file has been modified by contributors to GitPython.
+# The original state of this file may be referenced here:
+# https://github.com/google/oss-fuzz/commit/f26f254558fc48f3c9bc130b10507386b94522da
+###############################################################################
 import atheris
 import sys
 import io
diff --git a/fuzzing/fuzz-targets/fuzz_tree.py b/fuzzing/fuzz-targets/fuzz_tree.py
index 53258fb1e..b4e0e6b55 100644
--- a/fuzzing/fuzz-targets/fuzz_tree.py
+++ b/fuzzing/fuzz-targets/fuzz_tree.py
@@ -12,6 +12,12 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+#
+###############################################################################
+# Note: This file has been modified by contributors to GitPython.
+# The original state of this file may be referenced here:
+# https://github.com/google/oss-fuzz/commit/f26f254558fc48f3c9bc130b10507386b94522da
+###############################################################################
 import atheris
 import io
 import sys

From 68194a913fa0d9f601a55fcd08ff13b7ac35be75 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 14:41:36 -0400
Subject: [PATCH 139/332] Remove shebangs from fuzz harnesses

Prefer executing these files using the OSS-Fuzz or `python` command
methods outlined in the `fuzzing/README`.

Based on feedback and discussion on:
https://github.com/gitpython-developers/GitPython/pull/1901
---
 fuzzing/fuzz-targets/fuzz_config.py | 1 -
 fuzzing/fuzz-targets/fuzz_tree.py   | 1 -
 2 files changed, 2 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py
index fc2f0960a..0a06956c8 100644
--- a/fuzzing/fuzz-targets/fuzz_config.py
+++ b/fuzzing/fuzz-targets/fuzz_config.py
@@ -1,4 +1,3 @@
-#!/usr/bin/python3
 # Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/fuzzing/fuzz-targets/fuzz_tree.py b/fuzzing/fuzz-targets/fuzz_tree.py
index b4e0e6b55..464235098 100644
--- a/fuzzing/fuzz-targets/fuzz_tree.py
+++ b/fuzzing/fuzz-targets/fuzz_tree.py
@@ -1,4 +1,3 @@
-#!/usr/bin/python3
 # Copyright 2023 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");

From 8954c7151e098a0b12d4d2dec277fe6c63980579 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 14:45:49 -0400
Subject: [PATCH 140/332] Replace shebang in `build.sh` with ShellCheck
 directive

This script is meant to be sourced by the OSS-Fuzz file of the same
name, rather than executed directly. The shebang may lead to the
incorrect assumption that the script is meant for direct execution.
Replacing it with this directive instructs ShellCheck to treat
the script as a Bash script, regardless of how it is executed.

Based @EliahKagan's suggestion and feedback on:
https://github.com/gitpython-developers/GitPython/pull/1901
---
 fuzzing/oss-fuzz-scripts/build.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index aff1c4347..4c7de8799 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -1,4 +1,4 @@
-#!/usr/bin/env bash
+# shellcheck shell=bash
 
 set -euo pipefail
 

From b0a5b8e66c4da3d603d8e27a71c70aaad53542b8 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 14:59:05 -0400
Subject: [PATCH 141/332] Set executable bit on
 `container-environment-bootstrap.sh`

This script is executed directly, not sourced as is the case with
`build.sh`, so it should have an executable bit set to avoid ambiguity.

Based @EliahKagan's suggestion and feedback on:
https://github.com/gitpython-developers/GitPython/pull/1901
---
 fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 mode change 100644 => 100755 fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh

diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
old mode 100644
new mode 100755

From 25f360090cb6a7fd0f01bc127f2a2280659757a2 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 15:31:05 -0400
Subject: [PATCH 142/332] Minor clarity improvements in `fuzzing/README.md`

- Make the link text for the OSS-Fuzz test status URL more descriptive
- Fix capitalization of GitPython repository name

Based @EliahKagan's suggestion and feedback on:
https://github.com/gitpython-developers/GitPython/pull/1901
---
 fuzzing/README.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/fuzzing/README.md b/fuzzing/README.md
index 09d6fc003..65e311d4a 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -6,8 +6,8 @@ This directory contains files related to GitPython's suite of fuzz tests that ar
 infrastructure provided by [OSS-Fuzz][oss-fuzz-repo]. This document aims to provide necessary information for working
 with fuzzing in GitPython.
 
-The latest details regarding OSS-Fuzz test status, including build logs and coverage reports, is made available
-at [this link](https://introspector.oss-fuzz.com/project-profile?project=gitpython).
+The latest details regarding OSS-Fuzz test status, including build logs and coverage reports, is available
+on [the Open Source Fuzzing Introspection website](https://introspector.oss-fuzz.com/project-profile?project=gitpython).
 
 ## How to Contribute
 
@@ -152,7 +152,7 @@ python infra/helper.py build_fuzzers --sanitizer $SANITIZER gitpython
 ```
 
 > [!TIP]
-> The `build_fuzzers` command above accepts a local file path pointing to your gitpython repository clone as the last
+> The `build_fuzzers` command above accepts a local file path pointing to your GitPython repository clone as the last
 > argument.
 > This makes it easy to build fuzz targets you are developing locally in this repository without changing anything in
 > the OSS-Fuzz repo!

From d79c176384f1a5b6cb615f500037dbcecd9ee7d9 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 16:10:08 -0400
Subject: [PATCH 143/332] Simplify read delimiter to use empty string in fuzz
 harness loop

Replaces the null character delimiter `-d $'\0'` with the simpler
empty string `-d ''` in the fuzzing harness build loop.

This changes leverages the Bash `read` builtin behavior to avoid
unnecessary complexity and improving script readability.

Based @EliahKagan's suggestion and feedback on:
https://github.com/gitpython-developers/GitPython/pull/1901
---
 fuzzing/oss-fuzz-scripts/build.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index 4c7de8799..a412a1d15 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -13,7 +13,7 @@ find "$SEED_DATA_DIR" \( -name '*_seed_corpus.zip' -o -name '*.options' -o -name
   -exec cp {} "$OUT" \;
 
 # Build fuzzers in $OUT.
-find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d $'\0' fuzz_harness; do
+find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d '' fuzz_harness; do
   compile_python_fuzzer "$fuzz_harness"
 
   common_base_dictionary_filename="$SEED_DATA_DIR/__base.dict"

From e038526b846f4bc5e75a91c736f3384616800aa1 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 16:27:43 -0400
Subject: [PATCH 144/332] Remove unnecessary semicolon for consistent script
 formatting

Based @EliahKagan's suggestion and feedback on:
https://github.com/gitpython-developers/GitPython/pull/1901
---
 .../container-environment-bootstrap.sh                 | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index 881161fae..87f817993 100755
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -38,19 +38,19 @@ fetch_seed_corpra() {
   # Seed corpus zip files are hosted in a separate repository to avoid additional bloat in this repo.
   git clone --depth 1 https://github.com/gitpython-developers/qa-assets.git qa-assets &&
     rsync -avc qa-assets/gitpython/corpra/ "$SEED_DATA_DIR/" &&
-    rm -rf qa-assets; # Clean up the cloned repo to keep the Docker image as slim as possible.
+    rm -rf qa-assets # Clean up the cloned repo to keep the Docker image as slim as possible.
 }
 
 ########################
 # Main execution logic #
 ########################
 
-fetch_seed_corpra;
+fetch_seed_corpra
 
 download_and_concatenate_common_dictionaries "$SEED_DATA_DIR/__base.dict" \
   "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/utf8.dict" \
-  "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/url.dict";
+  "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/url.dict"
 
 # The OSS-Fuzz base image has outdated dependencies by default so we upgrade them below.
-python3 -m pip install --upgrade pip;
-python3 -m pip install 'setuptools~=69.0' 'pyinstaller~=6.0'; # Uses the latest versions know to work at the time of this commit.
+python3 -m pip install --upgrade pip
+python3 -m pip install 'setuptools~=69.0' 'pyinstaller~=6.0' # Uses the latest versions know to work at the time of this commit.

From d25ae2def1f995afcb7fad69250b12f5bf07b3bb Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 16 Apr 2024 16:38:21 -0400
Subject: [PATCH 145/332] Fix various misspellings of "corpora" & improve
 script comments

A misspelling in the https://github.com/gitpython-developers/qa-assets
repository is still present here. It will need to be fixed in that
repository first.

"corpora" is a difficult word to spell consistently I guess. This made
for a good opportunity to improve the phrasing of two other comments at
at least.

Based @EliahKagan's suggestion and feedback on:
https://github.com/gitpython-developers/GitPython/pull/1901
---
 fuzzing/oss-fuzz-scripts/build.sh                          | 4 ++--
 .../oss-fuzz-scripts/container-environment-bootstrap.sh    | 7 ++++---
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index a412a1d15..ab46ec7a2 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -4,7 +4,7 @@ set -euo pipefail
 
 python3 -m pip install .
 
-# Directory to look in for dictionaries, options files, and seed corpa:
+# Directory to look in for dictionaries, options files, and seed corpora:
 SEED_DATA_DIR="$SRC/seed_data"
 
 find "$SEED_DATA_DIR" \( -name '*_seed_corpus.zip' -o -name '*.options' -o -name '*.dict' \) \
@@ -27,7 +27,7 @@ find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d
       # If a dictionary file for this fuzzer already exists and is not empty,
       # we append a new line to the end of it before appending any new entries.
       #
-      # libfuzzer will happily ignore multiple empty lines in a dictionary but crash
+      # LibFuzzer will happily ignore multiple empty lines in a dictionary but fail with an error
       # if any single line has incorrect syntax (e.g., if we accidentally add two entries to the same line.)
       # See docs for valid syntax: https://llvm.org/docs/LibFuzzer.html#id32
       echo >>"$output_file"
diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index 87f817993..0be012ccd 100755
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -34,7 +34,7 @@ download_and_concatenate_common_dictionaries() {
   done
 }
 
-fetch_seed_corpra() {
+fetch_seed_corpora() {
   # Seed corpus zip files are hosted in a separate repository to avoid additional bloat in this repo.
   git clone --depth 1 https://github.com/gitpython-developers/qa-assets.git qa-assets &&
     rsync -avc qa-assets/gitpython/corpra/ "$SEED_DATA_DIR/" &&
@@ -45,7 +45,7 @@ fetch_seed_corpra() {
 # Main execution logic #
 ########################
 
-fetch_seed_corpra
+fetch_seed_corpora
 
 download_and_concatenate_common_dictionaries "$SEED_DATA_DIR/__base.dict" \
   "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/utf8.dict" \
@@ -53,4 +53,5 @@ download_and_concatenate_common_dictionaries "$SEED_DATA_DIR/__base.dict" \
 
 # The OSS-Fuzz base image has outdated dependencies by default so we upgrade them below.
 python3 -m pip install --upgrade pip
-python3 -m pip install 'setuptools~=69.0' 'pyinstaller~=6.0' # Uses the latest versions know to work at the time of this commit.
+ # Upgrade to the latest versions known to work at the time the below changes were introduced:
+python3 -m pip install 'setuptools~=69.0' 'pyinstaller~=6.0'

From 23a505f3ef51c4c26998fed924f4edad2438c757 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Wed, 17 Apr 2024 19:40:44 -0400
Subject: [PATCH 146/332] Remove comment suggesting the `undefined` sanitizer
 is a valid option

Also makes come structural improvements to how the local instructions
for running OSS-Fuzz are presented now that only the single `address`
sanitizer is a valid option.

The `undefined` sanitizer was removed from GitPython's `project.yaml`
OSS-Fuzz configuration file at the request of OSS-Fuzz project reviewers
in https://github.com/google/oss-fuzz/pull/11803.

The `undefined` sanitizer is only useful in Python projects that use
native exstensions (such as C, C++, Rust, ect.), which GitPython does
not currently do. This commit updates the `fuzzing/README` reference to
that sanitizer accoirdingly.
See:
- https://github.com/google/oss-fuzz/pull/11803/commits/b210fb21427f1f994c91f07e95ca0cc977f61f66
- https://github.com/google/oss-fuzz/pull/11803#discussion_r1569160945
---
 fuzzing/README.md | 28 +++++++++++++---------------
 1 file changed, 13 insertions(+), 15 deletions(-)

diff --git a/fuzzing/README.md b/fuzzing/README.md
index 65e311d4a..ab9f6a63f 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -129,18 +129,7 @@ This approach uses Docker images provided by OSS-Fuzz for building and running f
 comprehensive features but requires a local clone of the OSS-Fuzz repository and sufficient disk space for Docker
 containers.
 
-#### Preparation
-
-Set environment variables to simplify command usage:
-
-```shell
-# $SANITIZER can be either 'address' or 'undefined':
-export SANITIZER=address
-# specify the fuzz target without the .py extension:
-export FUZZ_TARGET=fuzz_config
-```
-
-#### Build and Run
+#### Build the Execution Environment
 
 Clone the OSS-Fuzz repository and prepare the Docker environment:
 
@@ -148,7 +137,7 @@ Clone the OSS-Fuzz repository and prepare the Docker environment:
 git clone --depth 1 https://github.com/google/oss-fuzz.git oss-fuzz
 cd oss-fuzz
 python infra/helper.py build_image gitpython
-python infra/helper.py build_fuzzers --sanitizer $SANITIZER gitpython
+python infra/helper.py build_fuzzers --sanitizer address gitpython
 ```
 
 > [!TIP]
@@ -160,16 +149,25 @@ python infra/helper.py build_fuzzers --sanitizer $SANITIZER gitpython
 > Then running this command would build new or modified fuzz targets using the `~/code/GitPython/fuzzing/fuzz-targets`
 > directory:
 > ```shell
-> python infra/helper.py build_fuzzers --sanitizer $SANITIZER gitpython ~/code/GitPython
+> python infra/helper.py build_fuzzers --sanitizer address gitpython ~/code/GitPython
 > ```
 
-
 Verify the build of your fuzzers with the optional `check_build` command:
 
 ```shell
 python infra/helper.py check_build gitpython
 ```
 
+#### Run a Fuzz Target
+
+Setting an environment variable for the fuzz target argument of the execution command makes it easier to quickly select
+a different target between runs:
+
+```shell
+# specify the fuzz target without the .py extension:
+export FUZZ_TARGET=fuzz_config
+```
+
 Execute the desired fuzz target:
 
 ```shell

From 1d54d4b0b2bdba60cc742f73a4b8d5a88cce8f64 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Wed, 17 Apr 2024 22:11:00 -0400
Subject: [PATCH 147/332] Remove unintentional leading space from comment

---
 fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index 0be012ccd..662808e27 100755
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -53,5 +53,5 @@ download_and_concatenate_common_dictionaries "$SEED_DATA_DIR/__base.dict" \
 
 # The OSS-Fuzz base image has outdated dependencies by default so we upgrade them below.
 python3 -m pip install --upgrade pip
- # Upgrade to the latest versions known to work at the time the below changes were introduced:
+# Upgrade to the latest versions known to work at the time the below changes were introduced:
 python3 -m pip install 'setuptools~=69.0' 'pyinstaller~=6.0'

From fdce8375c80abd02b1dc08bd218f09849fea8233 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Sat, 20 Apr 2024 16:48:00 -0400
Subject: [PATCH 148/332] Dockerized "Direct Execution of Fuzz Targets"

Adds a Dockerfile to enable easily executing the fuzz targets directly
inside a container environment instead of directly on a host machine.
This addresses concerns raised in PR #1901 related to how `fuzz_tree.py`
writes to the real `/tmp` directory of the file system it is executed on
as part of setting up its own test fixtures, but also makes for an
easier to use development workflow.

See this related comment on PR #1901 for additional context:
https://github.com/gitpython-developers/GitPython/pull/1901#issuecomment-2063818998
---
 fuzzing/README.md                    | 43 ++++++++++++++++++++++++----
 fuzzing/local-dev-helpers/Dockerfile | 22 ++++++++++++++
 2 files changed, 59 insertions(+), 6 deletions(-)
 create mode 100644 fuzzing/local-dev-helpers/Dockerfile

diff --git a/fuzzing/README.md b/fuzzing/README.md
index ab9f6a63f..0a62b4c85 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -44,8 +44,7 @@ capabilities, jump into the "Getting Started" section below.
 ### Setting Up Your Local Environment
 
 Before contributing to fuzzing efforts, ensure Python and Docker are installed on your machine. Docker is required for
-running fuzzers in containers provided by OSS-Fuzz. [Install Docker](https://docs.docker.com/get-docker/) following the
-official guide if you do not already have it.
+running fuzzers in containers provided by OSS-Fuzz and for safely executing test files directly. [Install Docker](https://docs.docker.com/get-docker/) following the official guide if you do not already have it.
 
 ### Understanding Existing Fuzz Targets
 
@@ -110,19 +109,51 @@ Includes scripts for building and integrating fuzz targets with OSS-Fuzz:
 - [OSS-Fuzz documentation on the build.sh](https://google.github.io/oss-fuzz/getting-started/new-project-guide/#buildsh)
 - [See GitPython's build.sh and Dockerfile in the OSS-Fuzz repository](https://github.com/google/oss-fuzz/tree/master/projects/gitpython)
 
+### Local Development Helpers (`local-dev-helpers/`)
+
+Contains tools to make local development tasks easier.
+See [the "Running Fuzzers Locally" section below](#running-fuzzers-locally) for further documentation and use cases related to files found here.
+
 ## Running Fuzzers Locally
 
+> [!WARNING]
+> **Some fuzz targets in this repository write to the filesystem** during execution.
+> For that reason, it is strongly recommended to **always use Docker when executing fuzz targets**, even when it may be
+> possible to do so without it.
+>
+> Although [I/O operations such as writing to disk are not considered best practice](https://github.com/google/fuzzing/blob/master/docs/good-fuzz-target.md#io), the current implementation of at least one test requires it. 
+> See [the "Setting Up Your Local Environment" section above](#setting-up-your-local-environment) if you do not already have Docker installed on your machine.
+>
+> PRs that replace disk I/O with in-memory alternatives are very much welcomed!
+
 ### Direct Execution of Fuzz Targets
 
-For quick testing of changes, [Atheris][atheris-repo] makes it possible to execute a fuzz target directly:
+Directly executing fuzz targets allows for quick iteration and testing of changes which can be helpful during early
+development of new fuzz targets or for validating changes made to an existing test.
+The [Dockerfile](./local-dev-helpers/Dockerfile) located in the `local-dev-helpers/` subdirectory provides a lightweight
+container environment preconfigured with [Atheris][atheris-repo] that makes it easy to execute a fuzz target directly.
+
+**From the root directory of your GitPython repository clone**:
 
-1. Install Atheris following the [installation guide][atheris-repo] for your operating system.
-2. Execute a fuzz target, for example:
+1. Build the local development helper image:
 
 ```shell
-python fuzzing/fuzz-targets/fuzz_config.py
+docker build -f fuzzing/local-dev-helpers/Dockerfile -t gitpython-fuzzdev .
 ```
 
+2. Then execute a fuzz target inside the image, for example:
+
+```shell
+ docker run -it -v "$PWD":/src gitpython-fuzzdev python fuzzing/fuzz-targets/fuzz_config.py -atheris_runs=10000
+```
+
+The above command executes [`fuzz_config.py`](./fuzz-targets/fuzz_config.py) and exits after `10000` runs, or earlier if
+the fuzzer finds an error.
+
+Docker CLI's `-v` flag specifies a volume mount in Docker that maps the directory in which the command is run (which
+should be the root directory of your local GitPython clone) to a directory inside the container, so any modifications
+made between invocations will be reflected immediately without the need to rebuild the image each time.
+
 ### Running OSS-Fuzz Locally
 
 This approach uses Docker images provided by OSS-Fuzz for building and running fuzz tests locally. It offers
diff --git a/fuzzing/local-dev-helpers/Dockerfile b/fuzzing/local-dev-helpers/Dockerfile
new file mode 100644
index 000000000..77808ed1d
--- /dev/null
+++ b/fuzzing/local-dev-helpers/Dockerfile
@@ -0,0 +1,22 @@
+# syntax=docker/dockerfile:1
+
+# Use the same Python version as OSS-Fuzz to accidental incompatibilities in test code
+FROM python:3.8-slim
+
+LABEL project="GitPython Fuzzing Local Dev Helper"
+
+WORKDIR /src
+
+COPY . .
+
+# Update package managers, install necessary packages, and cleanup unnecessary files in a single RUN to keep the image smaller.
+RUN apt-get update && \
+    apt-get install --no-install-recommends -y git && \
+    python -m pip install --upgrade pip && \
+    python -m pip install atheris && \
+    python -m pip install -e . && \
+    apt-get clean && \
+    apt-get autoremove -y && \
+    rm -rf /var/lib/apt/lists/* /root/.cache
+
+CMD ["bash"]

From f1451219c5a3a221615d3d38ac251bf5bbe46119 Mon Sep 17 00:00:00 2001
From: DaveLak <github@themoderndev.com>
Date: Sun, 21 Apr 2024 12:19:57 -0400
Subject: [PATCH 149/332] Fix Atheris install in local dev helper Docker image

The Atheris package bundles a binary that supplies libFuzzer on some
host machines, but in some cases (such as ARM based mac hosts) Atheris
seems to require building libFuzzer at install time while pip builds the
wheel. In the latter case, clang and related dependencies must be
present and available for the build, which itself requires using a non
"slim" version of the Python base image and not passing the
`--no-install-recommends` flag to `apt-get install` as both prevent the
required related libraries from being automatically installed.

It is also worth noting that at the time of this commit, the default
version of LLVM & Clang installed when `clang` is installed from `apt`
is version 14, while the latest stable version is 17 and OSS-Fuzz uses
15. The decision to install the default version (14) available via the
debian repos was intentional because a) it appears to work fine for our
needs and Atheris version b) specifying a different version requires
more complexity depending on install method, but the goal of this
Dockerfile is simplicity and low maintenance.

If it becomes neccissary to upgrade Clang/LLVM in the future, one option
to consider besides installing from source is the apt repository
maintained by the LLVM project: https://apt.llvm.org/

See the discussion in this issue for additional context to this change:
https://github.com/gitpython-developers/GitPython/pull/1904
---
 fuzzing/local-dev-helpers/Dockerfile | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/fuzzing/local-dev-helpers/Dockerfile b/fuzzing/local-dev-helpers/Dockerfile
index 77808ed1d..426de05dd 100644
--- a/fuzzing/local-dev-helpers/Dockerfile
+++ b/fuzzing/local-dev-helpers/Dockerfile
@@ -1,7 +1,7 @@
 # syntax=docker/dockerfile:1
 
 # Use the same Python version as OSS-Fuzz to accidental incompatibilities in test code
-FROM python:3.8-slim
+FROM python:3.8-bookworm
 
 LABEL project="GitPython Fuzzing Local Dev Helper"
 
@@ -11,12 +11,12 @@ COPY . .
 
 # Update package managers, install necessary packages, and cleanup unnecessary files in a single RUN to keep the image smaller.
 RUN apt-get update && \
-    apt-get install --no-install-recommends -y git && \
+    apt-get install -y git clang && \
     python -m pip install --upgrade pip && \
     python -m pip install atheris && \
     python -m pip install -e . && \
     apt-get clean && \
     apt-get autoremove -y && \
-    rm -rf /var/lib/apt/lists/* /root/.cache
+    rm -rf /var/lib/apt/lists/*
 
 CMD ["bash"]

From f4b95cf089706a29396b744b53a4ecdcc924d31c Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Mon, 22 Apr 2024 16:07:54 -0400
Subject: [PATCH 150/332] Fix Fuzzer Crash in ClusterFuzz Due to Missing Git
 Executable

A Git executable is not globally available in the ClusterFuzz container
environment where OSS-Fuzz executes fuzz tests, causing an error in the fuzz
harnesses when GitPython attempts to initialize, crashing the tests before they
can run.

To avoid this issue, we bundle the `git` binary that is available in the OSS-Fuzz
build container with the fuzz harness via Pyinstaller's `--add-binary` flag in
`build.sh` and use GitPython's `git.refresh(<full-path-to-git-executable>)`
method inside a Pyinstaller runtime check to initialize GitPython with the
bundled Git executable when running from the bundled application.

In all other execution environments, we assume a `git` executable is available
globally.

Fixes:
- https://github.com/gitpython-developers/GitPython/issues/1905
- https://github.com/google/oss-fuzz/issues/10600
---
 fuzzing/fuzz-targets/fuzz_config.py |  9 +++++++--
 fuzzing/fuzz-targets/fuzz_tree.py   | 11 +++++++----
 fuzzing/oss-fuzz-scripts/build.sh   |  2 +-
 3 files changed, 15 insertions(+), 7 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py
index 0a06956c8..7623ab98f 100644
--- a/fuzzing/fuzz-targets/fuzz_config.py
+++ b/fuzzing/fuzz-targets/fuzz_config.py
@@ -20,16 +20,21 @@
 import atheris
 import sys
 import io
+import os
 from configparser import MissingSectionHeaderError, ParsingError
 
 with atheris.instrument_imports():
-    from git import GitConfigParser
+    import git
 
 
 def TestOneInput(data):
+    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+        path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+        git.refresh(path_to_bundled_git_binary)
+
     sio = io.BytesIO(data)
     sio.name = "/tmp/fuzzconfig.config"
-    git_config = GitConfigParser(sio)
+    git_config = git.GitConfigParser(sio)
     try:
         git_config.read()
     except (MissingSectionHeaderError, ParsingError, UnicodeDecodeError):
diff --git a/fuzzing/fuzz-targets/fuzz_tree.py b/fuzzing/fuzz-targets/fuzz_tree.py
index 464235098..7187c4a6f 100644
--- a/fuzzing/fuzz-targets/fuzz_tree.py
+++ b/fuzzing/fuzz-targets/fuzz_tree.py
@@ -24,11 +24,14 @@
 import shutil
 
 with atheris.instrument_imports():
-    from git.objects import Tree
-    from git.repo import Repo
+    import git
 
 
 def TestOneInput(data):
+    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+        path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+        git.refresh(path_to_bundled_git_binary)
+
     fdp = atheris.FuzzedDataProvider(data)
     git_dir = "/tmp/.git"
     head_file = os.path.join(git_dir, "HEAD")
@@ -46,9 +49,9 @@ def TestOneInput(data):
     os.mkdir(common_dir)
     os.mkdir(objects_dir)
 
-    _repo = Repo("/tmp/")
+    _repo = git.Repo("/tmp/")
 
-    fuzz_tree = Tree(_repo, Tree.NULL_BIN_SHA, 0, "")
+    fuzz_tree = git.Tree(_repo, git.Tree.NULL_BIN_SHA, 0, "")
     try:
         fuzz_tree._deserialize(io.BytesIO(data))
     except IndexError:
diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index ab46ec7a2..be31ac32a 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -14,7 +14,7 @@ find "$SEED_DATA_DIR" \( -name '*_seed_corpus.zip' -o -name '*.options' -o -name
 
 # Build fuzzers in $OUT.
 find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d '' fuzz_harness; do
-  compile_python_fuzzer "$fuzz_harness"
+  compile_python_fuzzer "$fuzz_harness" --add-binary="$(command -v git):."
 
   common_base_dictionary_filename="$SEED_DATA_DIR/__base.dict"
   if [[ -r "$common_base_dictionary_filename" ]]; then

From 2b0a9693ea98ab7ff025a8ab1235b6f8ea0da676 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Mon, 22 Apr 2024 16:36:17 -0400
Subject: [PATCH 151/332] Add GitPython's standard license header comments to
 oss-fuzz scripts

These files are already BSD-3-Clause even without the headers, but
adding these comments and the `LICENSE-BSD` symlink to the root level
`LICENSE` file are helpful to reinforce that there are only two
particular files in the `fuzzing/` that are not under BSD-3-Clause.

See:
https://github.com/gitpython-developers/GitPython/pull/1901#discussion_r1567849271
---
 fuzzing/LICENSE-BSD                                         | 1 +
 fuzzing/oss-fuzz-scripts/build.sh                           | 3 +++
 fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh | 4 ++++
 3 files changed, 8 insertions(+)
 create mode 120000 fuzzing/LICENSE-BSD

diff --git a/fuzzing/LICENSE-BSD b/fuzzing/LICENSE-BSD
new file mode 120000
index 000000000..ea5b60640
--- /dev/null
+++ b/fuzzing/LICENSE-BSD
@@ -0,0 +1 @@
+../LICENSE
\ No newline at end of file
diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index ab46ec7a2..a79cbe895 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -1,4 +1,7 @@
 # shellcheck shell=bash
+#
+# This file is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
 set -euo pipefail
 
diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index 662808e27..76ec97c7f 100755
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+#
+# This file is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+
 set -euo pipefail
 
 #################

From b021a76354bb2779fc8f43c647a3a85b67f3d01a Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Mon, 22 Apr 2024 16:43:03 -0400
Subject: [PATCH 152/332] Add GitPython's standard license header comments to
 top level scripts

While discussing adding similar license comments to the shell scripts
introduced in PR #1901, it was noticed that the shell scripts in the
repository root directory did not have such comments and suggested that
we could add them when the scripts in the `fuzzing/` directory were
updated, so this commit does just that.

See:
https://github.com/gitpython-developers/GitPython/pull/1901#discussion_r1567849271
---
 build-release.sh          | 3 +++
 check-version.sh          | 3 +++
 init-tests-after-clone.sh | 3 +++
 3 files changed, 9 insertions(+)

diff --git a/build-release.sh b/build-release.sh
index 49c13b93a..1a8dce2c2 100755
--- a/build-release.sh
+++ b/build-release.sh
@@ -1,5 +1,8 @@
 #!/bin/bash
 #
+# This file is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+#
 # This script builds a release. If run in a venv, it auto-installs its tools.
 # You may want to run "make release" instead of running this script directly.
 
diff --git a/check-version.sh b/check-version.sh
index dac386e46..579cf789f 100755
--- a/check-version.sh
+++ b/check-version.sh
@@ -1,5 +1,8 @@
 #!/bin/bash
 #
+# This file is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
+#
 # This script checks if we are in a consistent state to build a new release.
 # See the release instructions in README.md for the steps to make this pass.
 # You may want to run "make release" instead of running this script directly.
diff --git a/init-tests-after-clone.sh b/init-tests-after-clone.sh
index 118e1de22..bfada01b0 100755
--- a/init-tests-after-clone.sh
+++ b/init-tests-after-clone.sh
@@ -1,4 +1,7 @@
 #!/bin/sh
+#
+# This file is part of GitPython and is released under the
+# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
 
 set -eu
 

From 47e5738074e7f9acfb64d164206770bbd41685a0 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Mon, 22 Apr 2024 18:48:26 -0400
Subject: [PATCH 153/332] Fix IndexError in GitConfigParser when config value
 ends in new line

Improve the guarding `if` check in `GitConfigParser`'s `string_decode`
function to safely handle empty strings and prevent `IndexError`s when
accessing string elements.

This resolves an IndexError in the `GitConfigParser`'s `.read()`
method when the config file contains a quoted value containing a
trailing new line.

Fixes:
https://github.com/gitpython-developers/GitPython/issues/1887
---
 fuzzing/fuzz-targets/fuzz_config.py | 8 ++------
 git/config.py                       | 2 +-
 test/test_config.py                 | 8 ++++++++
 3 files changed, 11 insertions(+), 7 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py
index 0a06956c8..81dcf9a88 100644
--- a/fuzzing/fuzz-targets/fuzz_config.py
+++ b/fuzzing/fuzz-targets/fuzz_config.py
@@ -34,12 +34,8 @@ def TestOneInput(data):
         git_config.read()
     except (MissingSectionHeaderError, ParsingError, UnicodeDecodeError):
         return -1  # Reject inputs raising expected exceptions
-    except (IndexError, ValueError) as e:
-        if isinstance(e, IndexError) and "string index out of range" in str(e):
-            # Known possibility that might be patched
-            # See: https://github.com/gitpython-developers/GitPython/issues/1887
-            pass
-        elif isinstance(e, ValueError) and "embedded null byte" in str(e):
+    except ValueError as e:
+        if isinstance(e, ValueError) and "embedded null byte" in str(e):
             # The `os.path.expanduser` function, which does not accept strings
             # containing null bytes might raise this.
             return -1
diff --git a/git/config.py b/git/config.py
index 3ce9b123f..c9b49684c 100644
--- a/git/config.py
+++ b/git/config.py
@@ -452,7 +452,7 @@ def _read(self, fp: Union[BufferedReader, IO[bytes]], fpname: str) -> None:
         e = None  # None, or an exception.
 
         def string_decode(v: str) -> str:
-            if v[-1] == "\\":
+            if v and v[-1] == "\\":
                 v = v[:-1]
             # END cut trailing escapes to prevent decode error
 
diff --git a/test/test_config.py b/test/test_config.py
index 0911d0262..92997422d 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -142,6 +142,14 @@ def test_multi_line_config(self):
             )
             self.assertEqual(len(config.sections()), 23)
 
+    def test_config_value_with_trailing_new_line(self):
+        config_content = b'[section-header]\nkey:"value\n"'
+        config_file = io.BytesIO(config_content)
+        config_file.name = "multiline_value.config"
+
+        git_config = GitConfigParser(config_file)
+        git_config.read()  # This should not throw an exception
+
     def test_base(self):
         path_repo = fixture_path("git_config")
         path_global = fixture_path("git_config_global")

From c2283f6e3566606300f64c44a12197f0b65f0d71 Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Tue, 23 Apr 2024 08:21:58 +0200
Subject: [PATCH 154/332] Avoid unnecessary isinstance check

---
 fuzzing/fuzz-targets/fuzz_config.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py
index 81dcf9a88..80ab7b08d 100644
--- a/fuzzing/fuzz-targets/fuzz_config.py
+++ b/fuzzing/fuzz-targets/fuzz_config.py
@@ -35,7 +35,7 @@ def TestOneInput(data):
     except (MissingSectionHeaderError, ParsingError, UnicodeDecodeError):
         return -1  # Reject inputs raising expected exceptions
     except ValueError as e:
-        if isinstance(e, ValueError) and "embedded null byte" in str(e):
+        if "embedded null byte" in str(e):
             # The `os.path.expanduser` function, which does not accept strings
             # containing null bytes might raise this.
             return -1

From 1a0ab5bbdaa9df5d04d1b6946af419492b650fce Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Fri, 26 Apr 2024 07:15:13 +0200
Subject: [PATCH 155/332] Use endswith() for more clarity

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

diff --git a/git/config.py b/git/config.py
index c9b49684c..de3508360 100644
--- a/git/config.py
+++ b/git/config.py
@@ -452,7 +452,7 @@ def _read(self, fp: Union[BufferedReader, IO[bytes]], fpname: str) -> None:
         e = None  # None, or an exception.
 
         def string_decode(v: str) -> str:
-            if v and v[-1] == "\\":
+            if v and v.endswith("\\"):
                 v = v[:-1]
             # END cut trailing escapes to prevent decode error
 

From dac3535d3dc4aaff9bd98a6ea70f46b132537694 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Fri, 26 Apr 2024 18:13:11 -0400
Subject: [PATCH 156/332] Attempt 2 - Fix Missing Git Executable Causing
 ClusterFuzz Crash

This is a second attempt at #1906 and should resolve:
- https://github.com/gitpython-developers/GitPython/issues/1905
- https://github.com/google/oss-fuzz/issues/10600

PR #1906 had the right idea but wrong implementation, and the differences between
the ClusterFuzz image that it was supposed to fix and the OSS-Fuzz image where
the fix was tested led to the issue not being fully resolved.

The root cause of the issue is the same: A Git executable is not globally
available in the ClusterFuzz container environment where OSS-Fuzz executes
fuzz tests.

 #1906 attempted to fix the issue by bundling the Git binary and using
GitPython's `git.refresh(<full-path-to-git-executable>)` method to set it
inside the `TestOneInput` function of the test harness.

However, GitPython attempts to set the binary at import time via its `__init__`
hook, and crashes the test if no executable is found during the import.

This issue is fixed here by setting the environment variable that GitPython
looks in before importing it, so it's available for the import. This was tested
by setting the `$PATH` to an empty string inside the test files, which
reproduced the crash, then adding the changes introduced here with `$PATH` still
empty, which avoided the crash indicating that the bundled Git executable is
working as expected.
---
 fuzzing/fuzz-targets/fuzz_config.py | 8 ++++----
 fuzzing/fuzz-targets/fuzz_tree.py   | 8 ++++----
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_config.py b/fuzzing/fuzz-targets/fuzz_config.py
index 6f2caad4b..4eddc32ff 100644
--- a/fuzzing/fuzz-targets/fuzz_config.py
+++ b/fuzzing/fuzz-targets/fuzz_config.py
@@ -23,15 +23,15 @@
 import os
 from configparser import MissingSectionHeaderError, ParsingError
 
+if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
+
 with atheris.instrument_imports():
     import git
 
 
 def TestOneInput(data):
-    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
-        path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
-        git.refresh(path_to_bundled_git_binary)
-
     sio = io.BytesIO(data)
     sio.name = "/tmp/fuzzconfig.config"
     git_config = git.GitConfigParser(sio)
diff --git a/fuzzing/fuzz-targets/fuzz_tree.py b/fuzzing/fuzz-targets/fuzz_tree.py
index 7187c4a6f..4e2038add 100644
--- a/fuzzing/fuzz-targets/fuzz_tree.py
+++ b/fuzzing/fuzz-targets/fuzz_tree.py
@@ -23,15 +23,15 @@
 import os
 import shutil
 
+if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
+
 with atheris.instrument_imports():
     import git
 
 
 def TestOneInput(data):
-    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
-        path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
-        git.refresh(path_to_bundled_git_binary)
-
     fdp = atheris.FuzzedDataProvider(data)
     git_dir = "/tmp/.git"
     head_file = os.path.join(git_dir, "HEAD")

From c84e643c6aa177f364ebe28e4c7bab1e37fb0242 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Sun, 28 Apr 2024 22:41:11 -0400
Subject: [PATCH 157/332] Replace the suboptimal fuzz_tree harness with a
 better alternative

As discussed in the initial fuzzing integration PR[^1], `fuzz_tree.py`'s
implementation was not ideal in terms of coverage and its reading/writing to
hard-coded paths inside `/tmp` was problematic as (among other concerns), it
causes intermittent crashes on ClusterFuzz[^2] when multiple workers execute
the test at the same time on the same machine.

The changes here replace `fuzz_tree.py` completely with a completely new
`fuzz_repo.py` fuzz target which:

- Uses `tempfile.TemporaryDirectory()` to safely manage tmpdir creation and
  tear down, including during multi-worker execution runs.
- Retains the same feature coverage as `fuzz_tree.py`, but it also adds
  considerably more from much smaller data inputs and with less memory consumed
  (and it doesn't even have a seed corpus or target specific dictionary yet.)
- Can likely be improved further in the future by exercising additional features
  of `Repo` to the harness.

Because `fuzz_tree.py` was removed and `fuzz_repo.py` was not derived from it,
the Apache License call outs in the docs were also updated as they only apply to
the singe `fuzz_config.py` file now.

[^1]: https://github.com/gitpython-developers/GitPython/pull/1901#discussion_r1565001609
[^2]: https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=68355
---
 README.md                           |  4 +-
 fuzzing/README.md                   | 16 +++----
 fuzzing/dictionaries/fuzz_tree.dict | 13 ------
 fuzzing/fuzz-targets/fuzz_repo.py   | 47 ++++++++++++++++++++
 fuzzing/fuzz-targets/fuzz_tree.py   | 67 -----------------------------
 5 files changed, 57 insertions(+), 90 deletions(-)
 delete mode 100644 fuzzing/dictionaries/fuzz_tree.dict
 create mode 100644 fuzzing/fuzz-targets/fuzz_repo.py
 delete mode 100644 fuzzing/fuzz-targets/fuzz_tree.py

diff --git a/README.md b/README.md
index 987e40e6c..d365a6584 100644
--- a/README.md
+++ b/README.md
@@ -240,8 +240,8 @@ Please have a look at the [contributions file][contributing].
 
 [3-Clause BSD License](https://opensource.org/license/bsd-3-clause/), also known as the New BSD License. See the [LICENSE file][license].
 
-Two files exclusively used for fuzz testing are subject to [a separate license, detailed here](./fuzzing/README.md#license).
-These files are not included in the wheel or sdist packages published by the maintainers of GitPython.
+One file exclusively used for fuzz testing is subject to [a separate license, detailed here](./fuzzing/README.md#license).
+This file is not included in the wheel or sdist packages published by the maintainers of GitPython.
 
 [contributing]: https://github.com/gitpython-developers/GitPython/blob/main/CONTRIBUTING.md
 [license]: https://github.com/gitpython-developers/GitPython/blob/main/LICENSE
diff --git a/fuzzing/README.md b/fuzzing/README.md
index 0a62b4c85..9d02bf72f 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -225,14 +225,14 @@ to [the official OSS-Fuzz documentation][oss-fuzz-docs].
 ## LICENSE
 
 All files located within the `fuzzing/` directory are subject to [the same license](../LICENSE)
-as [the other files in this repository](../README.md#license) with two exceptions:
-
-Two files located in this directory, [`fuzz_config.py`](./fuzz-targets/fuzz_config.py)
-and [`fuzz_tree.py`](./fuzz-targets/fuzz_tree.py), have been migrated here from the OSS-Fuzz project repository where
-they were originally created. As such, these two files retain their original license and copyright notice (Apache
-License, Version 2.0 and Copyright 2023 Google LLC respectively.) Each file includes a notice in their respective header
-comments stating that they have been modified. [LICENSE-APACHE](./LICENSE-APACHE) contains the original license used by
-the OSS-Fuzz project repository at the time they were migrated.
+as [the other files in this repository](../README.md#license) with one exception:
+
+[`fuzz_config.py`](./fuzz-targets/fuzz_config.py) was migrated to this repository from the OSS-Fuzz project's repository
+where it was originally created. As such, [`fuzz_config.py`](./fuzz-targets/fuzz_config.py) retains its original license
+and copyright notice (Apache License, Version 2.0 and Copyright 2023 Google LLC respectively) as in a header
+comment, followed by a notice stating that it has have been modified contributors to GitPython.
+[LICENSE-APACHE](./LICENSE-APACHE) contains the original license used by the OSS-Fuzz project repository at the time the
+file was migrated.
 
 [oss-fuzz-repo]: https://github.com/google/oss-fuzz
 
diff --git a/fuzzing/dictionaries/fuzz_tree.dict b/fuzzing/dictionaries/fuzz_tree.dict
deleted file mode 100644
index 3ebe52b7f..000000000
--- a/fuzzing/dictionaries/fuzz_tree.dict
+++ /dev/null
@@ -1,13 +0,0 @@
-"\\001\\000\\000\\000"
-"_join_multiline_va"
-"setdef"
-"1\\000\\000\\000\\000\\000\\000\\000"
-"\\000\\000\\000\\000\\000\\000\\000\\020"
-"\\377\\377\\377\\377\\377\\377\\377r"
-"\\001\\000\\000\\000\\000\\000\\000\\001"
-"\\000\\000\\000\\000\\000\\000\\000\\014"
-"\\000\\000\\000\\000\\000\\000\\000\\003"
-"\\001\\000"
-"\\032\\000\\000\\000\\000\\000\\000\\000"
-"-\\000\\000\\000\\000\\000\\000\\000"
-"__format"
diff --git a/fuzzing/fuzz-targets/fuzz_repo.py b/fuzzing/fuzz-targets/fuzz_repo.py
new file mode 100644
index 000000000..7bd82c120
--- /dev/null
+++ b/fuzzing/fuzz-targets/fuzz_repo.py
@@ -0,0 +1,47 @@
+import atheris
+import io
+import sys
+import os
+import tempfile
+
+if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
+
+with atheris.instrument_imports():
+    import git
+
+
+def TestOneInput(data):
+    fdp = atheris.FuzzedDataProvider(data)
+
+    with tempfile.TemporaryDirectory() as temp_dir:
+        repo = git.Repo.init(path=temp_dir)
+
+        # Generate a minimal set of files based on fuzz data to minimize I/O operations.
+        file_paths = [os.path.join(temp_dir, f"File{i}") for i in range(min(3, fdp.ConsumeIntInRange(1, 3)))]
+        for file_path in file_paths:
+            with open(file_path, "wb") as f:
+                # The chosen upperbound for count of bytes we consume by writing to these
+                # files is somewhat arbitrary and may be worth experimenting with if the
+                # fuzzer coverage plateaus.
+                f.write(fdp.ConsumeBytes(fdp.ConsumeIntInRange(1, 512)))
+
+        repo.index.add(file_paths)
+        repo.index.commit(fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 80)))
+
+        fuzz_tree = git.Tree(repo, git.Tree.NULL_BIN_SHA, 0, "")
+
+        try:
+            fuzz_tree._deserialize(io.BytesIO(data))
+        except IndexError:
+            return -1
+
+
+def main():
+    atheris.Setup(sys.argv, TestOneInput)
+    atheris.Fuzz()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/fuzzing/fuzz-targets/fuzz_tree.py b/fuzzing/fuzz-targets/fuzz_tree.py
deleted file mode 100644
index 4e2038add..000000000
--- a/fuzzing/fuzz-targets/fuzz_tree.py
+++ /dev/null
@@ -1,67 +0,0 @@
-# Copyright 2023 Google LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#      http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-###############################################################################
-# Note: This file has been modified by contributors to GitPython.
-# The original state of this file may be referenced here:
-# https://github.com/google/oss-fuzz/commit/f26f254558fc48f3c9bc130b10507386b94522da
-###############################################################################
-import atheris
-import io
-import sys
-import os
-import shutil
-
-if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
-    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
-    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
-
-with atheris.instrument_imports():
-    import git
-
-
-def TestOneInput(data):
-    fdp = atheris.FuzzedDataProvider(data)
-    git_dir = "/tmp/.git"
-    head_file = os.path.join(git_dir, "HEAD")
-    refs_dir = os.path.join(git_dir, "refs")
-    common_dir = os.path.join(git_dir, "commondir")
-    objects_dir = os.path.join(git_dir, "objects")
-
-    if os.path.isdir(git_dir):
-        shutil.rmtree(git_dir)
-
-    os.mkdir(git_dir)
-    with open(head_file, "w") as f:
-        f.write(fdp.ConsumeUnicodeNoSurrogates(1024))
-    os.mkdir(refs_dir)
-    os.mkdir(common_dir)
-    os.mkdir(objects_dir)
-
-    _repo = git.Repo("/tmp/")
-
-    fuzz_tree = git.Tree(_repo, git.Tree.NULL_BIN_SHA, 0, "")
-    try:
-        fuzz_tree._deserialize(io.BytesIO(data))
-    except IndexError:
-        return -1
-
-
-def main():
-    atheris.Setup(sys.argv, TestOneInput)
-    atheris.Fuzz()
-
-
-if __name__ == "__main__":
-    main()

From 48abb1cbc138cd9c013369ea4608dd2fe5ca7a62 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Sat, 4 May 2024 14:40:26 -0400
Subject: [PATCH 158/332] Add git.Blob fuzz target

Based on the `test_blob.py` unit test.
---
 fuzzing/dictionaries/fuzz_blob.dict |  1 +
 fuzzing/fuzz-targets/fuzz_blob.py   | 36 +++++++++++++++++++++++++++++
 2 files changed, 37 insertions(+)
 create mode 100644 fuzzing/dictionaries/fuzz_blob.dict
 create mode 100644 fuzzing/fuzz-targets/fuzz_blob.py

diff --git a/fuzzing/dictionaries/fuzz_blob.dict b/fuzzing/dictionaries/fuzz_blob.dict
new file mode 100644
index 000000000..7f123f830
--- /dev/null
+++ b/fuzzing/dictionaries/fuzz_blob.dict
@@ -0,0 +1 @@
+"\\377\\377\\377\\377\\377\\377\\377\\377" 
diff --git a/fuzzing/fuzz-targets/fuzz_blob.py b/fuzzing/fuzz-targets/fuzz_blob.py
new file mode 100644
index 000000000..9d296de40
--- /dev/null
+++ b/fuzzing/fuzz-targets/fuzz_blob.py
@@ -0,0 +1,36 @@
+import atheris
+import sys
+import os
+import tempfile
+
+if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
+
+with atheris.instrument_imports():
+    import git
+
+
+def TestOneInput(data):
+    fdp = atheris.FuzzedDataProvider(data)
+
+    with tempfile.TemporaryDirectory() as temp_dir:
+        repo = git.Repo.init(path=temp_dir)
+        blob = git.Blob(
+            repo,
+            **{
+                "binsha": git.Blob.NULL_BIN_SHA,
+                "path": fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes()),
+            },
+        )
+
+        _ = blob.mime_type
+
+
+def main():
+    atheris.Setup(sys.argv, TestOneInput)
+    atheris.Fuzz()
+
+
+if __name__ == "__main__":
+    main()

From 6823e4543f33eb623df14a5a27c9731199de7a4f Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Sat, 4 May 2024 15:44:23 -0400
Subject: [PATCH 159/332] Use fuzzed data for all git.Blob arguments

This increases the edges reached by the fuzzer, making for a more
effective test with higher coverage.
---
 fuzzing/fuzz-targets/fuzz_blob.py | 18 +++++++++++-------
 1 file changed, 11 insertions(+), 7 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_blob.py b/fuzzing/fuzz-targets/fuzz_blob.py
index 9d296de40..ce888e85f 100644
--- a/fuzzing/fuzz-targets/fuzz_blob.py
+++ b/fuzzing/fuzz-targets/fuzz_blob.py
@@ -16,13 +16,17 @@ def TestOneInput(data):
 
     with tempfile.TemporaryDirectory() as temp_dir:
         repo = git.Repo.init(path=temp_dir)
-        blob = git.Blob(
-            repo,
-            **{
-                "binsha": git.Blob.NULL_BIN_SHA,
-                "path": fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes()),
-            },
-        )
+        binsha = fdp.ConsumeBytes(20)
+        mode = fdp.ConsumeInt(fdp.ConsumeIntInRange(0, fdp.remaining_bytes()))
+        path = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes())
+
+        try:
+            blob = git.Blob(repo, binsha, mode, path)
+        except AssertionError as e:
+            if "Require 20 byte binary sha, got" in str(e):
+                return -1
+            else:
+                raise e
 
         _ = blob.mime_type
 

From e15caab8e70adc44b796bd3d972e1d34d30ad7ee Mon Sep 17 00:00:00 2001
From: Jirka <jirka.borovec@seznam.cz>
Date: Tue, 7 May 2024 19:26:54 +0200
Subject: [PATCH 160/332] lint: switch order Ruff's hooks `fix` -> `format`

---
 .pre-commit-config.yaml | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 585b4f04d..987d86cd9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,12 +1,12 @@
 repos:
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.3.2
+  rev: v0.4.3
   hooks:
-  - id: ruff-format
-    exclude: ^git/ext/
   - id: ruff
     args: ["--fix"]
     exclude: ^git/ext/
+  - id: ruff-format
+    exclude: ^git/ext/
 
 - repo: https://github.com/shellcheck-py/shellcheck-py
   rev: v0.9.0.6

From 2cfd2007b4a73bb061506e7c521570e9a0ec3f96 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Wed, 8 May 2024 03:20:18 -0400
Subject: [PATCH 161/332] Update OSS-Fuzz Scripts to Use New QA-Assets Repo
 Structure

This change is required to support the changes to the seed data repo
structure introduced in:
https://github.com/gitpython-developers/qa-assets/pull/2

This moves most of the seed data related build steps into the OSS-Fuzz
Docker image build via `container-environment-bootstrap.sh`. This
includes moveing the dictionaries into that repo.

The fuzzing/README.md here should be updated in a follow-up with a link
to the qa-assets repo (and probably some context setting about corpora
in general) but I have opted to defer that as I think the functionality
added by the seed data improvements is valuable as is and shouldn't be
blocked by documentation writers block.
---
 fuzzing/README.md                             | 19 ------
 fuzzing/dictionaries/fuzz_blob.dict           |  1 -
 fuzzing/dictionaries/fuzz_config.dict         | 56 ----------------
 fuzzing/oss-fuzz-scripts/build.sh             | 27 +-------
 .../container-environment-bootstrap.sh        | 64 +++++++++++++++----
 5 files changed, 53 insertions(+), 114 deletions(-)
 delete mode 100644 fuzzing/dictionaries/fuzz_blob.dict
 delete mode 100644 fuzzing/dictionaries/fuzz_config.dict

diff --git a/fuzzing/README.md b/fuzzing/README.md
index 9d02bf72f..286f529eb 100644
--- a/fuzzing/README.md
+++ b/fuzzing/README.md
@@ -76,25 +76,6 @@ Contains Python files for each fuzz test.
   reason, fuzz tests should gracefully handle anticipated exception cases with a `try`/`except` block to avoid false
   positives that halt the fuzzing engine.
 
-### Dictionaries (`dictionaries/`)
-
-Provides hints to the fuzzing engine about inputs that might trigger unique code paths. Each fuzz target may have a
-corresponding `.dict` file. For information about dictionary syntax, refer to
-the [LibFuzzer documentation on the subject](https://llvm.org/docs/LibFuzzer.html#dictionaries).
-
-**Things to Know**:
-
-- OSS-Fuzz loads dictionary files per fuzz target if one exists with the same name, all others are ignored.
-- Most entries in the dictionary files found here are escaped hex or Unicode values that were recommended by the fuzzing
-  engine after previous runs.
-- A default set of dictionary entries are created for all fuzz targets as part of the build process, regardless of an
-  existing file here.
-- Development or updates to dictionaries should reflect the varied formats and edge cases relevant to the
-  functionalities under test.
-- Example dictionaries (some of which are used to build the default dictionaries mentioned above) can be found here:
-  - [AFL++ dictionary repository](https://github.com/AFLplusplus/AFLplusplus/tree/stable/dictionaries#readme)
-  - [Google/fuzzing dictionary repository](https://github.com/google/fuzzing/tree/master/dictionaries)
-
 ### OSS-Fuzz Scripts (`oss-fuzz-scripts/`)
 
 Includes scripts for building and integrating fuzz targets with OSS-Fuzz:
diff --git a/fuzzing/dictionaries/fuzz_blob.dict b/fuzzing/dictionaries/fuzz_blob.dict
deleted file mode 100644
index 7f123f830..000000000
--- a/fuzzing/dictionaries/fuzz_blob.dict
+++ /dev/null
@@ -1 +0,0 @@
-"\\377\\377\\377\\377\\377\\377\\377\\377" 
diff --git a/fuzzing/dictionaries/fuzz_config.dict b/fuzzing/dictionaries/fuzz_config.dict
deleted file mode 100644
index b545ddfc8..000000000
--- a/fuzzing/dictionaries/fuzz_config.dict
+++ /dev/null
@@ -1,56 +0,0 @@
-"\\004\\000\\000\\000\\000\\000\\000\\000"
-"\\006\\000\\000\\000\\000\\000\\000\\000"
-"_validate_value_"
-"\\000\\000\\000\\000\\000\\000\\000\\000"
-"rem"
-"__eq__"
-"\\001\\000\\000\\000"
-"__abstrac"
-"_mutating_methods_"
-"items"
-"\\0021\\""
-"\\001\\000"
-"\\000\\000\\000\\000"
-"DEFAULT"
-"getfloat"
-"\\004\\000\\000\\000\\000\\000\\000\\000"
-"news"
-"\\037\\000\\000\\000\\000\\000\\000\\000"
-"\\001\\000\\000\\000\\000\\000\\000\\037"
-"\\000\\000\\000\\000\\000\\000\\000\\014"
-"list"
-"\\376\\377\\377\\377\\377\\377\\377\\377"
-"items_all"
-"\\004\\000\\000\\000\\000\\000\\000\\000"
-"\\377\\377\\377\\377\\377\\377\\377\\014"
-"\\001\\000\\000\\000"
-"_acqui"
-"\\000\\000\\000\\000\\000\\000\\000\\000"
-"__ne__"
-"__exit__"
-"__modu"
-"uucp"
-"__str__"
-"\\001\\000\\000\\000"
-"\\017\\000\\000\\000\\000\\000\\000\\000"
-"_has_incl"
-"update"
-"\\377\\377\\377\\377\\377\\377\\377\\023"
-"setdef"
-"setdefaul"
-"\\000\\000\\000\\000"
-"\\001\\000\\000\\000"
-"\\001\\000"
-"\\022\\000\\000\\000\\000\\000\\000\\000"
-"_value_to_string"
-"__abstr"
-"\\001\\000\\000\\000\\000\\000\\000\\000"
-"\\000\\000\\000\\000\\000\\000\\000\\022"
-"\\377\\377\\377\\377"
-"\\004\\000\\000\\000\\000\\000\\000\\000"
-"\\000\\000\\000\\000\\000\\000\\000\\000"
-"\\000\\000\\000\\000\\000\\000\\000\\037"
-"\\001\\000\\000\\000\\000\\000\\000\\013"
-"_OPT_TM"
-"__name__"
-"_get_conv"
diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index 58c9adb5a..e0b3a50ab 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -7,34 +7,13 @@ set -euo pipefail
 
 python3 -m pip install .
 
-# Directory to look in for dictionaries, options files, and seed corpora:
-SEED_DATA_DIR="$SRC/seed_data"
-
-find "$SEED_DATA_DIR" \( -name '*_seed_corpus.zip' -o -name '*.options' -o -name '*.dict' \) \
-  ! \( -name '__base.*' \) -exec printf 'Copying: %s\n' {} \; \
+find "$SRC" -maxdepth 1 \
+  \( -name '*_seed_corpus.zip' -o -name '*.options' -o -name '*.dict' \) \
+  -exec printf '[%s] Copying: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" {} \; \
   -exec chmod a-x {} \; \
   -exec cp {} "$OUT" \;
 
 # Build fuzzers in $OUT.
 find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d '' fuzz_harness; do
   compile_python_fuzzer "$fuzz_harness" --add-binary="$(command -v git):."
-
-  common_base_dictionary_filename="$SEED_DATA_DIR/__base.dict"
-  if [[ -r "$common_base_dictionary_filename" ]]; then
-    # Strip the `.py` extension from the filename and replace it with `.dict`.
-    fuzz_harness_dictionary_filename="$(basename "$fuzz_harness" .py).dict"
-    output_file="$OUT/$fuzz_harness_dictionary_filename"
-
-    printf 'Appending %s to %s\n' "$common_base_dictionary_filename" "$output_file"
-    if [[ -s "$output_file" ]]; then
-      # If a dictionary file for this fuzzer already exists and is not empty,
-      # we append a new line to the end of it before appending any new entries.
-      #
-      # LibFuzzer will happily ignore multiple empty lines in a dictionary but fail with an error
-      # if any single line has incorrect syntax (e.g., if we accidentally add two entries to the same line.)
-      # See docs for valid syntax: https://llvm.org/docs/LibFuzzer.html#id32
-      echo >>"$output_file"
-    fi
-    cat "$common_base_dictionary_filename" >>"$output_file"
-  fi
 done
diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index 76ec97c7f..bbdcf5357 100755
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -9,23 +9,20 @@ set -euo pipefail
 # Prerequisites #
 #################
 
-for cmd in python3 git wget rsync; do
+for cmd in python3 git wget zip; do
   command -v "$cmd" >/dev/null 2>&1 || {
     printf '[%s] Required command %s not found, exiting.\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$cmd" >&2
     exit 1
   }
 done
 
-SEED_DATA_DIR="$SRC/seed_data"
-mkdir -p "$SEED_DATA_DIR"
-
 #############
 # Functions #
 #############
 
 download_and_concatenate_common_dictionaries() {
   # Assign the first argument as the target file where all contents will be concatenated
-  target_file="$1"
+  local target_file="$1"
 
   # Shift the arguments so the first argument (target_file path) is removed
   # and only URLs are left for the loop below.
@@ -38,22 +35,61 @@ download_and_concatenate_common_dictionaries() {
   done
 }
 
-fetch_seed_corpora() {
-  # Seed corpus zip files are hosted in a separate repository to avoid additional bloat in this repo.
-  git clone --depth 1 https://github.com/gitpython-developers/qa-assets.git qa-assets &&
-    rsync -avc qa-assets/gitpython/corpra/ "$SEED_DATA_DIR/" &&
-    rm -rf qa-assets # Clean up the cloned repo to keep the Docker image as slim as possible.
+create_seed_corpora_zips() {
+  local seed_corpora_dir="$1"
+  local output_zip
+  for dir in "$seed_corpora_dir"/*; do
+    if [ -d "$dir" ] && [ -n "$dir" ]; then
+      output_zip="$SRC/$(basename "$dir")_seed_corpus.zip"
+      printf '[%s] Zipping the contents of %s into %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$dir" "$output_zip"
+      zip -jur "$output_zip" "$dir"/*
+    fi
+  done
+}
+
+prepare_dictionaries_for_fuzz_targets() {
+  local dictionaries_dir="$1"
+  local fuzz_targets_dir="$2"
+  local common_base_dictionary_filename="$WORK/__base.dict"
+
+  printf '[%s] Copying .dict files from %s to %s\n' "$(date '+%Y-%m-%d %H:%M:%S')"  "$dictionaries_dir" "$SRC/"
+  cp -v "$dictionaries_dir"/*.dict "$SRC/"
+
+  download_and_concatenate_common_dictionaries "$common_base_dictionary_filename" \
+    "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/utf8.dict" \
+    "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/url.dict"
+
+  find "$fuzz_targets_dir" -name 'fuzz_*.py' -print0 | while IFS= read -r -d '' fuzz_harness; do
+    if [[ -r "$common_base_dictionary_filename" ]]; then
+      # Strip the `.py` extension from the filename and replace it with `.dict`.
+      fuzz_harness_dictionary_filename="$(basename "$fuzz_harness" .py).dict"
+      local output_file="$SRC/$fuzz_harness_dictionary_filename"
+
+      printf '[%s] Appending %s to %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$common_base_dictionary_filename" "$output_file"
+      if [[ -s "$output_file" ]]; then
+        # If a dictionary file for this fuzzer already exists and is not empty,
+        # we append a new line to the end of it before appending any new entries.
+        #
+        # LibFuzzer will happily ignore multiple empty lines in a dictionary but fail with an error
+        # if any single line has incorrect syntax (e.g., if we accidentally add two entries to the same line.)
+        # See docs for valid syntax: https://llvm.org/docs/LibFuzzer.html#id32
+        echo >>"$output_file"
+      fi
+      cat "$common_base_dictionary_filename" >>"$output_file"
+    fi
+  done
 }
 
 ########################
 # Main execution logic #
 ########################
+# Seed corpora and dictionaries are hosted in a separate repository to avoid additional bloat in this repo.
+# We clone into the $WORK directory because OSS-Fuzz cleans it up after building the image, keeping the image small.
+git clone --depth 1 https://github.com/gitpython-developers/qa-assets.git "$WORK/qa-assets"
 
-fetch_seed_corpora
+create_seed_corpora_zips "$WORK/qa-assets/gitpython/corpora"
 
-download_and_concatenate_common_dictionaries "$SEED_DATA_DIR/__base.dict" \
-  "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/utf8.dict" \
-  "https://raw.githubusercontent.com/google/fuzzing/master/dictionaries/url.dict"
+prepare_dictionaries_for_fuzz_targets "$WORK/qa-assets/gitpython/dictionaries" "$SRC/gitpython/fuzzing"
 
 # The OSS-Fuzz base image has outdated dependencies by default so we upgrade them below.
 python3 -m pip install --upgrade pip

From a915adf08e570d8989bb070f647e2a3ee941871d Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Wed, 8 May 2024 17:06:19 -0400
Subject: [PATCH 162/332] Add `Diff` Fuzz Target

Adds a new `fuzz_diff.py` fuzz target that covers `Diff` class
initialization using fuzzed data.
---
 fuzzing/fuzz-targets/fuzz_diff.py | 54 +++++++++++++++++++++++++++++++
 1 file changed, 54 insertions(+)
 create mode 100644 fuzzing/fuzz-targets/fuzz_diff.py

diff --git a/fuzzing/fuzz-targets/fuzz_diff.py b/fuzzing/fuzz-targets/fuzz_diff.py
new file mode 100644
index 000000000..cf01e7ffa
--- /dev/null
+++ b/fuzzing/fuzz-targets/fuzz_diff.py
@@ -0,0 +1,54 @@
+import sys
+import os
+import tempfile
+from binascii import Error as BinasciiError
+
+import atheris
+
+if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
+
+with atheris.instrument_imports():
+    from git import Repo, Diff
+
+
+def TestOneInput(data):
+    fdp = atheris.FuzzedDataProvider(data)
+
+    with tempfile.TemporaryDirectory() as temp_dir:
+        repo = Repo.init(path=temp_dir)
+        try:
+            Diff(
+                repo,
+                a_rawpath=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
+                b_rawpath=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
+                a_blob_id=fdp.ConsumeBytes(20),
+                b_blob_id=fdp.ConsumeBytes(20),
+                a_mode=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
+                b_mode=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
+                new_file=fdp.ConsumeBool(),
+                deleted_file=fdp.ConsumeBool(),
+                copied_file=fdp.ConsumeBool(),
+                raw_rename_from=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
+                raw_rename_to=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
+                diff=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
+                change_type=fdp.PickValueInList(["A", "D", "C", "M", "R", "T", "U"]),
+                score=fdp.ConsumeIntInRange(0, fdp.remaining_bytes()),
+            )
+        except BinasciiError:
+            return -1
+        except AssertionError as e:
+            if "Require 20 byte binary sha, got" in str(e):
+                return -1
+            else:
+                raise e
+
+
+def main():
+    atheris.Setup(sys.argv, TestOneInput)
+    atheris.Fuzz()
+
+
+if __name__ == "__main__":
+    main()

From 989ae1ac03e25a5ce51d4c615128dcf75b9e24f5 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Wed, 8 May 2024 19:28:29 -0400
Subject: [PATCH 163/332] Read class properties & call methods to cover more
 features

Property access and private methods on the `Diff` class are complex and
involve encoding and decoding operations that warrant being tested.

This test borrows its design from the `test_diff.py` unit test file.
---
 fuzzing/fuzz-targets/fuzz_diff.py | 31 ++++++++++++++++++++++++++++++-
 1 file changed, 30 insertions(+), 1 deletion(-)

diff --git a/fuzzing/fuzz-targets/fuzz_diff.py b/fuzzing/fuzz-targets/fuzz_diff.py
index cf01e7ffa..ba44995f2 100644
--- a/fuzzing/fuzz-targets/fuzz_diff.py
+++ b/fuzzing/fuzz-targets/fuzz_diff.py
@@ -1,5 +1,6 @@
 import sys
 import os
+import io
 import tempfile
 from binascii import Error as BinasciiError
 
@@ -13,13 +14,26 @@
     from git import Repo, Diff
 
 
+class BytesProcessAdapter:
+    """Allows bytes to be used as process objects returned by subprocess.Popen."""
+
+    def __init__(self, input_string):
+        self.stdout = io.BytesIO(input_string)
+        self.stderr = io.BytesIO()
+
+    def wait(self):
+        return 0
+
+    poll = wait
+
+
 def TestOneInput(data):
     fdp = atheris.FuzzedDataProvider(data)
 
     with tempfile.TemporaryDirectory() as temp_dir:
         repo = Repo.init(path=temp_dir)
         try:
-            Diff(
+            diff = Diff(
                 repo,
                 a_rawpath=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
                 b_rawpath=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())),
@@ -44,6 +58,21 @@ def TestOneInput(data):
             else:
                 raise e
 
+        _ = diff.__str__()
+        _ = diff.a_path
+        _ = diff.b_path
+        _ = diff.rename_from
+        _ = diff.rename_to
+        _ = diff.renamed_file
+
+        diff_index = diff._index_from_patch_format(
+            repo, proc=BytesProcessAdapter(fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())))
+        )
+
+        diff._handle_diff_line(
+            lines_bytes=fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, fdp.remaining_bytes())), repo=repo, index=diff_index
+        )
+
 
 def main():
     atheris.Setup(sys.argv, TestOneInput)

From 315a2fd03c94c93d4a7089d23d734e4aaccbe066 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Wed, 15 May 2024 13:36:29 -0400
Subject: [PATCH 164/332] Instrument test utility functions to increase fuzzer
 efficiency

Fuzz Introspector was reporting a high percentage of fuzz blockers
in the `fuzz_diff` test. This means the fuzzing engine was unable
to gain visibility into functions lower in the call stack than the
blocking functions, making it less effective at producing interesting
input data.

This clears a large percentage of the fuzz blockers by adding fuzzer
instrumentation to them via the `@atheris.instrument_func` decorator.
---
 fuzzing/fuzz-targets/fuzz_diff.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/fuzzing/fuzz-targets/fuzz_diff.py b/fuzzing/fuzz-targets/fuzz_diff.py
index ba44995f2..d4bd68b57 100644
--- a/fuzzing/fuzz-targets/fuzz_diff.py
+++ b/fuzzing/fuzz-targets/fuzz_diff.py
@@ -17,16 +17,19 @@
 class BytesProcessAdapter:
     """Allows bytes to be used as process objects returned by subprocess.Popen."""
 
+    @atheris.instrument_func
     def __init__(self, input_string):
         self.stdout = io.BytesIO(input_string)
         self.stderr = io.BytesIO()
 
+    @atheris.instrument_func
     def wait(self):
         return 0
 
     poll = wait
 
 
+@atheris.instrument_func
 def TestOneInput(data):
     fdp = atheris.FuzzedDataProvider(data)
 

From cf81c6c98155c24d69af6f1a8eca368ad1a5d962 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 26 May 2024 16:40:23 -0400
Subject: [PATCH 165/332] Momentarily downgrade Git on Cygwin to investigate
 failures

Using this older version is not in general secure, since the new
version is a security update. It is sometimes acceptable to run
software with security bugs in CI workflows, but the intent of this
change is just to check if the version of the Cygwin `git` package
is the cause of the failures. If so, they can probably be fixed or
worked around in a better way than downgrading. (Furthermore, the
lower version of the `git` package will not always be avaialable
from Cygwin's repositories.)
---
 .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 61e6a3089..a2fe588ad 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: Set up Cygwin
       uses: egor-tensin/setup-cygwin@v4
       with:
-        packages: python39=3.9.16-1 python39-pip python39-virtualenv git
+        packages: python39=3.9.16-1 python39-pip python39-virtualenv git=2.43.0-1
 
     - name: Arrange for verbose output
       run: |

From eb06a18d83eda0ae04e2a00b2d656da147e9188a Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 26 May 2024 16:45:57 -0400
Subject: [PATCH 166/332] Unpin Cygwin `git`; add our `.git` as a
 `safe.directory`

This undoes the change of pinning Git to an earlier version (before
the recent security update) on Cygwin, and instead adds the `.git`
subdirectory of the `GitPython` directory as an additional value of
the multi-valued `safe.directory` Git configuration variable.
---
 .github/workflows/cygwin-test.yml | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index a2fe588ad..bde4ea659 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: Set up Cygwin
       uses: egor-tensin/setup-cygwin@v4
       with:
-        packages: python39=3.9.16-1 python39-pip python39-virtualenv git=2.43.0-1
+        packages: python39=3.9.16-1 python39-pip python39-virtualenv git
 
     - name: Arrange for verbose output
       run: |
@@ -40,6 +40,7 @@ jobs:
     - name: Special configuration for Cygwin git
       run: |
         git config --global --add safe.directory "$(pwd)"
+        git config --global --add safe.directory "$(pwd)/.git"
         git config --global core.autocrlf false
 
     - name: Prepare this repo for tests

From d3b181d54a8da6b8561474dba1333682b47b7ba7 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 27 May 2024 13:22:29 +0000
Subject: [PATCH 167/332] Bump Vampire/setup-wsl from 3.0.0 to 3.1.0

Bumps [Vampire/setup-wsl](https://github.com/vampire/setup-wsl) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/vampire/setup-wsl/releases)
- [Commits](https://github.com/vampire/setup-wsl/compare/v3.0.0...v3.1.0)

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

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 4c918a92d..574048620 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -44,7 +44,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: startsWith(matrix.os, 'windows')
-      uses: Vampire/setup-wsl@v3.0.0
+      uses: Vampire/setup-wsl@v3.1.0
       with:
         distribution: Debian
 

From 7bdcfa556ad476d89a3643137e97ee5749e3c7df Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Mon, 27 May 2024 21:27:07 +0200
Subject: [PATCH 168/332] Update to the fixed version of `Vampire`

---
 .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 574048620..031b0e6b2 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -44,7 +44,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: startsWith(matrix.os, 'windows')
-      uses: Vampire/setup-wsl@v3.1.0
+      uses: Vampire/setup-wsl@v3.1.1
       with:
         distribution: Debian
 

From 6d52bdbe6a546ecb76e28f7dde45b44fe8577010 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Wed, 29 May 2024 22:06:40 -0400
Subject: [PATCH 169/332] Add Submodules Fuzz Target

Fuzz Introspector heuristics suggest the Submodule API code represent
"optimal analysis targets" that should yield a meaningful increase in
code coverage. The changes here introduce a first pass at implementing a
fuzz harness that cover the primary APIs/methods related to Submodules.
Of particular interest to me is the `Submodule.config_writer()`
coverage.

Please note however, there is likely plenty of room for improvement in
this harness in terms of both code coverage as well as performance; the
latter of which will see significant benefit from a well curated seed
corpus of `.gitmodules` file like inputs. The `ParsingError` raised by
the fuzzer without a good seed corpus hinders test efficacy
significantly.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 93 ++++++++++++++++++++++++++
 fuzzing/fuzz-targets/utils.py          | 22 ++++++
 2 files changed, 115 insertions(+)
 create mode 100644 fuzzing/fuzz-targets/fuzz_submodule.py
 create mode 100644 fuzzing/fuzz-targets/utils.py

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
new file mode 100644
index 000000000..ddcbaa00f
--- /dev/null
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -0,0 +1,93 @@
+import atheris
+import sys
+import os
+import tempfile
+from configparser import ParsingError
+from utils import is_expected_exception_message
+
+if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
+    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
+
+with atheris.instrument_imports():
+    from git import Repo, GitCommandError, InvalidGitRepositoryError
+
+
+def TestOneInput(data):
+    fdp = atheris.FuzzedDataProvider(data)
+
+    with tempfile.TemporaryDirectory() as repo_temp_dir:
+        repo = Repo.init(path=repo_temp_dir)
+        repo.index.commit("Initial commit")
+
+        try:
+            with tempfile.TemporaryDirectory() as submodule_temp_dir:
+                sub_repo = Repo.init(submodule_temp_dir, bare=fdp.ConsumeBool())
+                sub_repo.index.commit(fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512)))
+
+                submodule_name = f"submodule_{fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512))}"
+                submodule_path = os.path.join(repo.working_tree_dir, submodule_name)
+                submodule_url = sub_repo.git_dir
+
+                submodule = repo.create_submodule(submodule_name, submodule_path, url=submodule_url)
+                repo.index.commit(f"Added submodule {submodule_name}")
+
+                with submodule.config_writer() as writer:
+                    key_length = fdp.ConsumeIntInRange(1, max(1, fdp.remaining_bytes()))
+                    value_length = fdp.ConsumeIntInRange(1, max(1, fdp.remaining_bytes()))
+
+                    writer.set_value(
+                        fdp.ConsumeUnicodeNoSurrogates(key_length), fdp.ConsumeUnicodeNoSurrogates(value_length)
+                    )
+                    writer.release()
+
+                submodule.update(init=fdp.ConsumeBool(), dry_run=fdp.ConsumeBool(), force=fdp.ConsumeBool())
+
+                submodule_repo = submodule.module()
+                new_file_path = os.path.join(
+                    submodule_repo.working_tree_dir,
+                    f"new_file_{fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512))}",
+                )
+                with open(new_file_path, "wb") as new_file:
+                    new_file.write(fdp.ConsumeBytes(fdp.ConsumeIntInRange(1, 512)))
+                submodule_repo.index.add([new_file_path])
+                submodule_repo.index.commit("Added new file to submodule")
+
+                repo.submodule_update(recursive=fdp.ConsumeBool())
+                submodule_repo.head.reset(commit="HEAD~1", working_tree=fdp.ConsumeBool(), head=fdp.ConsumeBool())
+                # Use fdp.PickValueInList to ensure at least one of 'module' or 'configuration' is True
+                module_option_value, configuration_option_value = fdp.PickValueInList(
+                    [(True, False), (False, True), (True, True)]
+                )
+                submodule.remove(
+                    module=module_option_value,
+                    configuration=configuration_option_value,
+                    dry_run=fdp.ConsumeBool(),
+                    force=fdp.ConsumeBool(),
+                )
+                repo.index.commit(f"Removed submodule {submodule_name}")
+
+        except (ParsingError, GitCommandError, InvalidGitRepositoryError, FileNotFoundError, BrokenPipeError):
+            return -1
+        except (ValueError, OSError) as e:
+            expected_messages = [
+                "SHA is empty",
+                "Reference at",
+                "embedded null byte",
+                "This submodule instance does not exist anymore",
+                "cmd stdin was empty",
+                "File name too long",
+            ]
+            if is_expected_exception_message(e, expected_messages):
+                return -1
+            else:
+                raise e
+
+
+def main():
+    atheris.Setup(sys.argv, TestOneInput)
+    atheris.Fuzz()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/fuzzing/fuzz-targets/utils.py b/fuzzing/fuzz-targets/utils.py
new file mode 100644
index 000000000..42faa8eb0
--- /dev/null
+++ b/fuzzing/fuzz-targets/utils.py
@@ -0,0 +1,22 @@
+import atheris  # pragma: no cover
+from typing import List  # pragma: no cover
+
+
+@atheris.instrument_func
+def is_expected_exception_message(exception: Exception, error_message_list: List[str]) -> bool:  # pragma: no cover
+    """
+    Checks if the message of a given exception matches any of the expected error messages, case-insensitively.
+
+    Args:
+         exception (Exception): The exception object raised during execution.
+         error_message_list (List[str]): A list of error message substrings to check against the exception's message.
+
+    Returns:
+      bool: True if the exception's message contains any of the substrings from the error_message_list,
+      case-insensitively, otherwise False.
+    """
+    exception_message = str(exception).lower()
+    for error in error_message_list:
+        if error.lower() in exception_message:
+            return True
+    return False

From 9e67138819c7e081fee89a5b855c89b538a8f604 Mon Sep 17 00:00:00 2001
From: Jirka <jirka.borovec@seznam.cz>
Date: Thu, 30 May 2024 12:22:45 +0200
Subject: [PATCH 170/332] precommit: enable `end-of-file-fixer`

---
 .pre-commit-config.yaml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 585b4f04d..50f430084 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -18,6 +18,7 @@ repos:
 - repo: https://github.com/pre-commit/pre-commit-hooks
   rev: v4.5.0
   hooks:
+  - id: end-of-file-fixer
   - id: check-toml
   - id: check-yaml
   - id: check-merge-conflict

From 96e21f0055060b01d03b705cdb582edd3551aa43 Mon Sep 17 00:00:00 2001
From: Jirka <jirka.borovec@seznam.cz>
Date: Thu, 30 May 2024 12:24:19 +0200
Subject: [PATCH 171/332] apply

---
 doc/source/index.rst                      | 1 -
 doc/source/intro.rst                      | 1 -
 doc/source/roadmap.rst                    | 1 -
 test/fixtures/.gitconfig                  | 2 +-
 test/fixtures/blame                       | 2 +-
 test/fixtures/cat_file_blob               | 2 +-
 test/fixtures/git_config                  | 1 -
 test/fixtures/git_config_with_empty_value | 2 +-
 test/fixtures/rev_list_bisect_all         | 1 -
 test/fixtures/rev_list_commit_diffs       | 1 -
 test/fixtures/rev_list_commit_idabbrev    | 1 -
 test/fixtures/rev_list_commit_stats       | 1 -
 12 files changed, 4 insertions(+), 12 deletions(-)

diff --git a/doc/source/index.rst b/doc/source/index.rst
index 72db8ee5a..ca5229ac3 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -21,4 +21,3 @@ Indices and tables
 * :ref:`genindex`
 * :ref:`modindex`
 * :ref:`search`
-
diff --git a/doc/source/intro.rst b/doc/source/intro.rst
index 4f22a0942..d053bd117 100644
--- a/doc/source/intro.rst
+++ b/doc/source/intro.rst
@@ -122,4 +122,3 @@ License Information
 ===================
 GitPython is licensed under the New BSD License.  See the LICENSE file for
 more information.
-
diff --git a/doc/source/roadmap.rst b/doc/source/roadmap.rst
index a573df33a..34c953626 100644
--- a/doc/source/roadmap.rst
+++ b/doc/source/roadmap.rst
@@ -6,4 +6,3 @@ The full list of milestones including associated tasks can be found on GitHub:
 https://github.com/gitpython-developers/GitPython/issues
 
 Select the respective milestone to filter the list of issues accordingly.
-
diff --git a/test/fixtures/.gitconfig b/test/fixtures/.gitconfig
index 6a0459f6b..f6c25c15a 100644
--- a/test/fixtures/.gitconfig
+++ b/test/fixtures/.gitconfig
@@ -1,3 +1,3 @@
 [alias]
     rbi = "!g() { git rebase -i origin/${1:-master} ; } ; g"
-    expush = "!f() { git branch -f tmp ; { git rbi $1 && git push ; } ; git reset --hard tmp ; git rebase origin/${1:-master}; } ; f"
\ No newline at end of file
+    expush = "!f() { git branch -f tmp ; { git rbi $1 && git push ; } ; git reset --hard tmp ; git rebase origin/${1:-master}; } ; f"
diff --git a/test/fixtures/blame b/test/fixtures/blame
index 10c141dda..949976c5d 100644
--- a/test/fixtures/blame
+++ b/test/fixtures/blame
@@ -128,4 +128,4 @@ b6e1b765e0c15586a2c5b9832854f95defd71e1f 23 23
 634396b2f541a9f2d58b00be1a07f0c358b999b3 11 24 2
 	  VERSION = '1.0.0'
 634396b2f541a9f2d58b00be1a07f0c358b999b3 12 25
-	end
\ No newline at end of file
+	end
diff --git a/test/fixtures/cat_file_blob b/test/fixtures/cat_file_blob
index 70c379b63..802992c42 100644
--- a/test/fixtures/cat_file_blob
+++ b/test/fixtures/cat_file_blob
@@ -1 +1 @@
-Hello world
\ No newline at end of file
+Hello world
diff --git a/test/fixtures/git_config b/test/fixtures/git_config
index a8cad56e8..d3066d86e 100644
--- a/test/fixtures/git_config
+++ b/test/fixtures/git_config
@@ -43,4 +43,3 @@
 # inclusions should be processed immediately
 [sec]
     var1 = value1_main
-
diff --git a/test/fixtures/git_config_with_empty_value b/test/fixtures/git_config_with_empty_value
index 0427caea5..83de84c8b 100644
--- a/test/fixtures/git_config_with_empty_value
+++ b/test/fixtures/git_config_with_empty_value
@@ -1,4 +1,4 @@
 [color]
 	ui
 [core]
-	filemode = true
\ No newline at end of file
+	filemode = true
diff --git a/test/fixtures/rev_list_bisect_all b/test/fixtures/rev_list_bisect_all
index 342ea94ae..60d382d01 100644
--- a/test/fixtures/rev_list_bisect_all
+++ b/test/fixtures/rev_list_bisect_all
@@ -48,4 +48,3 @@ committer David Aguilar <davvid@gmail.com> 1220418344 -0700
     This resolves the issue mentioned in that thread.
     
     Signed-off-by: David Aguilar <davvid@gmail.com>
-
diff --git a/test/fixtures/rev_list_commit_diffs b/test/fixtures/rev_list_commit_diffs
index 20397e2e4..c39df2061 100644
--- a/test/fixtures/rev_list_commit_diffs
+++ b/test/fixtures/rev_list_commit_diffs
@@ -5,4 +5,3 @@ author Tom Preston-Werner <tom@mojombo.com> 1193200199 -0700
 committer Tom Preston-Werner <tom@mojombo.com> 1193200199 -0700
 
     fix some initialization warnings
-
diff --git a/test/fixtures/rev_list_commit_idabbrev b/test/fixtures/rev_list_commit_idabbrev
index 9385ba713..6266df93e 100644
--- a/test/fixtures/rev_list_commit_idabbrev
+++ b/test/fixtures/rev_list_commit_idabbrev
@@ -5,4 +5,3 @@ author tom <tom@taco.(none)> 1195608462 -0800
 committer tom <tom@taco.(none)> 1195608462 -0800
 
     fix tests on other machines
-
diff --git a/test/fixtures/rev_list_commit_stats b/test/fixtures/rev_list_commit_stats
index 60aa8cf58..c78aadeb5 100644
--- a/test/fixtures/rev_list_commit_stats
+++ b/test/fixtures/rev_list_commit_stats
@@ -4,4 +4,3 @@ author Tom Preston-Werner <tom@mojombo.com> 1191997100 -0700
 committer Tom Preston-Werner <tom@mojombo.com> 1191997100 -0700
 
     initial grit setup
-

From 2ce9675b7238fb1a498f1ea4f5dae8a26d4b89ec Mon Sep 17 00:00:00 2001
From: Jirka <jirka.borovec@seznam.cz>
Date: Thu, 30 May 2024 12:25:33 +0200
Subject: [PATCH 172/332] precommit: enable `validate-pyproject`

---
 .pre-commit-config.yaml | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 585b4f04d..02950db8c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -21,3 +21,8 @@ repos:
   - id: check-toml
   - id: check-yaml
   - id: check-merge-conflict
+
+- repo: https://github.com/abravalheri/validate-pyproject
+  rev: v0.16
+  hooks:
+    - id: validate-pyproject
\ No newline at end of file

From 7b684cd43cf0f9c54adb8a8def54fa07f5cfd145 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 30 May 2024 10:34:49 -0400
Subject: [PATCH 173/332] Add graceful handling of expected exceptions in
 `fuzz_submodule.py`

Fixes: https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=69350

**`IsADirectoryError`**

Fuzzer provided input data can sometimes produce filenames that look
like directories and raise `IsADirectoryError` exceptions which crash
the fuzzer. This commit catches those cases and returns -1 to instruct
libfuzzer that the inputs are not valuable to add to the corpus.

**`FileExistsError`**

Similar to the above, this is a possible exception case produced by the
fuzzed data and not a bug so its handled the same.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index ddcbaa00f..9406fc68b 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -67,7 +67,15 @@ def TestOneInput(data):
                 )
                 repo.index.commit(f"Removed submodule {submodule_name}")
 
-        except (ParsingError, GitCommandError, InvalidGitRepositoryError, FileNotFoundError, BrokenPipeError):
+        except (
+            ParsingError,
+            GitCommandError,
+            InvalidGitRepositoryError,
+            FileNotFoundError,
+            FileExistsError,
+            IsADirectoryError,
+            BrokenPipeError,
+        ):
             return -1
         except (ValueError, OSError) as e:
             expected_messages = [

From 6c00ce602eb19eda342e827a25d005610ce92fa8 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 30 May 2024 13:53:42 -0400
Subject: [PATCH 174/332] Improve file name generation to prevent "File name
 too long" `OSError`'s

Adds a utility function to limit the maximum file name legnth produced
by the fuzzer to a max size dictated by the host its run on.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 13 ++++++-------
 fuzzing/fuzz-targets/utils.py          | 15 +++++++++++++++
 2 files changed, 21 insertions(+), 7 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 9406fc68b..817ce8f98 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -3,7 +3,7 @@
 import os
 import tempfile
 from configparser import ParsingError
-from utils import is_expected_exception_message
+from utils import is_expected_exception_message, get_max_filename_length
 
 if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
     path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
@@ -42,12 +42,12 @@ def TestOneInput(data):
                     writer.release()
 
                 submodule.update(init=fdp.ConsumeBool(), dry_run=fdp.ConsumeBool(), force=fdp.ConsumeBool())
-
                 submodule_repo = submodule.module()
-                new_file_path = os.path.join(
-                    submodule_repo.working_tree_dir,
-                    f"new_file_{fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512))}",
+
+                new_file_name = fdp.ConsumeUnicodeNoSurrogates(
+                    fdp.ConsumeIntInRange(1, max(1, get_max_filename_length(submodule_repo.working_tree_dir)))
                 )
+                new_file_path = os.path.join(submodule_repo.working_tree_dir, new_file_name)
                 with open(new_file_path, "wb") as new_file:
                     new_file.write(fdp.ConsumeBytes(fdp.ConsumeIntInRange(1, 512)))
                 submodule_repo.index.add([new_file_path])
@@ -77,14 +77,13 @@ def TestOneInput(data):
             BrokenPipeError,
         ):
             return -1
-        except (ValueError, OSError) as e:
+        except ValueError as e:
             expected_messages = [
                 "SHA is empty",
                 "Reference at",
                 "embedded null byte",
                 "This submodule instance does not exist anymore",
                 "cmd stdin was empty",
-                "File name too long",
             ]
             if is_expected_exception_message(e, expected_messages):
                 return -1
diff --git a/fuzzing/fuzz-targets/utils.py b/fuzzing/fuzz-targets/utils.py
index 42faa8eb0..86f049341 100644
--- a/fuzzing/fuzz-targets/utils.py
+++ b/fuzzing/fuzz-targets/utils.py
@@ -1,4 +1,5 @@
 import atheris  # pragma: no cover
+import os
 from typing import List  # pragma: no cover
 
 
@@ -20,3 +21,17 @@ def is_expected_exception_message(exception: Exception, error_message_list: List
         if error.lower() in exception_message:
             return True
     return False
+
+
+@atheris.instrument_func
+def get_max_filename_length(path: str) -> int:
+    """
+    Get the maximum filename length for the filesystem containing the given path.
+
+    Args:
+        path (str): The path to check the filesystem for.
+
+    Returns:
+        int: The maximum filename length.
+    """
+    return os.pathconf(path, "PC_NAME_MAX")

From 2a2294f9d1e46d9bbe11cd2031d62e5441fe19c4 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 30 May 2024 14:02:27 -0400
Subject: [PATCH 175/332] Improve `fuzz_submodule.py` coverage & efficacy

The fuzzer was having trouble analyzing `fuzz_submodule.py` when using
the `atheris.instrument_imports()` context manager. Switching to
`atheris.instrument_all()` instead slightly increases the startup time
for the fuzzer, but significantly improves the fuzzing engines ability
to identify new coverage.

The changes here also disable warnings that are logged to `stdout` from
the SUT. These warnings are expected to happen with some inputs and
clutter the fuzzer output logs. They can be optionally re-enabled for
debugging by passing a flag o the Python interpreter command line or
setting the `PYTHONWARNINGS` environment variable.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 16 +++++++++++++---
 fuzzing/fuzz-targets/utils.py          |  4 ++--
 2 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 817ce8f98..53f5a7884 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -4,13 +4,22 @@
 import tempfile
 from configparser import ParsingError
 from utils import is_expected_exception_message, get_max_filename_length
+from git import Repo, GitCommandError, InvalidGitRepositoryError
 
-if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):  # pragma: no cover
     path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
     os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
 
-with atheris.instrument_imports():
-    from git import Repo, GitCommandError, InvalidGitRepositoryError
+if not sys.warnoptions:  # pragma: no cover
+    # The warnings filter below can be overridden by passing the -W option
+    # to the Python interpreter command line or setting the `PYTHONWARNINGS` environment variable.
+    import warnings
+    import logging
+
+    # Fuzzing data causes some plugins to generate a large number of warnings
+    # which are not usually interesting and make the test output hard to read, so we ignore them.
+    warnings.simplefilter("ignore")
+    logging.getLogger().setLevel(logging.ERROR)
 
 
 def TestOneInput(data):
@@ -92,6 +101,7 @@ def TestOneInput(data):
 
 
 def main():
+    atheris.instrument_all()
     atheris.Setup(sys.argv, TestOneInput)
     atheris.Fuzz()
 
diff --git a/fuzzing/fuzz-targets/utils.py b/fuzzing/fuzz-targets/utils.py
index 86f049341..f522d2959 100644
--- a/fuzzing/fuzz-targets/utils.py
+++ b/fuzzing/fuzz-targets/utils.py
@@ -1,5 +1,5 @@
 import atheris  # pragma: no cover
-import os
+import os  # pragma: no cover
 from typing import List  # pragma: no cover
 
 
@@ -24,7 +24,7 @@ def is_expected_exception_message(exception: Exception, error_message_list: List
 
 
 @atheris.instrument_func
-def get_max_filename_length(path: str) -> int:
+def get_max_filename_length(path: str) -> int:  # pragma: no cover
     """
     Get the maximum filename length for the filesystem containing the given path.
 

From 57a56a8a2874d2ab76f4034b9d3c98e09ed7fa35 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 30 May 2024 14:12:02 -0400
Subject: [PATCH 176/332] Add graceful handling for `NotADirectoryError`s

---
 fuzzing/fuzz-targets/fuzz_submodule.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 53f5a7884..cfd1a6d3f 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -83,6 +83,7 @@ def TestOneInput(data):
             FileNotFoundError,
             FileExistsError,
             IsADirectoryError,
+            NotADirectoryError,
             BrokenPipeError,
         ):
             return -1

From 2b64dee466ed72523684f90a037d604355121df0 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 30 May 2024 14:17:20 -0400
Subject: [PATCH 177/332] Improve comment wording

---
 fuzzing/fuzz-targets/fuzz_submodule.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index cfd1a6d3f..92b569949 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -16,7 +16,7 @@
     import warnings
     import logging
 
-    # Fuzzing data causes some plugins to generate a large number of warnings
+    # Fuzzing data causes some modules to generate a large number of warnings
     # which are not usually interesting and make the test output hard to read, so we ignore them.
     warnings.simplefilter("ignore")
     logging.getLogger().setLevel(logging.ERROR)

From 882425ded5ae210c7092b87f4ea6bc871784ae89 Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Fri, 31 May 2024 07:24:47 +0200
Subject: [PATCH 178/332] Add missing newline in `prec-commit-config.yaml`

Just to be sure the coming linting won't be disturbed by that.
---
 .pre-commit-config.yaml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 02950db8c..fe966adad 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -25,4 +25,4 @@ repos:
 - repo: https://github.com/abravalheri/validate-pyproject
   rev: v0.16
   hooks:
-    - id: validate-pyproject
\ No newline at end of file
+    - id: validate-pyproject

From 59a0c88a08de4b35608d82b107844915a787f192 Mon Sep 17 00:00:00 2001
From: Andrej730 <azhilenkov@gmail.com>
Date: Mon, 3 Jun 2024 00:26:11 +0500
Subject: [PATCH 179/332] Fix IndexFile items argument type

Error before commit:

path: os.PathLike = ...
repo = git.Repo(path_dir)
repo.index.add(path)
---
 git/index/base.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/git/index/base.py b/git/index/base.py
index b8161ea52..fc4474cac 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -658,7 +658,7 @@ def _to_relative_path(self, path: PathLike) -> PathLike:
         return os.path.relpath(path, self.repo.working_tree_dir)
 
     def _preprocess_add_items(
-        self, items: Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]
+        self, items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]]
     ) -> Tuple[List[PathLike], List[BaseIndexEntry]]:
         """Split the items into two lists of path strings and BaseEntries."""
         paths = []
@@ -749,7 +749,7 @@ def _entries_for_paths(
 
     def add(
         self,
-        items: Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]],
+        items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]],
         force: bool = True,
         fprogress: Callable = lambda *args: None,
         path_rewriter: Union[Callable[..., PathLike], None] = None,
@@ -976,7 +976,7 @@ def _items_to_rela_paths(
     @default_index
     def remove(
         self,
-        items: Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]],
+        items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]],
         working_tree: bool = False,
         **kwargs: Any,
     ) -> List[str]:
@@ -1036,7 +1036,7 @@ def remove(
     @default_index
     def move(
         self,
-        items: Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]],
+        items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]],
         skip_errors: bool = False,
         **kwargs: Any,
     ) -> List[Tuple[str, str]]:

From 77fb5f06bd86a02f481a1d34ca0938bb5b7f5219 Mon Sep 17 00:00:00 2001
From: Andrej730 <azhilenkov@gmail.com>
Date: Mon, 3 Jun 2024 00:28:45 +0500
Subject: [PATCH 180/332] Specify DiffIndex generic type

Example before this commit:

repo = git.Repo(path_dir)
diff = repo.index.diff(None)
modified_files = [d for d in repo.index.diff(None)]
reveal_type(modified_files) # list[Unknown] instead of list[Diff]
---
 git/diff.py       | 8 ++++----
 git/index/base.py | 2 +-
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/git/diff.py b/git/diff.py
index f89b12d98..e9f7e209f 100644
--- a/git/diff.py
+++ b/git/diff.py
@@ -187,7 +187,7 @@ def diff(
         paths: Union[PathLike, List[PathLike], Tuple[PathLike, ...], None] = None,
         create_patch: bool = False,
         **kwargs: Any,
-    ) -> "DiffIndex":
+    ) -> "DiffIndex[Diff]":
         """Create diffs between two items being trees, trees and index or an index and
         the working tree. Detects renames automatically.
 
@@ -581,7 +581,7 @@ def _pick_best_path(cls, path_match: bytes, rename_match: bytes, path_fallback_m
         return None
 
     @classmethod
-    def _index_from_patch_format(cls, repo: "Repo", proc: Union["Popen", "Git.AutoInterrupt"]) -> DiffIndex:
+    def _index_from_patch_format(cls, repo: "Repo", proc: Union["Popen", "Git.AutoInterrupt"]) -> DiffIndex["Diff"]:
         """Create a new :class:`DiffIndex` from the given process output which must be
         in patch format.
 
@@ -674,7 +674,7 @@ def _index_from_patch_format(cls, repo: "Repo", proc: Union["Popen", "Git.AutoIn
         return index
 
     @staticmethod
-    def _handle_diff_line(lines_bytes: bytes, repo: "Repo", index: DiffIndex) -> None:
+    def _handle_diff_line(lines_bytes: bytes, repo: "Repo", index: DiffIndex["Diff"]) -> None:
         lines = lines_bytes.decode(defenc)
 
         # Discard everything before the first colon, and the colon itself.
@@ -747,7 +747,7 @@ def _handle_diff_line(lines_bytes: bytes, repo: "Repo", index: DiffIndex) -> Non
             index.append(diff)
 
     @classmethod
-    def _index_from_raw_format(cls, repo: "Repo", proc: "Popen") -> "DiffIndex":
+    def _index_from_raw_format(cls, repo: "Repo", proc: "Popen") -> "DiffIndex[Diff]":
         """Create a new :class:`DiffIndex` from the given process output which must be
         in raw format.
 
diff --git a/git/index/base.py b/git/index/base.py
index fc4474cac..28b60a880 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -1478,7 +1478,7 @@ def diff(
         paths: Union[PathLike, List[PathLike], Tuple[PathLike, ...], None] = None,
         create_patch: bool = False,
         **kwargs: Any,
-    ) -> git_diff.DiffIndex:
+    ) -> git_diff.DiffIndex[git_diff.Diff]:
         """Diff this index against the working copy or a :class:`~git.objects.tree.Tree`
         or :class:`~git.objects.commit.Commit` object.
 

From 491e134d2a930d12cc4250951e9e986dbab2be2d Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 4 Jun 2024 06:58:07 -0400
Subject: [PATCH 181/332] Fix Improper Import Order Breaking `fuzz_submodule`
 Fuzzer

ClusterFuzz runs of the `fuzz_submodule` target have been failing
because the `git` import was placed before the condition that sets the
Git executable path.

The order in which `git` is imported matters because it attempts to find
a Git executable as the import is loaded (via `refresh()` in
`git/__init__.py`.) As per #1909, we configure the ClusterFuzz
environment to use a bundled Git executable via the env variable
condition in all fuzz targets.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 92b569949..ca47690ea 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -4,12 +4,13 @@
 import tempfile
 from configparser import ParsingError
 from utils import is_expected_exception_message, get_max_filename_length
-from git import Repo, GitCommandError, InvalidGitRepositoryError
 
 if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):  # pragma: no cover
     path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
     os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
 
+from git import Repo, GitCommandError, InvalidGitRepositoryError
+
 if not sys.warnoptions:  # pragma: no cover
     # The warnings filter below can be overridden by passing the -W option
     # to the Python interpreter command line or setting the `PYTHONWARNINGS` environment variable.

From d59708812f50362f526d4c6aa67b7218d12024f4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= <kamilzary@gmail.com>
Date: Sat, 8 Jun 2024 01:23:21 +0200
Subject: [PATCH 182/332] Add deprecation test for DiffIndex.iter_change_type

---
 test/deprecation/test_basic.py | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/test/deprecation/test_basic.py b/test/deprecation/test_basic.py
index 6235a836c..3bf0287c7 100644
--- a/test/deprecation/test_basic.py
+++ b/test/deprecation/test_basic.py
@@ -31,7 +31,7 @@
 if TYPE_CHECKING:
     from pathlib import Path
 
-    from git.diff import Diff
+    from git.diff import Diff, DiffIndex
     from git.objects.commit import Commit
 
 # ------------------------------------------------------------------------
@@ -54,6 +54,12 @@ def diff(commit: "Commit") -> Generator["Diff", None, None]:
     yield diff
 
 
+@pytest.fixture
+def diffs(commit: "Commit") -> Generator["DiffIndex", None, None]:
+    """Fixture to supply a DiffIndex."""
+    yield commit.diff(NULL_TREE)
+
+
 def test_diff_renamed_warns(diff: "Diff") -> None:
     """The deprecated Diff.renamed property issues a deprecation warning."""
     with pytest.deprecated_call():
@@ -122,3 +128,10 @@ def test_iterable_obj_inheriting_does_not_warn() -> None:
 
         class Derived(IterableObj):
             pass
+
+
+def test_diff_iter_change_type(diffs: "DiffIndex") -> None:
+    """The internal DiffIndex.iter_change_type function issues no deprecation warning."""
+    with assert_no_deprecation_warning():
+        for change_type in diffs.change_type:
+            [*diffs.iter_change_type(change_type=change_type)]

From e1c660d224a1d27469c9275940c9db841570b8d0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?=
 <34622465+kamilkrzyskow@users.noreply.github.com>
Date: Tue, 28 May 2024 05:53:44 +0200
Subject: [PATCH 183/332] Fix iter_change_type diff renamed property to prevent
 warning

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

diff --git a/git/diff.py b/git/diff.py
index e9f7e209f..8d2646f99 100644
--- a/git/diff.py
+++ b/git/diff.py
@@ -325,7 +325,7 @@ def iter_change_type(self, change_type: Lit_change_type) -> Iterator[T_Diff]:
                 yield diffidx
             elif change_type == "C" and diffidx.copied_file:
                 yield diffidx
-            elif change_type == "R" and diffidx.renamed:
+            elif change_type == "R" and diffidx.renamed_file:
                 yield diffidx
             elif change_type == "M" and diffidx.a_blob and diffidx.b_blob and diffidx.a_blob != diffidx.b_blob:
                 yield diffidx

From f1ec1f15ec13e369bb5a4d758e94d7877e481ed3 Mon Sep 17 00:00:00 2001
From: Nick Papior <nickpapior@gmail.com>
Date: Thu, 13 Jun 2024 14:35:03 +0200
Subject: [PATCH 184/332] fixed doc to not faulty do #1924

Signed-off-by: Nick Papior <nickpapior@gmail.com>
---
 test/test_docs.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/test/test_docs.py b/test/test_docs.py
index b3547c1de..cc0bbf26a 100644
--- a/test/test_docs.py
+++ b/test/test_docs.py
@@ -469,11 +469,11 @@ def test_references_and_objects(self, rw_dir):
         # ![30-test_references_and_objects]
 
         # [31-test_references_and_objects]
-        git = repo.git
-        git.checkout("HEAD", b="my_new_branch")  # Create a new branch.
-        git.branch("another-new-one")
-        git.branch("-D", "another-new-one")  # Pass strings for full control over argument order.
-        git.for_each_ref()  # '-' becomes '_' when calling it.
+        git_cmd = repo.git
+        git_cmd.checkout("HEAD", b="my_new_branch")  # Create a new branch.
+        git_cmd.branch("another-new-one")
+        git_cmd.branch("-D", "another-new-one")  # Pass strings for full control over argument order.
+        git_cmd.for_each_ref()  # '-' becomes '_' when calling it.
         # ![31-test_references_and_objects]
 
         repo.git.clear_cache()

From d35998f5f420db780a30d73b703296498e3aa531 Mon Sep 17 00:00:00 2001
From: Guillaume Cardoen <G.Cardoen@outlook.com>
Date: Mon, 17 Jun 2024 09:28:32 +0200
Subject: [PATCH 185/332] fix: fix beginning whitespace error

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

diff --git a/git/diff.py b/git/diff.py
index 8d2646f99..9c6ae59e0 100644
--- a/git/diff.py
+++ b/git/diff.py
@@ -695,7 +695,7 @@ def _handle_diff_line(lines_bytes: bytes, repo: "Repo", index: DiffIndex["Diff"]
             change_type: Lit_change_type = cast(Lit_change_type, _change_type[0])
             score_str = "".join(_change_type[1:])
             score = int(score_str) if score_str.isdigit() else None
-            path = path.strip()
+            path = path.strip("\n")
             a_path = path.encode(defenc)
             b_path = path.encode(defenc)
             deleted_file = False

From 9910a886ddeb05a39b774e9f3520837fd9a76dca Mon Sep 17 00:00:00 2001
From: Guillaume Cardoen <G.Cardoen@outlook.com>
Date: Mon, 17 Jun 2024 09:31:51 +0200
Subject: [PATCH 186/332] test: add test for diff with beginning whitespace

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

diff --git a/test/test_diff.py b/test/test_diff.py
index 928a9f428..6cae3fbf2 100644
--- a/test/test_diff.py
+++ b/test/test_diff.py
@@ -529,3 +529,23 @@ def test_diff_patch_with_external_engine(self, rw_dir):
         self.assertEqual(len(index_against_head), 1)
         index_against_working_tree = repo.index.diff(None, create_patch=True)
         self.assertEqual(len(index_against_working_tree), 1)
+
+    @with_rw_directory
+    def test_beginning_space(self, rw_dir):
+        # Create a file beginning by a whitespace
+        repo = Repo.init(rw_dir)
+        file = osp.join(rw_dir, " file.txt")
+        with open(file, "w") as f:
+            f.write("hello world")
+        repo.git.add(Git.polish_url(file))
+        repo.index.commit("first commit")
+        
+        # Diff the commit with an empty tree
+        # and check the paths
+        diff_index = repo.head.commit.diff(NULL_TREE)
+        d = diff_index[0]
+        a_path = d.a_path
+        b_path = d.b_path
+        self.assertEqual(a_path, " file.txt")
+        self.assertEqual(b_path, " file.txt")
+    
\ No newline at end of file

From 97fad9cb8322e510647cf58cc023702a7b7e077f Mon Sep 17 00:00:00 2001
From: Guillaume Cardoen <G.Cardoen@outlook.com>
Date: Tue, 18 Jun 2024 08:51:00 +0200
Subject: [PATCH 187/332] style: ruff

---
 test/test_diff.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/test/test_diff.py b/test/test_diff.py
index 6cae3fbf2..612fbd9e0 100644
--- a/test/test_diff.py
+++ b/test/test_diff.py
@@ -539,7 +539,7 @@ def test_beginning_space(self, rw_dir):
             f.write("hello world")
         repo.git.add(Git.polish_url(file))
         repo.index.commit("first commit")
-        
+
         # Diff the commit with an empty tree
         # and check the paths
         diff_index = repo.head.commit.diff(NULL_TREE)
@@ -548,4 +548,3 @@ def test_beginning_space(self, rw_dir):
         b_path = d.b_path
         self.assertEqual(a_path, " file.txt")
         self.assertEqual(b_path, " file.txt")
-    
\ No newline at end of file

From f96eb0cdaeb6e33bf7725e1fb0385509f6030969 Mon Sep 17 00:00:00 2001
From: Patrick Massot <patrickmassot@free.fr>
Date: Mon, 24 Jun 2024 14:02:53 -0400
Subject: [PATCH 188/332] Change aliases to work around mypy issue.

Fixes #1934
Note this should also gives better LSP support to these property aliases.
---
 git/remote.py    | 11 +++++++++--
 git/repo/base.py | 24 ++++++++++++++++++++----
 2 files changed, 29 insertions(+), 6 deletions(-)

diff --git a/git/remote.py b/git/remote.py
index 37c991d27..15e360064 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -828,8 +828,15 @@ def remove(cls, repo: "Repo", name: str) -> str:
             name._clear_cache()
         return name
 
-    # `rm` is an alias.
-    rm = remove
+    @classmethod
+    def rm(cls, repo: "Repo", name: str) -> str:
+        """Alias of remove.
+        Remove the remote with the given name.
+
+        :return:
+            The passed remote name to remove
+        """
+        return cls.remove(repo, name)
 
     def rename(self, new_name: str) -> "Remote":
         """Rename self to the given `new_name`.
diff --git a/git/repo/base.py b/git/repo/base.py
index 51ea76901..346248ddb 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -402,6 +402,17 @@ def heads(self) -> "IterableList[Head]":
         """
         return Head.list_items(self)
 
+    @property
+    def branches(self) -> "IterableList[Head]":
+        """Alias for heads.
+        A list of :class:`~git.refs.head.Head` objects representing the branch heads
+        in this repo.
+
+        :return:
+            ``git.IterableList(Head, ...)``
+        """
+        return self.heads
+
     @property
     def references(self) -> "IterableList[Reference]":
         """A list of :class:`~git.refs.reference.Reference` objects representing tags,
@@ -412,11 +423,16 @@ def references(self) -> "IterableList[Reference]":
         """
         return Reference.list_items(self)
 
-    # Alias for references.
-    refs = references
+    @property
+    def refs(self) -> "IterableList[Reference]":
+        """Alias for references.
+        A list of :class:`~git.refs.reference.Reference` objects representing tags,
+        heads and remote references.
 
-    # Alias for heads.
-    branches = heads
+        :return:
+            ``git.IterableList(Reference, ...)``
+        """
+        return self.references
 
     @property
     def index(self) -> "IndexFile":

From 366a60760cea066b40ed33815fa8256b25afdfcc Mon Sep 17 00:00:00 2001
From: jirka <jirka.borovec@seznam.cz>
Date: Tue, 16 Jul 2024 12:35:36 +0200
Subject: [PATCH 189/332] exclude: test/fixtures/

---
 .pre-commit-config.yaml                   | 1 +
 test/fixtures/.gitconfig                  | 2 +-
 test/fixtures/blame                       | 2 +-
 test/fixtures/cat_file_blob               | 2 +-
 test/fixtures/git_config                  | 1 +
 test/fixtures/git_config_with_empty_value | 2 +-
 test/fixtures/rev_list_bisect_all         | 1 +
 test/fixtures/rev_list_commit_diffs       | 1 +
 test/fixtures/rev_list_commit_idabbrev    | 1 +
 test/fixtures/rev_list_commit_stats       | 1 +
 10 files changed, 10 insertions(+), 4 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 50f430084..5491c4297 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -19,6 +19,7 @@ repos:
   rev: v4.5.0
   hooks:
   - id: end-of-file-fixer
+    exclude: test/fixtures/
   - id: check-toml
   - id: check-yaml
   - id: check-merge-conflict
diff --git a/test/fixtures/.gitconfig b/test/fixtures/.gitconfig
index f6c25c15a..6a0459f6b 100644
--- a/test/fixtures/.gitconfig
+++ b/test/fixtures/.gitconfig
@@ -1,3 +1,3 @@
 [alias]
     rbi = "!g() { git rebase -i origin/${1:-master} ; } ; g"
-    expush = "!f() { git branch -f tmp ; { git rbi $1 && git push ; } ; git reset --hard tmp ; git rebase origin/${1:-master}; } ; f"
+    expush = "!f() { git branch -f tmp ; { git rbi $1 && git push ; } ; git reset --hard tmp ; git rebase origin/${1:-master}; } ; f"
\ No newline at end of file
diff --git a/test/fixtures/blame b/test/fixtures/blame
index 949976c5d..10c141dda 100644
--- a/test/fixtures/blame
+++ b/test/fixtures/blame
@@ -128,4 +128,4 @@ b6e1b765e0c15586a2c5b9832854f95defd71e1f 23 23
 634396b2f541a9f2d58b00be1a07f0c358b999b3 11 24 2
 	  VERSION = '1.0.0'
 634396b2f541a9f2d58b00be1a07f0c358b999b3 12 25
-	end
+	end
\ No newline at end of file
diff --git a/test/fixtures/cat_file_blob b/test/fixtures/cat_file_blob
index 802992c42..70c379b63 100644
--- a/test/fixtures/cat_file_blob
+++ b/test/fixtures/cat_file_blob
@@ -1 +1 @@
-Hello world
+Hello world
\ No newline at end of file
diff --git a/test/fixtures/git_config b/test/fixtures/git_config
index d3066d86e..a8cad56e8 100644
--- a/test/fixtures/git_config
+++ b/test/fixtures/git_config
@@ -43,3 +43,4 @@
 # inclusions should be processed immediately
 [sec]
     var1 = value1_main
+
diff --git a/test/fixtures/git_config_with_empty_value b/test/fixtures/git_config_with_empty_value
index 83de84c8b..0427caea5 100644
--- a/test/fixtures/git_config_with_empty_value
+++ b/test/fixtures/git_config_with_empty_value
@@ -1,4 +1,4 @@
 [color]
 	ui
 [core]
-	filemode = true
+	filemode = true
\ No newline at end of file
diff --git a/test/fixtures/rev_list_bisect_all b/test/fixtures/rev_list_bisect_all
index 60d382d01..342ea94ae 100644
--- a/test/fixtures/rev_list_bisect_all
+++ b/test/fixtures/rev_list_bisect_all
@@ -48,3 +48,4 @@ committer David Aguilar <davvid@gmail.com> 1220418344 -0700
     This resolves the issue mentioned in that thread.
     
     Signed-off-by: David Aguilar <davvid@gmail.com>
+
diff --git a/test/fixtures/rev_list_commit_diffs b/test/fixtures/rev_list_commit_diffs
index c39df2061..20397e2e4 100644
--- a/test/fixtures/rev_list_commit_diffs
+++ b/test/fixtures/rev_list_commit_diffs
@@ -5,3 +5,4 @@ author Tom Preston-Werner <tom@mojombo.com> 1193200199 -0700
 committer Tom Preston-Werner <tom@mojombo.com> 1193200199 -0700
 
     fix some initialization warnings
+
diff --git a/test/fixtures/rev_list_commit_idabbrev b/test/fixtures/rev_list_commit_idabbrev
index 6266df93e..9385ba713 100644
--- a/test/fixtures/rev_list_commit_idabbrev
+++ b/test/fixtures/rev_list_commit_idabbrev
@@ -5,3 +5,4 @@ author tom <tom@taco.(none)> 1195608462 -0800
 committer tom <tom@taco.(none)> 1195608462 -0800
 
     fix tests on other machines
+
diff --git a/test/fixtures/rev_list_commit_stats b/test/fixtures/rev_list_commit_stats
index c78aadeb5..60aa8cf58 100644
--- a/test/fixtures/rev_list_commit_stats
+++ b/test/fixtures/rev_list_commit_stats
@@ -4,3 +4,4 @@ author Tom Preston-Werner <tom@mojombo.com> 1191997100 -0700
 committer Tom Preston-Werner <tom@mojombo.com> 1191997100 -0700
 
     initial grit setup
+

From 1c88b0a734142cfc05114ad2ca0794c565294fb9 Mon Sep 17 00:00:00 2001
From: jirka <jirka.borovec@seznam.cz>
Date: Tue, 7 May 2024 19:32:10 +0200
Subject: [PATCH 190/332] use codespell

---
 .pre-commit-config.yaml | 6 ++++++
 pyproject.toml          | 5 +++++
 2 files changed, 11 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 551d8be34..23272bc25 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,4 +1,10 @@
 repos:
+- repo: https://github.com/codespell-project/codespell
+  rev: v2.2.4
+  hooks:
+  - id: codespell
+    additional_dependencies: [tomli]
+
 - repo: https://github.com/astral-sh/ruff-pre-commit
   rev: v0.4.3
   hooks:
diff --git a/pyproject.toml b/pyproject.toml
index ee54edb78..7fc809a6d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,3 +78,8 @@ lint.unfixable = [
 "test/**" = [
     "B018",  # useless-expression
 ]
+
+[tool.codespell]
+#skip = '*.po,*.ts,./src/3rdParty,./src/Test'
+#count = true
+quiet-level = 3
\ No newline at end of file

From 2ce013cc0043f7968f126ea38482a32077efa991 Mon Sep 17 00:00:00 2001
From: Jirka <jirka.borovec@seznam.cz>
Date: Tue, 7 May 2024 19:38:44 +0200
Subject: [PATCH 191/332] fix & skip

---
 .pre-commit-config.yaml | 1 +
 git/index/base.py       | 2 +-
 git/remote.py           | 2 +-
 pyproject.toml          | 3 ++-
 4 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 23272bc25..03730febd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,6 +4,7 @@ repos:
   hooks:
   - id: codespell
     additional_dependencies: [tomli]
+    args: ["--write-changes"]
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
   rev: v0.4.3
diff --git a/git/index/base.py b/git/index/base.py
index 28b60a880..a317e71c0 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -1443,7 +1443,7 @@ def reset(
                     key = entry_key(path, 0)
                     self.entries[key] = nie[key]
                 except KeyError:
-                    # If key is not in theirs, it musn't be in ours.
+                    # If key is not in theirs, it mustn't be in ours.
                     try:
                         del self.entries[key]
                     except KeyError:
diff --git a/git/remote.py b/git/remote.py
index 15e360064..1e09e210e 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -250,7 +250,7 @@ def _from_line(cls, remote: "Remote", line: str) -> "PushInfo":
                 flags |= cls.NEW_TAG
             elif "[new branch]" in summary:
                 flags |= cls.NEW_HEAD
-            # uptodate encoded in control character
+            # up-to-date encoded in control character
         else:
             # Fast-forward or forced update - was encoded in control character,
             # but we parse the old and new commit.
diff --git a/pyproject.toml b/pyproject.toml
index 7fc809a6d..8b4522824 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,6 +80,7 @@ lint.unfixable = [
 ]
 
 [tool.codespell]
-#skip = '*.po,*.ts,./src/3rdParty,./src/Test'
+skip = 'test/fixtures/reflog_*'
+ignore-words-list="gud,doesnt"
 #count = true
 quiet-level = 3
\ No newline at end of file

From 93993b201458fd18059e97fa25a08a14fae2af1f Mon Sep 17 00:00:00 2001
From: jirka <jirka.borovec@seznam.cz>
Date: Wed, 17 Jul 2024 12:31:02 +0200
Subject: [PATCH 192/332] fixing

---
 .pre-commit-config.yaml | 3 ++-
 README.md               | 2 +-
 doc/source/changes.rst  | 2 +-
 git/objects/util.py     | 6 +++---
 pyproject.toml          | 3 +--
 test/test_exc.py        | 2 +-
 test/test_index.py      | 6 +++---
 7 files changed, 12 insertions(+), 12 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 03730febd..692c7fa2a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,7 +4,8 @@ repos:
   hooks:
   - id: codespell
     additional_dependencies: [tomli]
-    args: ["--write-changes"]
+    # args: ["--write-changes"]  # consider enabling for auto-fif
+    exclude: "test/fixtures/"
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
   rev: v0.4.3
diff --git a/README.md b/README.md
index d365a6584..59c6f995b 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ In the less common case that you do not want to install test dependencies, `pip
 
 #### With editable *dependencies* (not preferred, and rarely needed)
 
-In rare cases, you may want to work on GitPython and one or both of its [gitdb](https://github.com/gitpython-developers/gitdb) and [smmap](https://github.com/gitpython-developers/smmap) dependencies at the same time, with changes in your local working copy of gitdb or smmap immediatley reflected in the behavior of your local working copy of GitPython. This can be done by making editable installations of those dependencies in the same virtual environment where you install GitPython.
+In rare cases, you may want to work on GitPython and one or both of its [gitdb](https://github.com/gitpython-developers/gitdb) and [smmap](https://github.com/gitpython-developers/smmap) dependencies at the same time, with changes in your local working copy of gitdb or smmap immediately reflected in the behavior of your local working copy of GitPython. This can be done by making editable installations of those dependencies in the same virtual environment where you install GitPython.
 
 If you want to do that *and* you want the versions in GitPython's git submodules to be used, then pass `-e git/ext/gitdb` and/or `-e git/ext/gitdb/gitdb/ext/smmap` to `pip install`. This can be done in any order, and in separate `pip install` commands or the same one, so long as `-e` appears before *each* path. For example, you can install GitPython, gitdb, and smmap editably in the currently active virtual environment this way:
 
diff --git a/doc/source/changes.rst b/doc/source/changes.rst
index 0bc757134..3c903423c 100644
--- a/doc/source/changes.rst
+++ b/doc/source/changes.rst
@@ -20,7 +20,7 @@ https://github.com/gitpython-developers/GitPython/releases/tag/3.1.42
 3.1.41
 ======
 
-This release is relevant for security as it fixes a possible arbitary
+This release is relevant for security as it fixes a possible arbitrary
 code execution on Windows.
 
 See this PR for details: https://github.com/gitpython-developers/GitPython/pull/1792
diff --git a/git/objects/util.py b/git/objects/util.py
index 5c56e6134..a68d701f5 100644
--- a/git/objects/util.py
+++ b/git/objects/util.py
@@ -568,11 +568,11 @@ def addToStack(
                 yield rval
 
             # Only continue to next level if this is appropriate!
-            nd = d + 1
-            if depth > -1 and nd > depth:
+            next_d = d + 1
+            if depth > -1 and next_d > depth:
                 continue
 
-            addToStack(stack, item, branch_first, nd)
+            addToStack(stack, item, branch_first, next_d)
         # END for each item on work stack
 
 
diff --git a/pyproject.toml b/pyproject.toml
index 8b4522824..603e2597c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,7 +80,6 @@ lint.unfixable = [
 ]
 
 [tool.codespell]
-skip = 'test/fixtures/reflog_*'
 ignore-words-list="gud,doesnt"
 #count = true
-quiet-level = 3
\ No newline at end of file
+quiet-level = 3
diff --git a/test/test_exc.py b/test/test_exc.py
index c1eae7240..2e979f5a1 100644
--- a/test/test_exc.py
+++ b/test/test_exc.py
@@ -52,7 +52,7 @@
 
 _streams_n_substrings = (
     None,
-    "steram",
+    "stream",
     "ομορφο stream",
 )
 
diff --git a/test/test_index.py b/test/test_index.py
index b92258c92..2684cfd81 100644
--- a/test/test_index.py
+++ b/test/test_index.py
@@ -1018,7 +1018,7 @@ class Mocked:
     @pytest.mark.xfail(
         type(_win_bash_status) is WinBashStatus.Absent,
         reason="Can't run a hook on Windows without bash.exe.",
-        rasies=HookExecutionError,
+        raises=HookExecutionError,
     )
     @pytest.mark.xfail(
         type(_win_bash_status) is WinBashStatus.WslNoDistro,
@@ -1077,7 +1077,7 @@ def test_hook_uses_shell_not_from_cwd(self, rw_dir, case):
     @pytest.mark.xfail(
         type(_win_bash_status) is WinBashStatus.Absent,
         reason="Can't run a hook on Windows without bash.exe.",
-        rasies=HookExecutionError,
+        raises=HookExecutionError,
     )
     @pytest.mark.xfail(
         type(_win_bash_status) is WinBashStatus.WslNoDistro,
@@ -1120,7 +1120,7 @@ def test_pre_commit_hook_fail(self, rw_repo):
     @pytest.mark.xfail(
         type(_win_bash_status) is WinBashStatus.Absent,
         reason="Can't run a hook on Windows without bash.exe.",
-        rasies=HookExecutionError,
+        raises=HookExecutionError,
     )
     @pytest.mark.xfail(
         type(_win_bash_status) is WinBashStatus.Wsl,

From 813520c123d44a8cf87a219fc710faeb1f1559ca Mon Sep 17 00:00:00 2001
From: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Date: Wed, 17 Jul 2024 12:34:14 +0200
Subject: [PATCH 193/332] Apply suggestions from code review

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

diff --git a/git/remote.py b/git/remote.py
index 1e09e210e..9de3dace4 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -250,7 +250,7 @@ def _from_line(cls, remote: "Remote", line: str) -> "PushInfo":
                 flags |= cls.NEW_TAG
             elif "[new branch]" in summary:
                 flags |= cls.NEW_HEAD
-            # up-to-date encoded in control character
+            # `uptodate` encoded in control character
         else:
             # Fast-forward or forced update - was encoded in control character,
             # but we parse the old and new commit.

From ce8a69a4141d2149bac2cbf56ea7d4b1f2ed7257 Mon Sep 17 00:00:00 2001
From: Jonas Scharpf <jonas.scharpf@checkmk.com>
Date: Wed, 17 Jul 2024 11:01:09 +0200
Subject: [PATCH 194/332] Add type of change to files_dict of a commit

This allows to not only get the total, inserted or deleted number of
lines being changed but also the type of change like
Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R),
type changed (T), Unmerged (U), Unknown (X), or pairing Broken (B)
---
 AUTHORS                    |  1 +
 git/objects/commit.py      | 24 +++++++++++++++++-------
 git/types.py               |  1 +
 git/util.py                |  4 +++-
 test/fixtures/diff_numstat |  5 +++--
 test/test_commit.py        |  9 ++++++---
 test/test_stats.py         | 12 +++++++++---
 7 files changed, 40 insertions(+), 16 deletions(-)

diff --git a/AUTHORS b/AUTHORS
index 9311b3962..45b14c961 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -54,5 +54,6 @@ Contributors are:
 -Wenhan Zhu <wzhu.cosmos _at_ gmail.com>
 -Eliah Kagan <eliah.kagan _at_ gmail.com>
 -Ethan Lin <et.repositories _at_ gmail.com>
+-Jonas Scharpf <jonas.scharpf _at_ checkmk.com>
 
 Portions derived from other open source works and are clearly marked.
diff --git a/git/objects/commit.py b/git/objects/commit.py
index d957c9051..0ceb46609 100644
--- a/git/objects/commit.py
+++ b/git/objects/commit.py
@@ -377,15 +377,25 @@ def stats(self) -> Stats:
         :return:
             :class:`Stats`
         """
-        if not self.parents:
-            text = self.repo.git.diff_tree(self.hexsha, "--", numstat=True, no_renames=True, root=True)
-            text2 = ""
-            for line in text.splitlines()[1:]:
+
+        def process_lines(lines: List[str]) -> str:
+            text = ""
+            for file_info, line in zip(lines, lines[len(lines) // 2 :]):
+                change_type = file_info.split("\t")[0][-1]
                 (insertions, deletions, filename) = line.split("\t")
-                text2 += "%s\t%s\t%s\n" % (insertions, deletions, filename)
-            text = text2
+                text += "%s\t%s\t%s\t%s\n" % (change_type, insertions, deletions, filename)
+            return text
+
+        if not self.parents:
+            lines = self.repo.git.diff_tree(
+                self.hexsha, "--", numstat=True, no_renames=True, root=True, raw=True
+            ).splitlines()[1:]
+            text = process_lines(lines)
         else:
-            text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True)
+            lines = self.repo.git.diff(
+                self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True, raw=True
+            ).splitlines()
+            text = process_lines(lines)
         return Stats._list_from_string(self.repo, text)
 
     @property
diff --git a/git/types.py b/git/types.py
index 584450146..cce184530 100644
--- a/git/types.py
+++ b/git/types.py
@@ -248,6 +248,7 @@ class Files_TD(TypedDict):
     insertions: int
     deletions: int
     lines: int
+    change_type: str
 
 
 class Total_TD(TypedDict):
diff --git a/git/util.py b/git/util.py
index 11f963e02..9e8ac821d 100644
--- a/git/util.py
+++ b/git/util.py
@@ -910,6 +910,7 @@ class Stats:
       deletions = number of deleted lines as int
       insertions = number of inserted lines as int
       lines = total number of lines changed as int, or deletions + insertions
+      change_type = type of change as str, A|C|D|M|R|T|U|X|B
 
     ``full-stat-dict``
 
@@ -938,7 +939,7 @@ def _list_from_string(cls, repo: "Repo", text: str) -> "Stats":
             "files": {},
         }
         for line in text.splitlines():
-            (raw_insertions, raw_deletions, filename) = line.split("\t")
+            (change_type, raw_insertions, raw_deletions, filename) = line.split("\t")
             insertions = raw_insertions != "-" and int(raw_insertions) or 0
             deletions = raw_deletions != "-" and int(raw_deletions) or 0
             hsh["total"]["insertions"] += insertions
@@ -949,6 +950,7 @@ def _list_from_string(cls, repo: "Repo", text: str) -> "Stats":
                 "insertions": insertions,
                 "deletions": deletions,
                 "lines": insertions + deletions,
+                "change_type": change_type,
             }
             hsh["files"][filename.strip()] = files_dict
         return Stats(hsh["total"], hsh["files"])
diff --git a/test/fixtures/diff_numstat b/test/fixtures/diff_numstat
index 44c6ca2d5..b76e467eb 100644
--- a/test/fixtures/diff_numstat
+++ b/test/fixtures/diff_numstat
@@ -1,2 +1,3 @@
-29	18	a.txt
-0	5	b.txt
+M	29	18	a.txt
+M	0	5	b.txt
+A	7	0	c.txt
\ No newline at end of file
diff --git a/test/test_commit.py b/test/test_commit.py
index 5832258de..37c66e3e7 100644
--- a/test/test_commit.py
+++ b/test/test_commit.py
@@ -135,9 +135,12 @@ def test_stats(self):
         commit = self.rorepo.commit("33ebe7acec14b25c5f84f35a664803fcab2f7781")
         stats = commit.stats
 
-        def check_entries(d):
+        def check_entries(d, has_change_type=False):
             assert isinstance(d, dict)
-            for key in ("insertions", "deletions", "lines"):
+            keys = ("insertions", "deletions", "lines")
+            if has_change_type:
+                keys += ("change_type",)
+            for key in keys:
                 assert key in d
 
         # END assertion helper
@@ -148,7 +151,7 @@ def check_entries(d):
         assert "files" in stats.total
 
         for _filepath, d in stats.files.items():
-            check_entries(d)
+            check_entries(d, True)
         # END for each stated file
 
         # Check that data is parsed properly.
diff --git a/test/test_stats.py b/test/test_stats.py
index eec73c802..91d2cf6ae 100644
--- a/test/test_stats.py
+++ b/test/test_stats.py
@@ -14,13 +14,19 @@ def test_list_from_string(self):
         output = fixture("diff_numstat").decode(defenc)
         stats = Stats._list_from_string(self.rorepo, output)
 
-        self.assertEqual(2, stats.total["files"])
-        self.assertEqual(52, stats.total["lines"])
-        self.assertEqual(29, stats.total["insertions"])
+        self.assertEqual(3, stats.total["files"])
+        self.assertEqual(59, stats.total["lines"])
+        self.assertEqual(36, stats.total["insertions"])
         self.assertEqual(23, stats.total["deletions"])
 
         self.assertEqual(29, stats.files["a.txt"]["insertions"])
         self.assertEqual(18, stats.files["a.txt"]["deletions"])
+        self.assertEqual("M", stats.files["a.txt"]["change_type"])
 
         self.assertEqual(0, stats.files["b.txt"]["insertions"])
         self.assertEqual(5, stats.files["b.txt"]["deletions"])
+        self.assertEqual("M", stats.files["b.txt"]["change_type"])
+
+        self.assertEqual(7, stats.files["c.txt"]["insertions"])
+        self.assertEqual(0, stats.files["c.txt"]["deletions"])
+        self.assertEqual("A", stats.files["c.txt"]["change_type"])

From 58a9a58f58e6aae220efda8ce95bf4c2e0fd9ca0 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 24 Jul 2024 02:10:57 -0400
Subject: [PATCH 195/332] Use Alpine Linux in WSL on CI

Some of the CI tests use WSL. This switches the WSL distribution
from Debian to Alpine, which might be slightly faster. For the way
it is being used here, the main expected speed improvement would be
to how long the image would take to download, as Alpine is smaller.

(The reason for this is thus unrelated to the reason for the Alpine
docker CI test job added in #1826. There, the goal was to test on a
wider variety of systems and environments, and that runs the whole
test suite in Alpine. This just changes the WSL distro, used by a
few tests on Windows, from Debian to Alpine.)

Two things have changed that, taken together, have unblocked this:

- https://github.com/Vampire/setup-wsl/issues/50 was fixed, so the
  action we are using is able to install Alpine Linux. See:
  https://github.com/gitpython-developers/GitPython/pull/1917#pullrequestreview-2081550232

- #1893 was fixed in #1888. So if switching the WSL distro from
  Debian to Alpine breaks any tests, including by making them fail
  in an unexpected way that raises the wrong exception, we are
  likely to find out.
---
 .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 031b0e6b2..61ab2206c 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -46,7 +46,7 @@ jobs:
       if: startsWith(matrix.os, 'windows')
       uses: Vampire/setup-wsl@v3.1.1
       with:
-        distribution: Debian
+        distribution: Alpine
 
     - name: Prepare this repo for tests
       run: |

From ce5eefd90b6c652083ce615583b5ee62b39ae187 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 24 Jul 2024 02:39:03 -0400
Subject: [PATCH 196/332] Enable Python 3.8 and 3.9 on M1 runners

These were excluded in 9ad28c3 (#1817) due to
https://github.com/actions/setup-python/issues/808, which was later
fixed by https://github.com/actions/python-versions/pull/259.

Because Python 3.7 has been end-of-life for a while, it is very
unlikely to have AArch64 builds added in python-versions for use
on GitHub Actions CI runners (preinstalled or via setup-python).
---
 .github/workflows/pythonpackage.yml | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 031b0e6b2..f3c837742 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -18,10 +18,6 @@ jobs:
         exclude:
         - os: "macos-14"
           python-version: "3.7"
-        - os: "macos-14"
-          python-version: "3.8"
-        - os: "macos-14"
-          python-version: "3.9"
         include:
         - experimental: false
 

From 055394a548d19dded2ad9791a208bbcc54879b14 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 24 Jul 2024 03:25:31 -0400
Subject: [PATCH 197/332] Install bash in WSL Alpine distro

Because Alpine Linux does not ship with bash, and the tests that
use WSL use it.
---
 .github/workflows/pythonpackage.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 61ab2206c..1902ecb19 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -47,6 +47,7 @@ jobs:
       uses: Vampire/setup-wsl@v3.1.1
       with:
         distribution: Alpine
+        additional-packages: bash
 
     - name: Prepare this repo for tests
       run: |

From c2bbaf47e14dac5f0470938b3ecf67836ca1695d Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 24 Jul 2024 04:34:00 -0400
Subject: [PATCH 198/332] Remove the non-ARM macOS CI jobs

This keeps only the macos-14 jobs, which run on Apple Silicon M1,
and removes the macos-13 jobs, which ran on x86-64.

Other operating systems jobs continue to run on x86-64 machines
(and none on ARM, yet). Only the macOS jobs are removed.

This change leaves Python 3.7 without any macOS test job. That is
probably okay, since it has been end-of-life for some time, and
it remains tested on Ubuntu and Windows.
---
 .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 49f6c5254..7547aecf9 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -13,7 +13,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        os: ["ubuntu-latest", "macos-13", "macos-14", "windows-latest"]
+        os: ["ubuntu-latest", "macos-14", "windows-latest"]
         python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
         exclude:
         - os: "macos-14"

From be6744b6e4365fc42996a1be7f026f133f992928 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 24 Jul 2024 04:38:06 -0400
Subject: [PATCH 199/332] Use the macos-latest label rather than macos-14

Currently they are the same. The macos-latest label will move to
later versions automatically in the future, like the ubuntu-latest
and windows-latest labels that we are already using.

In this repo, the macos-14 label had been used originally because
it was added before the migration of macos-latest to be macos-14
was completed. See https://github.com/github/roadmap/issues/926. It
was kept for clarity of constrast with the macos-13 jobs that were
also in use, some for the same Python versions.

Now that the macos-13 jobs have been removed in c2bbaf4, the
macos-latest label can be used here without confusion.
---
 .github/workflows/pythonpackage.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 7547aecf9..0f1d17544 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -13,10 +13,10 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        os: ["ubuntu-latest", "macos-14", "windows-latest"]
+        os: ["ubuntu-latest", "macos-latest", "windows-latest"]
         python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
         exclude:
-        - os: "macos-14"
+        - os: "macos-latest"
           python-version: "3.7"
         include:
         - experimental: false

From af0cd933e84b9f83210c0f12f95a456606ee79e9 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 6 Jun 2024 02:17:25 -0400
Subject: [PATCH 200/332] Fix "OSError: [Errno 36] File name too long" in
 fuzz_submodule

Fixes a bug in the `fuzz_submodule` harness where the fuzzed data can
produce file names that exceed the maximum size allowed byt the OS. This
issue came up previously and was fixed in #1922, but the submodule file
name fixed here was missed in that PR.

Fixes: https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=69456
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index ca47690ea..9f5828d8d 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -35,12 +35,13 @@ def TestOneInput(data):
                 sub_repo = Repo.init(submodule_temp_dir, bare=fdp.ConsumeBool())
                 sub_repo.index.commit(fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512)))
 
-                submodule_name = f"submodule_{fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512))}"
+                submodule_name = fdp.ConsumeUnicodeNoSurrogates(
+                    fdp.ConsumeIntInRange(1, max(1, get_max_filename_length(repo.working_tree_dir)))
+                )
                 submodule_path = os.path.join(repo.working_tree_dir, submodule_name)
-                submodule_url = sub_repo.git_dir
 
-                submodule = repo.create_submodule(submodule_name, submodule_path, url=submodule_url)
-                repo.index.commit(f"Added submodule {submodule_name}")
+                submodule = repo.create_submodule(submodule_name, submodule_path, url=sub_repo.git_dir)
+                repo.index.commit("Added submodule")
 
                 with submodule.config_writer() as writer:
                     key_length = fdp.ConsumeIntInRange(1, max(1, fdp.remaining_bytes()))

From 7de1556d3895c718f0f0772530ff7cde5457d9d8 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 8 Aug 2024 16:54:37 -0400
Subject: [PATCH 201/332] Filter out non-bug exceptions using a pre-defined
 exception list.

This reduces false positive test failures by identifying and
gracefully handling exceptions that are explicitly raised by GitPython,
thus reducing the false-positive fuzzing test failure rate.
---
 fuzzing/fuzz-targets/fuzz_submodule.py        | 56 +++++++++++++++----
 fuzzing/oss-fuzz-scripts/build.sh             |  2 +-
 .../container-environment-bootstrap.sh        | 11 ++++
 3 files changed, 56 insertions(+), 13 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 9f5828d8d..05c543bf8 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -1,16 +1,51 @@
+# ruff: noqa: E402
 import atheris
 import sys
 import os
+import traceback
 import tempfile
 from configparser import ParsingError
-from utils import is_expected_exception_message, get_max_filename_length
+from utils import get_max_filename_length
+import re
+
+bundle_dir = os.path.dirname(os.path.abspath(__file__))
 
 if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):  # pragma: no cover
-    path_to_bundled_git_binary = os.path.abspath(os.path.join(os.path.dirname(__file__), "git"))
-    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path_to_bundled_git_binary
+    bundled_git_binary_path = os.path.join(bundle_dir, "git")
+    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = bundled_git_binary_path
 
 from git import Repo, GitCommandError, InvalidGitRepositoryError
 
+
+def load_exception_list(file_path):
+    """Load and parse the exception list from a file."""
+    try:
+        with open(file_path, "r") as file:
+            lines = file.readlines()
+        exception_list = set()
+        for line in lines:
+            match = re.match(r"(.+):(\d+):", line)
+            if match:
+                file_path = match.group(1).strip()
+                line_number = int(match.group(2).strip())
+                exception_list.add((file_path, line_number))
+        return exception_list
+    except FileNotFoundError:
+        print("File not found: %s", file_path)
+        return set()
+    except Exception as e:
+        print("Error loading exception list: %s", e)
+        return set()
+
+
+def check_exception_against_list(exception_list, exc_traceback):
+    """Check if the exception traceback matches any entry in the exception list."""
+    for filename, lineno, _, _ in traceback.extract_tb(exc_traceback):
+        if (filename, lineno) in exception_list:
+            return True
+    return False
+
+
 if not sys.warnoptions:  # pragma: no cover
     # The warnings filter below can be overridden by passing the -W option
     # to the Python interpreter command line or setting the `PYTHONWARNINGS` environment variable.
@@ -89,17 +124,14 @@ def TestOneInput(data):
             BrokenPipeError,
         ):
             return -1
-        except ValueError as e:
-            expected_messages = [
-                "SHA is empty",
-                "Reference at",
-                "embedded null byte",
-                "This submodule instance does not exist anymore",
-                "cmd stdin was empty",
-            ]
-            if is_expected_exception_message(e, expected_messages):
+        except Exception as e:
+            exc_traceback = e.__traceback__
+            exception_list = load_exception_list(os.path.join(bundle_dir, "explicit-exceptions-list.txt"))
+            if check_exception_against_list(exception_list, exc_traceback):
+                print("Exception matches an entry in the exception list.")
                 return -1
             else:
+                print("Exception does not match any entry in the exception list.")
                 raise e
 
 
diff --git a/fuzzing/oss-fuzz-scripts/build.sh b/fuzzing/oss-fuzz-scripts/build.sh
index e0b3a50ab..c156e872d 100644
--- a/fuzzing/oss-fuzz-scripts/build.sh
+++ b/fuzzing/oss-fuzz-scripts/build.sh
@@ -15,5 +15,5 @@ find "$SRC" -maxdepth 1 \
 
 # Build fuzzers in $OUT.
 find "$SRC/gitpython/fuzzing" -name 'fuzz_*.py' -print0 | while IFS= read -r -d '' fuzz_harness; do
-  compile_python_fuzzer "$fuzz_harness" --add-binary="$(command -v git):."
+  compile_python_fuzzer "$fuzz_harness" --add-binary="$(command -v git):." --add-data="$SRC/explicit-exceptions-list.txt:."
 done
diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index bbdcf5357..af1ddf014 100755
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -91,6 +91,17 @@ create_seed_corpora_zips "$WORK/qa-assets/gitpython/corpora"
 
 prepare_dictionaries_for_fuzz_targets "$WORK/qa-assets/gitpython/dictionaries" "$SRC/gitpython/fuzzing"
 
+pushd "$SRC/gitpython/"
+# Search for 'raise' and 'assert' statements in Python files within GitPython's 'git/' directory and its submodules,
+# remove trailing colons, and save to 'explicit-exceptions-list.txt'. This file can then be used by fuzz harnesses to
+# check exception tracebacks:
+# If an exception found by the fuzzer originated in a file + line number in explicit-exceptions-list.txt, then it is not a bug.
+
+git grep -n --recurse-submodules -e '\braise\b' -e '\bassert\b' -- "git/**/*.py" > "$SRC/explicit-exceptions-list.txt"
+
+popd
+
+
 # The OSS-Fuzz base image has outdated dependencies by default so we upgrade them below.
 python3 -m pip install --upgrade pip
 # Upgrade to the latest versions known to work at the time the below changes were introduced:

From 799b9cae745f50f2c0c590e8b3e19bfea199c463 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 8 Aug 2024 18:58:28 -0400
Subject: [PATCH 202/332] Improve `check_exception_against_list` matching logic
 using regex

Changes:
   - `match_exception_with_traceback` uses regular expressions for more
     flexible matching of file paths and line numbers. This allows for
     partial matches and more complex patterns.

   - Improve `check_exception_against_list` by delegating to
     `match_exception_with_traceback` for checking tracebacks against
     exception list entries.

   - `load_exception_list`: Remains largely unchanged, as it correctly
     parses the file and line number from each exception entry. However,
     we ensure the set consists of regex patterns to match against
     tracebacks.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 20 ++++++++++++--------
 1 file changed, 12 insertions(+), 8 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 05c543bf8..37f069079 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -31,21 +31,27 @@ def load_exception_list(file_path):
                 exception_list.add((file_path, line_number))
         return exception_list
     except FileNotFoundError:
-        print("File not found: %s", file_path)
+        print(f"File not found: {file_path}")
         return set()
     except Exception as e:
-        print("Error loading exception list: %s", e)
+        print(f"Error loading exception list: {e}")
         return set()
 
 
-def check_exception_against_list(exception_list, exc_traceback):
-    """Check if the exception traceback matches any entry in the exception list."""
+def match_exception_with_traceback(exception_list, exc_traceback):
+    """Match exception traceback with the entries in the exception list."""
     for filename, lineno, _, _ in traceback.extract_tb(exc_traceback):
-        if (filename, lineno) in exception_list:
-            return True
+        for file_pattern, line_pattern in exception_list:
+            if re.fullmatch(file_pattern, filename) and re.fullmatch(line_pattern, str(lineno)):
+                return True
     return False
 
 
+def check_exception_against_list(exception_list, exc_traceback):
+    """Check if the exception traceback matches any entry in the exception list."""
+    return match_exception_with_traceback(exception_list, exc_traceback)
+
+
 if not sys.warnoptions:  # pragma: no cover
     # The warnings filter below can be overridden by passing the -W option
     # to the Python interpreter command line or setting the `PYTHONWARNINGS` environment variable.
@@ -128,10 +134,8 @@ def TestOneInput(data):
             exc_traceback = e.__traceback__
             exception_list = load_exception_list(os.path.join(bundle_dir, "explicit-exceptions-list.txt"))
             if check_exception_against_list(exception_list, exc_traceback):
-                print("Exception matches an entry in the exception list.")
                 return -1
             else:
-                print("Exception does not match any entry in the exception list.")
                 raise e
 
 

From 2e9c23995b70372a18edc4d0b143b6b522d3fb39 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 8 Aug 2024 19:38:06 -0400
Subject: [PATCH 203/332] Extract environment setup and exception checking
 boilerplate logic

Changes:

   - Simplify exception handling in test harnesses via `handle_exception(e)`
     in the `except Exception as e:` block.

   - `setup_git_environment` is a step towards centralizing environment
     variable and logging configuration set up consistently across
     different fuzzing scripts. **Only applying it to a single test for
     now is an intentional choice in case it fails to work in the
     ClusterFuzz environment!** If it proves successful, a follow-up
     change set will be welcome.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 70 +++------------------
 fuzzing/fuzz-targets/utils.py          | 87 +++++++++++++++++++++++++-
 2 files changed, 95 insertions(+), 62 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 37f069079..634572bf2 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -1,67 +1,17 @@
-# ruff: noqa: E402
 import atheris
 import sys
 import os
-import traceback
 import tempfile
 from configparser import ParsingError
-from utils import get_max_filename_length
-import re
-
-bundle_dir = os.path.dirname(os.path.abspath(__file__))
-
-if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):  # pragma: no cover
-    bundled_git_binary_path = os.path.join(bundle_dir, "git")
-    os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = bundled_git_binary_path
-
 from git import Repo, GitCommandError, InvalidGitRepositoryError
+from utils import (
+    setup_git_environment,
+    handle_exception,
+    get_max_filename_length,
+)
 
-
-def load_exception_list(file_path):
-    """Load and parse the exception list from a file."""
-    try:
-        with open(file_path, "r") as file:
-            lines = file.readlines()
-        exception_list = set()
-        for line in lines:
-            match = re.match(r"(.+):(\d+):", line)
-            if match:
-                file_path = match.group(1).strip()
-                line_number = int(match.group(2).strip())
-                exception_list.add((file_path, line_number))
-        return exception_list
-    except FileNotFoundError:
-        print(f"File not found: {file_path}")
-        return set()
-    except Exception as e:
-        print(f"Error loading exception list: {e}")
-        return set()
-
-
-def match_exception_with_traceback(exception_list, exc_traceback):
-    """Match exception traceback with the entries in the exception list."""
-    for filename, lineno, _, _ in traceback.extract_tb(exc_traceback):
-        for file_pattern, line_pattern in exception_list:
-            if re.fullmatch(file_pattern, filename) and re.fullmatch(line_pattern, str(lineno)):
-                return True
-    return False
-
-
-def check_exception_against_list(exception_list, exc_traceback):
-    """Check if the exception traceback matches any entry in the exception list."""
-    return match_exception_with_traceback(exception_list, exc_traceback)
-
-
-if not sys.warnoptions:  # pragma: no cover
-    # The warnings filter below can be overridden by passing the -W option
-    # to the Python interpreter command line or setting the `PYTHONWARNINGS` environment variable.
-    import warnings
-    import logging
-
-    # Fuzzing data causes some modules to generate a large number of warnings
-    # which are not usually interesting and make the test output hard to read, so we ignore them.
-    warnings.simplefilter("ignore")
-    logging.getLogger().setLevel(logging.ERROR)
+# Setup the git environment
+setup_git_environment()
 
 
 def TestOneInput(data):
@@ -131,12 +81,10 @@ def TestOneInput(data):
         ):
             return -1
         except Exception as e:
-            exc_traceback = e.__traceback__
-            exception_list = load_exception_list(os.path.join(bundle_dir, "explicit-exceptions-list.txt"))
-            if check_exception_against_list(exception_list, exc_traceback):
+            if isinstance(e, ValueError) and "embedded null byte" in str(e):
                 return -1
             else:
-                raise e
+                return handle_exception(e)
 
 
 def main():
diff --git a/fuzzing/fuzz-targets/utils.py b/fuzzing/fuzz-targets/utils.py
index f522d2959..97e6eab98 100644
--- a/fuzzing/fuzz-targets/utils.py
+++ b/fuzzing/fuzz-targets/utils.py
@@ -1,6 +1,9 @@
 import atheris  # pragma: no cover
 import os  # pragma: no cover
-from typing import List  # pragma: no cover
+import re  # pragma: no cover
+import traceback  # pragma: no cover
+import sys  # pragma: no cover
+from typing import Set, Tuple, List  # pragma: no cover
 
 
 @atheris.instrument_func
@@ -35,3 +38,85 @@ def get_max_filename_length(path: str) -> int:  # pragma: no cover
         int: The maximum filename length.
     """
     return os.pathconf(path, "PC_NAME_MAX")
+
+
+@atheris.instrument_func
+def read_lines_from_file(file_path: str) -> list:
+    """Read lines from a file and return them as a list."""
+    try:
+        with open(file_path, "r") as f:
+            return [line.strip() for line in f if line.strip()]
+    except FileNotFoundError:
+        print(f"File not found: {file_path}")
+        return []
+    except IOError as e:
+        print(f"Error reading file {file_path}: {e}")
+        return []
+
+
+@atheris.instrument_func
+def load_exception_list(file_path: str = "explicit-exceptions-list.txt") -> Set[Tuple[str, str]]:
+    """Load and parse the exception list from a default or specified file."""
+    try:
+        bundle_dir = os.path.dirname(os.path.abspath(__file__))
+        full_path = os.path.join(bundle_dir, file_path)
+        lines = read_lines_from_file(full_path)
+        exception_list: Set[Tuple[str, str]] = set()
+        for line in lines:
+            match = re.match(r"(.+):(\d+):", line)
+            if match:
+                file_path: str = match.group(1).strip()
+                line_number: str = str(match.group(2).strip())
+                exception_list.add((file_path, line_number))
+        return exception_list
+    except Exception as e:
+        print(f"Error loading exception list: {e}")
+        return set()
+
+
+@atheris.instrument_func
+def match_exception_with_traceback(exception_list: Set[Tuple[str, str]], exc_traceback) -> bool:
+    """Match exception traceback with the entries in the exception list."""
+    for filename, lineno, _, _ in traceback.extract_tb(exc_traceback):
+        for file_pattern, line_pattern in exception_list:
+            # Ensure filename and line_number are strings for regex matching
+            if re.fullmatch(file_pattern, filename) and re.fullmatch(line_pattern, str(lineno)):
+                return True
+    return False
+
+
+@atheris.instrument_func
+def check_exception_against_list(exc_traceback, exception_file: str = "explicit-exceptions-list.txt") -> bool:
+    """Check if the exception traceback matches any entry in the exception list."""
+    exception_list = load_exception_list(exception_file)
+    return match_exception_with_traceback(exception_list, exc_traceback)
+
+
+@atheris.instrument_func
+def handle_exception(e: Exception) -> int:
+    """Encapsulate exception handling logic for reusability."""
+    exc_traceback = e.__traceback__
+    if check_exception_against_list(exc_traceback):
+        return -1
+    else:
+        raise e
+
+
+@atheris.instrument_func
+def setup_git_environment() -> None:
+    """Set up the environment variables for Git."""
+    bundle_dir = os.path.dirname(os.path.abspath(__file__))
+    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):  # pragma: no cover
+        bundled_git_binary_path = os.path.join(bundle_dir, "git")
+        os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = bundled_git_binary_path
+
+    if not sys.warnoptions:  # pragma: no cover
+        # The warnings filter below can be overridden by passing the -W option
+        # to the Python interpreter command line or setting the `PYTHONWARNINGS` environment variable.
+        import warnings
+        import logging
+
+        # Fuzzing data causes some modules to generate a large number of warnings
+        # which are not usually interesting and make the test output hard to read, so we ignore them.
+        warnings.simplefilter("ignore")
+        logging.getLogger().setLevel(logging.ERROR)

From 27de8676c64b549038b4fdd994a20f1ce996ad5e Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Thu, 8 Aug 2024 20:35:13 -0400
Subject: [PATCH 204/332] Fix buggy `git grep` pathspec args

To ensure that all necessary files are included in the
explicit-exceptions-list.txt file and unwanted files and directories are
not.
---
 .../container-environment-bootstrap.sh                 | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
index af1ddf014..924a3cbf3 100755
--- a/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
+++ b/fuzzing/oss-fuzz-scripts/container-environment-bootstrap.sh
@@ -92,12 +92,12 @@ create_seed_corpora_zips "$WORK/qa-assets/gitpython/corpora"
 prepare_dictionaries_for_fuzz_targets "$WORK/qa-assets/gitpython/dictionaries" "$SRC/gitpython/fuzzing"
 
 pushd "$SRC/gitpython/"
-# Search for 'raise' and 'assert' statements in Python files within GitPython's 'git/' directory and its submodules,
-# remove trailing colons, and save to 'explicit-exceptions-list.txt'. This file can then be used by fuzz harnesses to
-# check exception tracebacks:
-# If an exception found by the fuzzer originated in a file + line number in explicit-exceptions-list.txt, then it is not a bug.
+# Search for 'raise' and 'assert' statements in Python files within GitPython's source code and submodules, saving the
+# matched file path, line number, and line content to a file named 'explicit-exceptions-list.txt'.
+# This file can then be used by fuzz harnesses to check exception tracebacks and filter out explicitly raised or otherwise
+# anticipated exceptions to reduce false positive test failures.
 
-git grep -n --recurse-submodules -e '\braise\b' -e '\bassert\b' -- "git/**/*.py" > "$SRC/explicit-exceptions-list.txt"
+git grep -n --recurse-submodules -e '\braise\b' -e '\bassert\b' -- '*.py' -- ':!setup.py' -- ':!test/**' -- ':!fuzzing/**' > "$SRC/explicit-exceptions-list.txt"
 
 popd
 

From 2ed33345667706c5755708e88c989ede06f2414f Mon Sep 17 00:00:00 2001
From: David Lakin <david.js.lakin@gmail.com>
Date: Fri, 9 Aug 2024 00:06:44 -0400
Subject: [PATCH 205/332] Fix order of environment setup and git module import

The environment setup must happen before the `git` module is imported,
otherwise GitPython won't be able to find the Git executable and raise
an exception that causes the ClusterFuzz fuzzer runs to fail.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 2 +-
 pyproject.toml                         | 4 ++++
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 634572bf2..997133b70 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -3,7 +3,6 @@
 import os
 import tempfile
 from configparser import ParsingError
-from git import Repo, GitCommandError, InvalidGitRepositoryError
 from utils import (
     setup_git_environment,
     handle_exception,
@@ -12,6 +11,7 @@
 
 # Setup the git environment
 setup_git_environment()
+from git import Repo, GitCommandError, InvalidGitRepositoryError
 
 
 def TestOneInput(data):
diff --git a/pyproject.toml b/pyproject.toml
index 603e2597c..6cf4b3f5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,6 +78,10 @@ lint.unfixable = [
 "test/**" = [
     "B018",  # useless-expression
 ]
+"fuzzing/fuzz-targets/**" = [
+  "E402",  # environment setup must happen before the `git` module is imported, thus cannot happen at top of file
+]
+
 
 [tool.codespell]
 ignore-words-list="gud,doesnt"

From 096851b61fa99df233176b090146efb52e524f48 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Fri, 9 Aug 2024 11:01:34 -0400
Subject: [PATCH 206/332] Gracefully handle `PermissionError` exceptions that
 crash fuzzer

Fuzzing inputs sometimes produce directory paths that are protected
inside the fuzzer execution environment. This is not an issue in
GitPython's code, so it should not crash the fuzzer.

Fixes OSS-Fuzz Issue 69456:
https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=69870
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index 997133b70..c2bf1e4fe 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -78,6 +78,7 @@ def TestOneInput(data):
             IsADirectoryError,
             NotADirectoryError,
             BrokenPipeError,
+            PermissionError,
         ):
             return -1
         except Exception as e:

From 7126ce16a03e0aea5ef4d031c62596992a6d7cb5 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Tue, 13 Aug 2024 01:09:37 -0400
Subject: [PATCH 207/332] Fuzzing: Gracefully Handle Uninteresting Error to Fix
 OSS-Fuzz Issue 71095

Fuzzing data can generate filenames that trigger:
> OSError: [Errno 36] File name too long

The changes here add handling for these exceptions because they di not
indicate a bug and should not crash the fuzzer.
a
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index c2bf1e4fe..d22b0aa5b 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -84,6 +84,8 @@ def TestOneInput(data):
         except Exception as e:
             if isinstance(e, ValueError) and "embedded null byte" in str(e):
                 return -1
+            elif isinstance(e, OSError) and "File name too long" in str(e):
+                return -1
             else:
                 return handle_exception(e)
 

From d1582d181bfeb5138d9cae306b40dfa2fe87fe39 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 18:08:22 -0400
Subject: [PATCH 208/332] Update versions of pre-commit hooks

---
 .pre-commit-config.yaml | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 692c7fa2a..7d93876ed 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
 - repo: https://github.com/codespell-project/codespell
-  rev: v2.2.4
+  rev: v2.3.0
   hooks:
   - id: codespell
     additional_dependencies: [tomli]
@@ -8,7 +8,7 @@ repos:
     exclude: "test/fixtures/"
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.4.3
+  rev: v0.6.0
   hooks:
   - id: ruff
     args: ["--fix"]
@@ -17,14 +17,14 @@ repos:
     exclude: ^git/ext/
 
 - repo: https://github.com/shellcheck-py/shellcheck-py
-  rev: v0.9.0.6
+  rev: v0.10.0.1
   hooks:
   - id: shellcheck
     args: [--color]
     exclude: ^test/fixtures/polyglot$|^git/ext/
 
 - repo: https://github.com/pre-commit/pre-commit-hooks
-  rev: v4.5.0
+  rev: v4.6.0
   hooks:
   - id: end-of-file-fixer
     exclude: test/fixtures/
@@ -33,6 +33,6 @@ repos:
   - id: check-merge-conflict
 
 - repo: https://github.com/abravalheri/validate-pyproject
-  rev: v0.16
+  rev: v0.19
   hooks:
     - id: validate-pyproject

From 016fa44a64ac244de2335b00338af67e3f8585ee Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 18:09:06 -0400
Subject: [PATCH 209/332] Have codespell ignore words that cause new false
 positives

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index 6cf4b3f5d..090972eed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -84,6 +84,6 @@ lint.unfixable = [
 
 
 [tool.codespell]
-ignore-words-list="gud,doesnt"
+ignore-words-list="afile,assertIn,doesnt,gud,uptodate"
 #count = true
 quiet-level = 3

From c82bb65fd263603b374b925f61483efc47c2a264 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 18:13:28 -0400
Subject: [PATCH 210/332] Fix a spelling error that codespell didn't catch

---
 .pre-commit-config.yaml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7d93876ed..90b899f8e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,7 +4,7 @@ repos:
   hooks:
   - id: codespell
     additional_dependencies: [tomli]
-    # args: ["--write-changes"]  # consider enabling for auto-fif
+    # args: ["--write-changes"]  # consider enabling for auto-fix
     exclude: "test/fixtures/"
 
 - repo: https://github.com/astral-sh/ruff-pre-commit

From 9556f63a965877db19002849d7bfeec71e84a2c7 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 18:19:03 -0400
Subject: [PATCH 211/332] Drop suggestion to auto-fix spelling (many false
 positives)

---
 .pre-commit-config.yaml | 1 -
 1 file changed, 1 deletion(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 90b899f8e..c47d9a2c7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,7 +4,6 @@ repos:
   hooks:
   - id: codespell
     additional_dependencies: [tomli]
-    # args: ["--write-changes"]  # consider enabling for auto-fix
     exclude: "test/fixtures/"
 
 - repo: https://github.com/astral-sh/ruff-pre-commit

From 7a138eea78fd922b21f6049d273aaeca5f02bfb0 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 18:55:48 -0400
Subject: [PATCH 212/332] Fix small inconsistencies in test/fixtures/
 exclusions

---
 .pre-commit-config.yaml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c47d9a2c7..f5635b2a0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,7 +4,7 @@ repos:
   hooks:
   - id: codespell
     additional_dependencies: [tomli]
-    exclude: "test/fixtures/"
+    exclude: ^test/fixtures/
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
   rev: v0.6.0
@@ -26,7 +26,7 @@ repos:
   rev: v4.6.0
   hooks:
   - id: end-of-file-fixer
-    exclude: test/fixtures/
+    exclude: ^test/fixtures/
   - id: check-toml
   - id: check-yaml
   - id: check-merge-conflict

From 53ec790e0dbc1ec9e4451394edb5c572c807b817 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 19:01:02 -0400
Subject: [PATCH 213/332] Fix inconsistent indentation

---
 .pre-commit-config.yaml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f5635b2a0..0cbf5aa73 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -34,4 +34,4 @@ repos:
 - repo: https://github.com/abravalheri/validate-pyproject
   rev: v0.19
   hooks:
-    - id: validate-pyproject
+  - id: validate-pyproject

From bdfa280f6dd412464419dd133ad02781cd27a312 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 19:02:14 -0400
Subject: [PATCH 214/332] Temporarily let end-of-file-fixer break LICENSE-BSD
 symlink

On Windows, when `core.symlinks` is `false` or unset (since it
defaults to `false` on Windows), Git checks out symbolic links as
regular files whose contents are symlinks' target paths. Modifying
those regular files and committing the changes alters the symlink
target in the repository, and when they are checked out as actual
symlinks, the targets are different.

But the `end-of-file-fixer` pre-commit hook automatically adds
newlines to the end of regular files that lack them. It doesn't do
this on actual symlinks, but it does do it to regular files that
stand in for symlinks. This causes it to carry a risk of breaking
symlinks if it is run on Windows and the changes committed, and it
is easy to miss that this will happen because `git diff` output
shows it the same way as other additions of absent newlines.

This deliberately commits the change that end-of-file-fixer makes
to the `LICENSE-BSD` symlink, in order to allow a mitigation beyond
just excluding that symlink (or replacing it with a regular file)
to be tested. This change must be undone, of course.
---
 fuzzing/LICENSE-BSD | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fuzzing/LICENSE-BSD b/fuzzing/LICENSE-BSD
index ea5b60640..4f88f81bf 120000
--- a/fuzzing/LICENSE-BSD
+++ b/fuzzing/LICENSE-BSD
@@ -1 +1 @@
-../LICENSE
\ No newline at end of file
+../LICENSE

From 965ea8bebcd768f6cadbc6cae6b7fe65868f1fb6 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 20:17:24 -0400
Subject: [PATCH 215/332] Enable check-symlinks pre-commit hook

Rationale:

- Small but likely benefit in general, since there are no currently
  foreseen intentional use cases of committing of broken/dangling
  symlinks in this project. So such symlinks that arise are likely
  unintentional.

- If the end-of-file-fixer hook has run on a Windows system where
  `core.symlinks` has *not* been set to `true`, and symlinks' paths
  have not been excluded, then a newline character is added to the
  end of the path held in the regular file Git checks out to stand
  in for the symlink. Because it is not actually a symlink, this
  will not detect the problem at that time (regardless of the order
  in which this and that hook run relative to each other). But when
  it is then run on CI on a system where symlinks are checked out,
  it will detect the problem.
---
 .pre-commit-config.yaml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0cbf5aa73..3f6892687 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -27,6 +27,7 @@ repos:
   hooks:
   - id: end-of-file-fixer
     exclude: ^test/fixtures/
+  - id: check-symlinks
   - id: check-toml
   - id: check-yaml
   - id: check-merge-conflict

From e9782487b8119147aa0c456c708f61ca7e3139e1 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 20:24:36 -0400
Subject: [PATCH 216/332] Revert "Temporarily let end-of-file-fixer break
 LICENSE-BSD symlink"

This reverts commit bdfa280f6dd412464419dd133ad02781cd27a312.
---
 fuzzing/LICENSE-BSD | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/fuzzing/LICENSE-BSD b/fuzzing/LICENSE-BSD
index 4f88f81bf..ea5b60640 120000
--- a/fuzzing/LICENSE-BSD
+++ b/fuzzing/LICENSE-BSD
@@ -1 +1 @@
-../LICENSE
+../LICENSE
\ No newline at end of file

From cae0d8743a31fb0eda3c224a45c14de8cabd0d90 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 15 Aug 2024 20:28:46 -0400
Subject: [PATCH 217/332] Don't fix end-of-file in files named like licenses

The unanchored `LICENSE` and `COPYING` alternatives match the
pattern anywhere, and therefore exclude the currently used path
`fuzzing/LICENSE-BSD`.

License files are more likely than other files in this project to
be introduced as symlinks, and less likely to be noticed
immediately if they break. Symlinks can be checked out as regular
files when `core.symlinks` is set to `false`, which is rare outside
of Windows but is the default behavior when unset on Windows.

This exclusion fixes the current problem that end-of-file-fixer
breaks those links by adding a newline character to the end (the
symlinks are checked out broken if that is committed). It also
guards against most future cases involving licenses, though
possibly not all, and not other unrelated cases where symlinks may
be used for other purposes.

Although the pre-commit-hooks repository also provides a
destroyed-symlinks hook that detects the situation of a symlink
that has been replaced by a regular file, this does not add that
hook, because this situation is not inherently a problem. The code
here does not require symlinks to be checked out to work, and
adding that would break significant uses of the repository on
Windows.

Note that this leaves the situation where a license file may be a
symlink to another license file and may thus be checked out as a
regular file containing that file's path. However, it is easy to
understand that situation and manually follow the path. That
differs from the scenario where a symlink is created but broken,
because attempting to open it gives an error, and the error message
is often non-obvious, reporting that a file is not found but giving
the name of the symlink rather than its target.
---
 .pre-commit-config.yaml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3f6892687..424cc5f37 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -26,7 +26,7 @@ repos:
   rev: v4.6.0
   hooks:
   - id: end-of-file-fixer
-    exclude: ^test/fixtures/
+    exclude: ^test/fixtures/|COPYING|LICENSE
   - id: check-symlinks
   - id: check-toml
   - id: check-yaml

From 16fc99fee45412c3dae44bdd7f59d921a11c00b3 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 17 Aug 2024 12:51:13 -0400
Subject: [PATCH 218/332] Upgrade sphinx to ~7.1.2

The old pinned version and its explicitly constrained dependencies
are retained for now for Python 3.7 so that documentation can be
built even with 3.7. (This could maybe be removed soon as a
preliminary step toward evenutally dropping 3.7 support.)

For Python 3.8 and higher, version 7.1.2 is used, allowing later
patch versions but constrained to remain 7.1.*. This is so the same
versions are likely to be selected on all Python version from 3.8
and higher, to minimize small differences in generated documentation
that different versions could give, and also to simplify debugging.

The reason to upgrade Sphinx now is to suppport Python 3.13, which
shall be (and, in the pre-releases available, is) incompatible with
versions of Sphinx below 6.2. This is because those earlier Sphinx
versions use the deprecated `imghdr` module, which 3.13 removes:

- https://docs.python.org/3.13/whatsnew/3.13.html#whatsnew313-pep594
- https://github.com/python/cpython/issues/104818

On old versions of Sphinx, that gives the error:

    Extension error:
    Could not import extension sphinx.builders.epub3 (exception: No module named 'imghdr')

Using Sphinx 6.2 is sufficient to avoid this, but there do not seem
to be any disadvantages for GitPython to remain below 7.1.2.

The reason we did not upgrade Sphinx before is that, last time we
considered doing so, we ran into a problem of new warnings (that we
treat as errors). This is detailed in the "Can we upgrade Sphinx?"
section of #1802, especially the "What Sphinx 5 is talking about"
subsection. The problem is warnings about `Actor` when it comes
in through type annotations:

    WARNING: more than one target found for cross-reference 'Actor': git.objects.util.Actor, git.util.Actor

So this includes other changes to fix that problem as well. The
solution used here is to import `Actor` even when `TYPE_CHECKING`
is `false`, and write it unquoted in annotations, as `Actor` rather
than `"Actor"`. This allows Sphinx to discern where it should
consider it to be located, for the purpose of linking to its
documentation.

This does not incur overhead, because:

- The affected modules already have imports from `git.util`, so also
  importing `Actor` from `git.util` does not cause any modules to
  be imported that were not imported otherwise, nor even to be
  imported at a different time.

- Even if that that had not been the case, most modules in GitPython
  including `git.util` have members imported them into the top-level
  `git` module in `git.__init__` when `git` is imported (and thus
  when any Python submodule of `git` is imported).

The only disadvantage is the presence of the additional name in
those modules at runtime, which a user might inadvertently use and
thus write brittle code that could break if it is later removed.
But:

- The affected modules define `__all__` and do not include
  `"Actor"` in `__all__`, so it is non-public.

- There are many places in GitPython (and most Python libraries)
  where the onus is already on the author of code that uses the
  library to avoid doing this.

The reasons for this approach, rather than any of several others,
were:

1. I did not write out the annotations as `git.util.Actor` to
   resolve the ambiguity because annotations should, and for some
   uses must, also be interpretable as expressions. But while
   `from git.util import Actor` works and makes `Actor` available,
   `git.util.Actor` cannot be used as an expression even after
   `import git.util`. This is because, even after such an import,
   `git.util` actually refers to `git.index.util`. This is as
   detailed in the warnings issued when it is accessed, originally
   from an overly broad `*` import but retained because removing it
   could be a breaking change. See `git/__init__.py` for details.

2. The reason I did not write out the annotations as
   `git.objects.util.Actor` to resolve the ambiguity is that not
   all occurrences where Sphinx needed to be told which module to
   document it as being from were within the `git.objects` Python
   submodule. Two of the warnings were in `git/objects/tag.py`,
   where annotating it that way would not be confusing. But the
   other two were in `git/index/base.py`.

3. Although removing `Actor` from `git.objects.util.__all__` would
   resolve the ambiguity, this should not be done, because:

   - This would be a breaking change.

   - It seems to be there deliberately, since `git.objects.util`
     contains other members that relate to it directly.

4. The reasons I did not take this opportunity to move the contents
   of `git/util.py` to a new file in that directory and make
   `git/util.py` re-export the contents, even though this would
   allow a solution analogous to (1) but for the new module to be
   used, while also simplifying importing elsewhere, were:

   - That seems like a change that should be done separately, based
     either on the primary benefit to users or on a greater need
     for it.

   - If and when that is done, it may make sense to change the
     interface as well. For example, `git/util.py` has a number of
     members that it makes available for use throughout GitPython
     but that are deliberately omitted from `__all__` and are meant
     as non-public outside the project. One approach would be to make
     a module with a leading `_` for these "internal" members, and
     another public ones with everything else. But that also cannot
     be decided based on the considerations that motivate the
     changes here.

   - The treatment of `HIDE_WINDOWS_KNOWN_ERRORS`, which is public
     in `git/util.py` and which currently *does* have an effect,
     will need to be considered. Although it cannot be re-bound by
     assigning to `git.util.HIDE_WINDOWS_KNOWN_ERRORS` because the
     `git.util` subexpression would evaluate to `git.index.util`,
     there may be code that re-binds it in another way, such as by
     accessing the module through `sys.modules`. Unlike functions
     and classes that should not be monkey-patched from code
     outside GitPython's test suite anyway, this attribute may
     reasonably be assigned to, so it matters what module it is
     actually in, unless the action of assigning to it elsewhere
     is customized dynamically to carry over to the "real" place.

5. An alternative solution that may be reasonable in the near
   future is to modify `reference.rst` so duplicate documentation
   is no longer emitted for functions and classes that are defined
   in one place but imported and "re-exported" elsewhere. I suspect
   this may solve the problem, allowing the `Actor` imports to go
   back under `if TYPE_CHECKING:` and to annotate with `"Actor"`
   again while still running `make -C doc html` with no warnings.

   However, this would entail design decisions about how to still
   document those members. They should probably have links to where
   they are fully documented. So an entry for `Actor` in the
   section of `reference.rst` for `git.objects.util` would still
   exist, but be very short and refer to the full autodoc item for
   `Actor` the section for `git.util`. Since a `:class:` reference
   to `git.objects.util.Actor` should still go to the stub that
   links to `git.util.Actor`, it is not obvious that solving the
   duplication in documentation generated for `reference.rst` ought
   to be done in a way that would address the ambiguity Sphinx
   warns about, even if it *can* be done in such a way.

   Therefore, that should also be a separate consideration and, if
   warranted, a separate change.
---
 doc/requirements.txt | 13 +++++++------
 git/index/base.py    |  6 +++---
 git/objects/tag.py   |  5 ++---
 3 files changed, 12 insertions(+), 12 deletions(-)

diff --git a/doc/requirements.txt b/doc/requirements.txt
index 7769af5ae..a90a7a496 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -1,8 +1,9 @@
-sphinx == 4.3.2
+sphinx >= 7.1.2, < 7.2 ; python_version >= "3.8"
+sphinx == 4.3.2 ; python_version < "3.8"
+sphinxcontrib-applehelp >= 1.0.2, <= 1.0.4 ; python_version < "3.8"
+sphinxcontrib-devhelp == 1.0.2 ; python_version < "3.8"
+sphinxcontrib-htmlhelp >= 2.0.0, <= 2.0.1 ; python_version < "3.8"
+sphinxcontrib-qthelp == 1.0.3 ; python_version < "3.8"
+sphinxcontrib-serializinghtml == 1.1.5 ; python_version < "3.8"
 sphinx_rtd_theme
-sphinxcontrib-applehelp >= 1.0.2, <= 1.0.4
-sphinxcontrib-devhelp == 1.0.2
-sphinxcontrib-htmlhelp >= 2.0.0, <= 2.0.1
-sphinxcontrib-qthelp == 1.0.3
-sphinxcontrib-serializinghtml == 1.1.5
 sphinx-autodoc-typehints
diff --git a/git/index/base.py b/git/index/base.py
index a317e71c0..47925ad1c 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -28,6 +28,7 @@
 from git.objects import Blob, Commit, Object, Submodule, Tree
 from git.objects.util import Serializable
 from git.util import (
+    Actor,
     LazyMixin,
     LockedFD,
     join_path_native,
@@ -76,7 +77,6 @@
 
     from git.refs.reference import Reference
     from git.repo import Repo
-    from git.util import Actor
 
 
 Treeish = Union[Tree, Commit, str, bytes]
@@ -1117,8 +1117,8 @@ def commit(
         message: str,
         parent_commits: Union[List[Commit], None] = None,
         head: bool = True,
-        author: Union[None, "Actor"] = None,
-        committer: Union[None, "Actor"] = None,
+        author: Union[None, Actor] = None,
+        committer: Union[None, Actor] = None,
         author_date: Union[datetime.datetime, str, None] = None,
         commit_date: Union[datetime.datetime, str, None] = None,
         skip_hooks: bool = False,
diff --git a/git/objects/tag.py b/git/objects/tag.py
index a3bb0b882..88671d316 100644
--- a/git/objects/tag.py
+++ b/git/objects/tag.py
@@ -14,7 +14,7 @@
 import sys
 
 from git.compat import defenc
-from git.util import hex_to_bin
+from git.util import Actor, hex_to_bin
 
 from . import base
 from .util import get_object_type_by_name, parse_actor_and_date
@@ -30,7 +30,6 @@
 
 if TYPE_CHECKING:
     from git.repo import Repo
-    from git.util import Actor
 
     from .blob import Blob
     from .commit import Commit
@@ -64,7 +63,7 @@ def __init__(
         binsha: bytes,
         object: Union[None, base.Object] = None,
         tag: Union[None, str] = None,
-        tagger: Union[None, "Actor"] = None,
+        tagger: Union[None, Actor] = None,
         tagged_date: Union[int, None] = None,
         tagger_tz_offset: Union[int, None] = None,
         message: Union[str, None] = None,

From 44f7a738b85f75d86516a3ee1f128f4098fbcda6 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 18 Aug 2024 14:02:40 -0400
Subject: [PATCH 219/332] Don't support building documentation on Python 3.7

This removes the specially cased alternative lower versions of
`sphinx` and its dependencies that, since #1954, were only for
Python 3.7. As discussed in comments there, this simplifies the
documentation dependencies and avoids a situation where the version
of Python used to build the documentation has a noticeable effect
on the generated result.

This also conditions running the "Documentation" step in the main
CI test workflow (`pythonpackage.yml`) on the Python version not
being 3.7 (otherwise the job would always fail).

The only change this makes to the support status of GitPython on
Python 3.7 is to no longer support building documentation on 3.7.
GitPython can still be installed and used on 3.7 (though usually
this would not be a good idea, outside of testing, since Python 3.7
itself has not been supported by the Python Software Foundation for
quite some time). In addition, the documentation, which can be
built on any version >= 3.8 (including 3.13 starting in #1954) is
no less relevant to usage on Python 3.7 than it was before.
---
 .github/workflows/pythonpackage.yml | 1 +
 doc/requirements.txt                | 8 +-------
 2 files changed, 2 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 0f1d17544..292c9fc86 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -99,6 +99,7 @@ jobs:
       continue-on-error: false
 
     - name: Documentation
+      if: matrix.python-version != '3.7'
       run: |
         pip install ".[doc]"
         make -C doc html
diff --git a/doc/requirements.txt b/doc/requirements.txt
index a90a7a496..81140d898 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -1,9 +1,3 @@
-sphinx >= 7.1.2, < 7.2 ; python_version >= "3.8"
-sphinx == 4.3.2 ; python_version < "3.8"
-sphinxcontrib-applehelp >= 1.0.2, <= 1.0.4 ; python_version < "3.8"
-sphinxcontrib-devhelp == 1.0.2 ; python_version < "3.8"
-sphinxcontrib-htmlhelp >= 2.0.0, <= 2.0.1 ; python_version < "3.8"
-sphinxcontrib-qthelp == 1.0.3 ; python_version < "3.8"
-sphinxcontrib-serializinghtml == 1.1.5 ; python_version < "3.8"
+sphinx >= 7.1.2, < 7.2
 sphinx_rtd_theme
 sphinx-autodoc-typehints

From f2254af5d3fd183ec150740d517bd0f8070fc67d Mon Sep 17 00:00:00 2001
From: Andrej730 <azhilenkov@gmail.com>
Date: Sat, 14 Sep 2024 10:45:53 +0500
Subject: [PATCH 220/332] _to_relative_path to support mixing slashes and
 backslashes

Working on Windows you sometime end up having some paths with backslashes (windows native) and some with slashes - this PR will resolve the issue using gitpython for those kind of cases (see example below). It will also fix the issues if paths contain redundant separators or "..".

```
import git

repo = git.Repo(r"C:\gittest")
repo.index.add(r"C:\gittest\1.txt")
# Traceback (most recent call last):
#   File "c:\second_test.py", line 5, in <module>
#     repo.index.add(r"C:/gittest/2.txt")
#   File "Python311\Lib\site-packages\git\index\base.py", line 879, in add
#     paths, entries = self._preprocess_add_items(items)
#                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#   File "Python311\Lib\site-packages\git\index\base.py", line 672, in _preprocess_add_items
#     paths.append(self._to_relative_path(item))
#                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#   File "Python311\Lib\site-packages\git\index\base.py", line 657, in _to_relative_path
#     raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir))
# ValueError: Absolute path 'C:/gittest/2.txt' is not in git repository at 'C:\\gittest'

repo.index.add(r"C:/gittest/2.txt")

repo.index.commit("test")
```
---
 git/index/base.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/index/base.py b/git/index/base.py
index 47925ad1c..7f53e614a 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -653,7 +653,7 @@ def _to_relative_path(self, path: PathLike) -> PathLike:
             return path
         if self.repo.bare:
             raise InvalidGitRepositoryError("require non-bare repository")
-        if not str(path).startswith(str(self.repo.working_tree_dir)):
+        if not osp.normpath(str(path)).startswith(osp.normpath(str(self.repo.working_tree_dir))):
             raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir))
         return os.path.relpath(path, self.repo.working_tree_dir)
 

From ca06b11efde845080354dac71e9062ea6d63ab84 Mon Sep 17 00:00:00 2001
From: Andrej730 <azhilenkov@gmail.com>
Date: Sat, 14 Sep 2024 16:51:41 +0500
Subject: [PATCH 221/332] test adding a file using non-normalized path

---
 test/test_index.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/test/test_index.py b/test/test_index.py
index 2684cfd81..efd5b83a6 100644
--- a/test/test_index.py
+++ b/test/test_index.py
@@ -1181,6 +1181,18 @@ def test_index_add_pathlike(self, rw_repo):
 
         rw_repo.index.add(file)
 
+    @with_rw_repo("HEAD")
+    def test_index_add_non_normalized_path(self, rw_repo):
+        git_dir = Path(rw_repo.git_dir)
+
+        file = git_dir / "file.txt"
+        file.touch()
+        non_normalized_path = file.as_posix()
+        if os.name != "nt":
+            non_normalized_path = non_normalized_path.replace("/", "\\")
+
+        rw_repo.index.add(non_normalized_path)
+
 
 class TestIndexUtils:
     @pytest.mark.parametrize("file_path_type", [str, Path])

From 46740590f7918fd5b789c95db7e41fbda06fb46f Mon Sep 17 00:00:00 2001
From: Andrej730 <azhilenkov@gmail.com>
Date: Sat, 14 Sep 2024 16:52:56 +0500
Subject: [PATCH 222/332] Remove redundant path normalization for
 working_tree_dir

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

diff --git a/git/index/base.py b/git/index/base.py
index 7f53e614a..39cc9143c 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -653,7 +653,7 @@ def _to_relative_path(self, path: PathLike) -> PathLike:
             return path
         if self.repo.bare:
             raise InvalidGitRepositoryError("require non-bare repository")
-        if not osp.normpath(str(path)).startswith(osp.normpath(str(self.repo.working_tree_dir))):
+        if not osp.normpath(str(path)).startswith(str(self.repo.working_tree_dir)):
             raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir))
         return os.path.relpath(path, self.repo.working_tree_dir)
 

From 8327b82a1079f667006f649cb3f1bbdcc8792955 Mon Sep 17 00:00:00 2001
From: Andrej730 <azhilenkov@gmail.com>
Date: Sat, 14 Sep 2024 21:18:18 +0500
Subject: [PATCH 223/332] Fix test failing on unix

---
 test/test_index.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/test_index.py b/test/test_index.py
index efd5b83a6..c586a0b5a 100644
--- a/test/test_index.py
+++ b/test/test_index.py
@@ -1189,7 +1189,7 @@ def test_index_add_non_normalized_path(self, rw_repo):
         file.touch()
         non_normalized_path = file.as_posix()
         if os.name != "nt":
-            non_normalized_path = non_normalized_path.replace("/", "\\")
+            non_normalized_path = "/" + non_normalized_path[1:].replace("/", "//")
 
         rw_repo.index.add(non_normalized_path)
 

From 49ca9099dc75d0d686ec6737da36637cbee1c000 Mon Sep 17 00:00:00 2001
From: No big deal <69958306+alex20230721@users.noreply.github.com>
Date: Sat, 5 Oct 2024 17:23:19 +0800
Subject: [PATCH 224/332] Update base.py (#1965)

Improve documentation around opening repositories.

Co-authored-by: Sebastian Thiel <sebastian.thiel@icloud.com>
---
 git/repo/base.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/repo/base.py b/git/repo/base.py
index 346248ddb..db89cdf41 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -179,7 +179,7 @@ def __init__(
         R"""Create a new :class:`Repo` instance.
 
         :param path:
-            The path to either the root git directory or the bare git repo::
+            The path to either the worktree directory or the .git directory itself::
 
                 repo = Repo("/Users/mtrier/Development/git-python")
                 repo = Repo("/Users/mtrier/Development/git-python.git")

From 1bb465122f9673c9834b094c49d815148e84b8eb Mon Sep 17 00:00:00 2001
From: Florent Valette <florent.valette@ledger.fr>
Date: Mon, 14 Oct 2024 21:39:37 +0200
Subject: [PATCH 225/332] git,remote: use universal new lines for fetch/pull
 stderr capture

See https://github.com/gitpython-developers/GitPython/issues/1969

stderr parser call RemoteProgress update on each line received.
With universal_newlines set to False, there is a mixup between
line feed and carriage return.
In the `handle_process_output` thread, this is thus seen as a single
line for the whole output on each steps.
---
 git/remote.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/git/remote.py b/git/remote.py
index 9de3dace4..20e42b412 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -894,7 +894,7 @@ def _get_fetch_info_from_stderr(
             None,
             progress_handler,
             finalizer=None,
-            decode_streams=True,
+            decode_streams=False,
             kill_after_timeout=kill_after_timeout,
         )
 
@@ -1071,7 +1071,7 @@ def fetch(
             Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_fetch_options)
 
         proc = self.repo.git.fetch(
-            "--", self, *args, as_process=True, with_stdout=False, universal_newlines=False, v=verbose, **kwargs
+            "--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs
         )
         res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
         if hasattr(self.repo.odb, "update_cache"):
@@ -1125,7 +1125,7 @@ def pull(
             Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options)
 
         proc = self.repo.git.pull(
-            "--", self, refspec, with_stdout=False, as_process=True, universal_newlines=False, v=True, **kwargs
+            "--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs
         )
         res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
         if hasattr(self.repo.odb, "update_cache"):

From 52cceaf2663422a79a0f1d21f905eb132e46b556 Mon Sep 17 00:00:00 2001
From: Florent Valette <florent.valette@ledger.fr>
Date: Tue, 15 Oct 2024 18:04:44 +0200
Subject: [PATCH 226/332] git,cmd: add encoding arg to popen if universal
 newlines is True

---
 git/cmd.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/git/cmd.py b/git/cmd.py
index 90fc39cd6..2048a43fa 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -1269,6 +1269,7 @@ def execute(
                 stdout=stdout_sink,
                 shell=shell,
                 universal_newlines=universal_newlines,
+                encoding=defenc if universal_newlines else None,
                 **subprocess_kwargs,
             )
         except cmd_not_found_exception as err:

From d6cdb67bcaa2cf606bfc0a9295aacb54677ea86d Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Tue, 15 Oct 2024 20:35:29 +0200
Subject: [PATCH 227/332] See if python 3.7 still works when using an older
 Ubuntu version.

This should be undone once python 3.7 is EOL.
---
 .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 292c9fc86..747db62f0 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -13,7 +13,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        os: ["ubuntu-latest", "macos-latest", "windows-latest"]
+        os: ["ubuntu-22.04", "macos-latest", "windows-latest"]
         python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
         exclude:
         - os: "macos-latest"

From e51bf80ad576256f2fbeead41ea3f0b667c77055 Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Thu, 2 Jan 2025 08:24:01 +0100
Subject: [PATCH 228/332] update GitDB submodule to latest pubslished version

---
 git/ext/gitdb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/git/ext/gitdb b/git/ext/gitdb
index 3d3e9572d..775cfe829 160000
--- a/git/ext/gitdb
+++ b/git/ext/gitdb
@@ -1 +1 @@
-Subproject commit 3d3e9572dc452fea53d328c101b3d1440bbefe40
+Subproject commit 775cfe8299ea5474f605935469359a9d1cdb49dc

From fb1b05124f1070ed56231a782daee0ffce9e1372 Mon Sep 17 00:00:00 2001
From: Sebastian Thiel <sebastian.thiel@icloud.com>
Date: Thu, 2 Jan 2025 08:27:54 +0100
Subject: [PATCH 229/332] bump patch level to prepare new version

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

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

From 9429be69c85442e744ef697bd79bad3fb4236e0a Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 04:21:49 -0500
Subject: [PATCH 230/332] Special-case Python 3.7 for OSes we can install it on

This is analogous to the 3.7-related CI change in gitdb that was
part of https://github.com/gitpython-developers/gitdb/pull/114, as
to part of https://github.com/gitpython-developers/smmap/pull/58.

Since some tests are not yet passing on 3.13, this does not add
3.13 to CI, nor to the documentation of supported versions in
`setup.py`. Note that the list there is not enforced; GitPython can
already be installed on Python 3.13 and probably *mostly* works.

(See https://github.com/gitpython-developers/GitPython/pull/1955
for details on other changes that should be made to fully support
running GitPython on Python 3.13.)
---
 .github/workflows/pythonpackage.yml | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 747db62f0..deebe9e11 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -11,15 +11,19 @@ permissions:
 jobs:
   build:
     strategy:
-      fail-fast: false
       matrix:
-        os: ["ubuntu-22.04", "macos-latest", "windows-latest"]
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
-        exclude:
-        - os: "macos-latest"
-          python-version: "3.7"
+        os: [ubuntu-latest, macos-latest, windows-latest]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
         include:
         - experimental: false
+        - os: ubuntu-22.04
+          python-version: "3.7"
+          experimental: false
+        - os: windows-latest
+          python-version: "3.7"
+          experimental: false
+
+      fail-fast: false
 
     runs-on: ${{ matrix.os }}
 

From affab8eda6a716363bc36c703de305676afc4ae3 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 9 Dec 2024 13:14:20 +0000
Subject: [PATCH 231/332] Bump Vampire/setup-wsl from 3.1.1 to 4.0.0

Bumps [Vampire/setup-wsl](https://github.com/vampire/setup-wsl) from 3.1.1 to 4.0.0.
- [Release notes](https://github.com/vampire/setup-wsl/releases)
- [Commits](https://github.com/vampire/setup-wsl/compare/v3.1.1...v4.0.0)

---
updated-dependencies:
- dependency-name: Vampire/setup-wsl
  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 deebe9e11..b8e6391a1 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -44,7 +44,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: startsWith(matrix.os, 'windows')
-      uses: Vampire/setup-wsl@v3.1.1
+      uses: Vampire/setup-wsl@v4.0.0
       with:
         distribution: Alpine
         additional-packages: bash

From 01e40a7b06c4ba3d0cf937ba0974949392446b51 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 04:38:44 -0500
Subject: [PATCH 232/332] Do everything in the venv in the Alpine test

---
 .github/workflows/alpine-test.yml | 12 +++++-------
 1 file changed, 5 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml
index 2c1eed391..8c00b1086 100644
--- a/.github/workflows/alpine-test.yml
+++ b/.github/workflows/alpine-test.yml
@@ -16,10 +16,10 @@ jobs:
     steps:
     - name: Prepare Alpine Linux
       run: |
-        apk add sudo git git-daemon python3 py3-pip
+        apk add sudo git git-daemon python3 py3-pip py3-setuptools py3-virtualenv py3-wheel
         echo 'Defaults env_keep += "CI GITHUB_* RUNNER_*"' >/etc/sudoers.d/ci_env
         addgroup -g 127 docker
-        adduser -D -u 1001 runner
+        adduser -D -u 1001 runner  # TODO: Check if this still works on GHA as intended.
         adduser runner docker
       shell: sh -exo pipefail {0}  # Run this as root, not the "runner" user.
 
@@ -50,17 +50,14 @@ jobs:
         . .venv/bin/activate
         printf '%s=%s\n' 'PATH' "$PATH" 'VIRTUAL_ENV' "$VIRTUAL_ENV" >>"$GITHUB_ENV"
 
-    - 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
-
     - name: Install project and test dependencies
       run: |
+        . .venv/bin/activate
         pip install ".[test]"
 
     - name: Show version and platform information
       run: |
+        . .venv/bin/activate
         uname -a
         command -v git python
         git version
@@ -69,4 +66,5 @@ jobs:
 
     - name: Test with pytest
       run: |
+        . .venv/bin/activate
         pytest --color=yes -p no:sugar --instafail -vv

From 0300de986ef78a4e7a5562638592f5e91bfd8fa7 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 05:24:47 -0500
Subject: [PATCH 233/332] Instrument `handle_process_output` test

To try to find the bug that causes it to fail on Cygwin on CI, but
not on other systems on CI, and not locally on Cygwin.

It looks like there's an extra line being read from stderr when the
test fails, so let's examine the lines themselves.
---
 test/test_git.py | 21 ++++++++++++---------
 1 file changed, 12 insertions(+), 9 deletions(-)

diff --git a/test/test_git.py b/test/test_git.py
index 94e68ecf0..3f9687dfe 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -762,14 +762,17 @@ def test_environment(self, rw_dir):
     def test_handle_process_output(self):
         from git.cmd import handle_process_output, safer_popen
 
-        line_count = 5002
-        count = [None, 0, 0]
+        expected_line_count = 5002
+        line_counts = [None, 0, 0]
+        lines = [None, [], []]
 
-        def counter_stdout(line):
-            count[1] += 1
+        def stdout_handler(line):
+            line_counts[1] += 1
+            lines[1].append(line)
 
-        def counter_stderr(line):
-            count[2] += 1
+        def stderr_handler(line):
+            line_counts[2] += 1
+            lines[2].append(line)
 
         cmdline = [
             sys.executable,
@@ -784,10 +787,10 @@ def counter_stderr(line):
             shell=False,
         )
 
-        handle_process_output(proc, counter_stdout, counter_stderr, finalize_process)
+        handle_process_output(proc, stdout_handler, stderr_handler, finalize_process)
 
-        self.assertEqual(count[1], line_count)
-        self.assertEqual(count[2], line_count)
+        self.assertEqual(line_counts[1], expected_line_count, repr(lines[1]))
+        self.assertEqual(line_counts[2], expected_line_count, repr(lines[2]))
 
     def test_execute_kwargs_set_agrees_with_method(self):
         parameter_names = inspect.signature(cmd.Git.execute).parameters.keys()

From a0f8425c94992bdf3fdde9cbf7c3c7f9dc12e52c Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 05:28:31 -0500
Subject: [PATCH 234/332] Slightly simplify and clarify instrumented code

---
 test/test_git.py | 13 +++++--------
 1 file changed, 5 insertions(+), 8 deletions(-)

diff --git a/test/test_git.py b/test/test_git.py
index 3f9687dfe..274511f8d 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -763,16 +763,13 @@ def test_handle_process_output(self):
         from git.cmd import handle_process_output, safer_popen
 
         expected_line_count = 5002
-        line_counts = [None, 0, 0]
-        lines = [None, [], []]
+        actual_lines = [None, [], []]
 
         def stdout_handler(line):
-            line_counts[1] += 1
-            lines[1].append(line)
+            actual_lines[1].append(line)
 
         def stderr_handler(line):
-            line_counts[2] += 1
-            lines[2].append(line)
+            actual_lines[2].append(line)
 
         cmdline = [
             sys.executable,
@@ -789,8 +786,8 @@ def stderr_handler(line):
 
         handle_process_output(proc, stdout_handler, stderr_handler, finalize_process)
 
-        self.assertEqual(line_counts[1], expected_line_count, repr(lines[1]))
-        self.assertEqual(line_counts[2], expected_line_count, repr(lines[2]))
+        self.assertEqual(len(actual_lines[1]), expected_line_count, repr(actual_lines[1]))
+        self.assertEqual(len(actual_lines[2]), expected_line_count, repr(actual_lines[2]))
 
     def test_execute_kwargs_set_agrees_with_method(self):
         parameter_names = inspect.signature(cmd.Git.execute).parameters.keys()

From 4aad37a303ca51a594700976b04e17f0f835d61d Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 06:00:23 -0500
Subject: [PATCH 235/332] Improve environment isolation in Cygwin and Alpine
 Linux on CI

The main change here is to the Cygwin test workflow, which now runs
the tests in a venv (a Python virtual environment), to avoid mixing
up our intended dependencies with other files installed by Python
related packages on the system. This should either fix the problem
where `test_handle_process_output` reports an extra line in stderr
for the `cat_file.py` subprocess on CI or Cygwin, or at least make
it somewhat easier to continue investigating the problem. I think
this change is also valuable for its own sake.

The connection to the `test_handle_process_output` failure is that
the extra line in stderr appears at the beginning and is an error
message about a missing import needed for subprocess code coverage.
With the recently added more detailed error reporting, it shows:

            self.assertEqual(line_counts[1], expected_line_count, repr(lines[1]))
    >       self.assertEqual(line_counts[2], expected_line_count, repr(lines[2]))
    E       AssertionError: 5003 != 5002 : ['pytest-cov: Failed to setup subprocess coverage. Environ: {\'COV_CORE_SOURCE\': \'git\', \'COV_CORE_CONFIG\': \':\', \'COV_CORE_DATAFILE\': \'/cygdrive/d/a/GitPython/GitPython/.coverage\'} Exception: ModuleNotFoundError("No module named \'iniconfig\'")\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', ' = [up to date]      testcommit100 -> origin/testcommit100\n', ' = [up to date]      testcommit1000 -> origin/testcommit1000\n', ' = [up to date]      testcommit1001 -> origin/testcommit1001\n', ' = [up to date]      testcommit1002 -> origin/testcommit1002\n', ' = [up to date]      testcommit1003 -> origin/testcommit1003\n', ' = [up to date]      testcommit1004 -> origin/testcommit1004\n', ' = [up to date]      testcommit1005 -> origin/testcommit1005\n', ' = [up to date]      testcommit
    test/test_git.py:793: AssertionError

This could possibly be worked around by attempting to install a
package to provide `iniconfig`, by configuring `pytest-cov` in a
way that does not require it, or by temporarily disabling code
coverage reports on Cygwin. Before exploring those or other
options, this change seeks to prepare a more isolated environment
in which different package versions are more likely to work
properly together.

In addition to that change to the Cygwin workflow, this also
changes the way the Alpine Linux test workflow is made to use a
venv, using the technique that is used here, and undoing some
changes in the Alpine Linux workflow that should not be necessary.
The reason for making that change together with this Cygwin change
is that if either does not work as expected, it might shed light on
what is going wrong with the other.

Although the same technique is used on Cygwin and in Alpine Linux,
it looks a little different, because the environment variable on
Cygwin is `BASH_ENV` (since script steps are run in `bash`), while
the environment variable is `ENV` (since script steps are run in
`busybox sh`) and this must also be allowed to pass through `sudo`
(since `sh`, which is `busybox sh` on this system, is being invoked
through `sudo`).
---
 .github/workflows/alpine-test.yml | 15 ++++++++-------
 .github/workflows/cygwin-test.yml |  6 +++---
 2 files changed, 11 insertions(+), 10 deletions(-)

diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml
index 8c00b1086..ca141cf03 100644
--- a/.github/workflows/alpine-test.yml
+++ b/.github/workflows/alpine-test.yml
@@ -16,8 +16,8 @@ jobs:
     steps:
     - name: Prepare Alpine Linux
       run: |
-        apk add sudo git git-daemon python3 py3-pip py3-setuptools py3-virtualenv py3-wheel
-        echo 'Defaults env_keep += "CI GITHUB_* RUNNER_*"' >/etc/sudoers.d/ci_env
+        apk add sudo git git-daemon python3 py3-pip py3-virtualenv
+        echo 'Defaults env_keep += "CI ENV GITHUB_* RUNNER_*"' >/etc/sudoers.d/ci_env
         addgroup -g 127 docker
         adduser -D -u 1001 runner  # TODO: Check if this still works on GHA as intended.
         adduser runner docker
@@ -47,17 +47,19 @@ jobs:
     - name: Set up virtualenv
       run: |
         python -m venv .venv
-        . .venv/bin/activate
-        printf '%s=%s\n' 'PATH' "$PATH" 'VIRTUAL_ENV' "$VIRTUAL_ENV" >>"$GITHUB_ENV"
+        echo 'ENV=.venv/bin/activate' >> "$GITHUB_ENV"  # ENV (not BASH_ENV) for BusyBox sh.
+
+    - 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
 
     - name: Install project and test dependencies
       run: |
-        . .venv/bin/activate
         pip install ".[test]"
 
     - name: Show version and platform information
       run: |
-        . .venv/bin/activate
         uname -a
         command -v git python
         git version
@@ -66,5 +68,4 @@ jobs:
 
     - name: Test with pytest
       run: |
-        . .venv/bin/activate
         pytest --color=yes -p no:sugar --instafail -vv
diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index bde4ea659..ebe50240d 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -55,10 +55,10 @@ jobs:
         # and cause subsequent tests to fail
         cat test/fixtures/.gitconfig >> ~/.gitconfig
 
-    - name: Ensure the "pip" command is available
+    - name: Set up virtualenv
       run: |
-        # This is used unless, and before, an updated pip is installed.
-        ln -s pip3 /usr/bin/pip
+        python -m venv .venv
+        echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV"
 
     - name: Update PyPA packages
       run: |

From 39cd608b762256663b862224bcb46bdb2fc18817 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 06:28:48 -0500
Subject: [PATCH 236/332] Put back explicit venv activation in Alpine Linux

`busybox sh` does not appear to read commands from a file whose
path is given as the value of `$ENV`, in this situation. I think I
may have misunderstood that; the documentation does not say much
about it and maybe, in Almquist-style shells, it is only read by
interactive shells? I am not sure.

This removes everything about `ENV` and activates the venv in each
step where the venv should be used.

The good news is that the technique did work fully in Cygwin: not
only did `BASH_ENV` work (which was not much in doubt), but using
a virtual environment for all steps that run test code on Cygwin
fixed the problem and allowed all tests to pass. That seems to have
been the reason I didn't reproduce the problem locally: on my local
system I always use a venv in Cygwin since I would otherwise not
have adequate isolation.

Thus, this commit changes only the Alpine workflow and not the
Cygwin workflow.
---
 .github/workflows/alpine-test.yml | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml
index ca141cf03..6dc62f596 100644
--- a/.github/workflows/alpine-test.yml
+++ b/.github/workflows/alpine-test.yml
@@ -17,7 +17,7 @@ jobs:
     - name: Prepare Alpine Linux
       run: |
         apk add sudo git git-daemon python3 py3-pip py3-virtualenv
-        echo 'Defaults env_keep += "CI ENV GITHUB_* RUNNER_*"' >/etc/sudoers.d/ci_env
+        echo 'Defaults env_keep += "CI GITHUB_* RUNNER_*"' >/etc/sudoers.d/ci_env
         addgroup -g 127 docker
         adduser -D -u 1001 runner  # TODO: Check if this still works on GHA as intended.
         adduser runner docker
@@ -47,19 +47,21 @@ jobs:
     - name: Set up virtualenv
       run: |
         python -m venv .venv
-        echo 'ENV=.venv/bin/activate' >> "$GITHUB_ENV"  # ENV (not BASH_ENV) for BusyBox sh.
 
     - 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 $(pip freeze --all | grep -ow ^setuptools) wheel
 
     - name: Install project and test dependencies
       run: |
+        . .venv/bin/activate
         pip install ".[test]"
 
     - name: Show version and platform information
       run: |
+        . .venv/bin/activate
         uname -a
         command -v git python
         git version
@@ -68,4 +70,5 @@ jobs:
 
     - name: Test with pytest
       run: |
+        . .venv/bin/activate
         pytest --color=yes -p no:sugar --instafail -vv

From 8a05390925ef416736ce9c0be8569977ca48fa07 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 07:22:11 -0500
Subject: [PATCH 237/332] Tune CI matrix adjustments so reports are clearer

Since #1987, test jobs from `pythonpackage.yml` appear in an
unintuitive order, and some show an extra bool matrix variable in
their names while others don't (this corresponds to `experimental`,
which was always set to some value, but was set in different ways).

This fixes that by:

- Listing all tested versions, rather than introducing some in an
  `include` key. (The `include:`-introduced jobs didn't distinguish
  between originally-present matrix variables and those that are
  introduced based on the values of the original ones.)

- Replacing `os` with `os-type`, which has only the first part of
  the value for `runs-on:` (e.g., `ubuntu`), and adding `os-ver`
  to each matrix job, defaulting it to `latest`, but using `22.04`
  for Python 3.7 on Ubuntu.

This should also naturally extend to adding 3.13, with or without
setting `continue-on-error` to temporarily work around the problems
obseved in #1955, but nothing 3.13-related is done in this commit.
---
 .github/workflows/pythonpackage.yml | 23 ++++++++++++-----------
 1 file changed, 12 insertions(+), 11 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index b8e6391a1..aff6354f4 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -12,20 +12,21 @@ jobs:
   build:
     strategy:
       matrix:
-        os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
-        include:
-        - experimental: false
-        - os: ubuntu-22.04
+        os-type: [ubuntu, macos, windows]
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
+        exclude:
+        - os-type: macos
           python-version: "3.7"
-          experimental: false
-        - os: windows-latest
+        include:
+        - os-ver: latest
+        - os-type: ubuntu
           python-version: "3.7"
-          experimental: false
+          os-ver: "22.04"
+        - experimental: false
 
       fail-fast: false
 
-    runs-on: ${{ matrix.os }}
+    runs-on: ${{ matrix.os-type }}-${{ matrix.os-ver }}
 
     defaults:
       run:
@@ -43,7 +44,7 @@ jobs:
         allow-prereleases: ${{ matrix.experimental }}
 
     - name: Set up WSL (Windows)
-      if: startsWith(matrix.os, 'windows')
+      if: matrix.os-type == 'windows'
       uses: Vampire/setup-wsl@v4.0.0
       with:
         distribution: Alpine
@@ -80,7 +81,7 @@ jobs:
 
     # For debugging hook tests on native Windows systems that may have WSL.
     - name: Show bash.exe candidates (Windows)
-      if: startsWith(matrix.os, 'windows')
+      if: matrix.os-type == 'windows'
       run: |
         set +e
         bash.exe -c 'printenv WSL_DISTRO_NAME; uname -a'

From 41377d5254d3e4a03a7edd5adc8fd4b5a0767210 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 2 Jan 2025 07:39:19 -0500
Subject: [PATCH 238/332] Change test job keys from `build` to `test`

This goes a bit further in the direction of the preceding commit,
making CI reports/logs a bit more intuitive.
---
 .github/workflows/alpine-test.yml   | 2 +-
 .github/workflows/cygwin-test.yml   | 2 +-
 .github/workflows/pythonpackage.yml | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/alpine-test.yml b/.github/workflows/alpine-test.yml
index 6dc62f596..bd09a939b 100644
--- a/.github/workflows/alpine-test.yml
+++ b/.github/workflows/alpine-test.yml
@@ -3,7 +3,7 @@ name: test-alpine
 on: [push, pull_request, workflow_dispatch]
 
 jobs:
-  build:
+  test:
     runs-on: ubuntu-latest
 
     container:
diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index ebe50240d..575fb26ef 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -3,7 +3,7 @@ name: test-cygwin
 on: [push, pull_request, workflow_dispatch]
 
 jobs:
-  build:
+  test:
     runs-on: windows-latest
 
     strategy:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index aff6354f4..9225624f0 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -9,7 +9,7 @@ permissions:
   contents: read
 
 jobs:
-  build:
+  test:
     strategy:
       matrix:
         os-type: [ubuntu, macos, windows]

From 73ddb22f5c06fc3f09addf9be176d569d770b469 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sat, 4 Jan 2025 09:55:50 -0500
Subject: [PATCH 239/332] Continue testing Python 3.9 on CI but unpin 3.9.16-1

We pinned Python 3.9.16 on Cygwin CI in #1814 (by requiring
3.9.16-1 as the exact version of the `python39` Cygwin package,
along with other supporting changes). We did this to solve a
problem where Python 3.9.18-1, which contained a bug that broke
GitPython CI (and various other software), would be selected.

Version 3.9.18-1 was marked back to being a "test" package shortly
after the bug was reported, and was subsequently removed altogether
from the Cygwin repositories. Because the affected package version
effectively no longer exists, and because this issue is known and
a non-"test" version still affected by it is very unlikely to be
released in the future, this pinning has been decisively
unnecessary for some time, though still not harmful.

This commit undoes the pinning, so that the `python39` package can
be installed at a higher version if one becomes available. This
serves two purposes.

- There is work under way in porting Python 3.12 to Cygwin. To test
  this with GitPython (either while it is in development or later),
  it will be useful to turn the Cygwin test job into a matrix job
  definition, generating two jobs, one for Python 3.9 and one for
  Python 3.12. Since 3.12 will probably not benefit from pinning,
  dropping pinning simplifies this.

- If the port of Python 3.12 to Cygwin is successful, it might
  lead to a solution to the but that currently keeps 3.9.18 from
  being made available for Cygwin. In that case, another 3.9.18-*
  Cygwin package would be released, which we would want to use.

Although this is uncertain, the change is a simplification, so I
think it is reasonable to do now.

Note that the pinning being undone here only affects the
distinction between different 3.9.* versions. `python39` and
`python312` are different Cygwin packages altogether, with
correspondingly different `python39-*` and `python312-*` associated
packages; this is not unpinning Python 3.9 in a way that would
cause Python 3.12 to be selected instead of it.
---
 .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 575fb26ef..d42eb0587 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: Set up Cygwin
       uses: egor-tensin/setup-cygwin@v4
       with:
-        packages: python39=3.9.16-1 python39-pip python39-virtualenv git
+        packages: python39 python39-pip python39-virtualenv git
 
     - name: Arrange for verbose output
       run: |

From 85d72ef55eb3ba64f5db6016198724ec45769961 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 5 Jan 2025 02:38:34 -0500
Subject: [PATCH 240/332] Test Python 3.13 on Ubuntu and macOS on CI

But not Windows yet (#1955).
---
 .github/workflows/pythonpackage.yml | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 9225624f0..245844972 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -13,10 +13,12 @@ jobs:
     strategy:
       matrix:
         os-type: [ubuntu, macos, windows]
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
         exclude:
         - os-type: macos
-          python-version: "3.7"
+          python-version: "3.7"  # Not available for the ARM-based macOS runners.
+        - os-type: windows
+          python-version: "3.13"  # FIXME: Fix and enable Python 3.13 on Windows (#1955).
         include:
         - os-ver: latest
         - os-type: ubuntu

From b20de09016ce221943a7bc4c7b67be5bacad9a15 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 5 Jan 2025 03:24:28 -0500
Subject: [PATCH 241/332] Affirm that gitdb and smmap advisories can also be
 created

This expands `SECURITY.md` to affirm the claims in the new
`SECURITY.md` files in gitdb and smmap that vulnerabilities found
in them can be reported in the GitPython repository with the same
link as one would use to report a GitPython vulnerability, as well
as to note how the distinction between affected package can be
specified when it is known at the time a vulnerability is reported.

Along with https://github.com/gitpython-developers/smmap/pull/59
and https://github.com/gitpython-developers/gitdb/pull/117, this
fixes https://github.com/gitpython-developers/gitdb/issues/116.
---
 SECURITY.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/SECURITY.md b/SECURITY.md
index d39425b70..3f7d9f27e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,4 +11,6 @@ Only the latest version of GitPython can receive security updates. If a vulnerab
 
 ## Reporting a Vulnerability
 
-Please report private portions of a vulnerability to <https://github.com/gitpython-developers/GitPython/security/advisories/new>. Doing so helps to receive updates and collaborate on the matter, without disclosing it publicliy right away.
+Please report private portions of a vulnerability to <https://github.com/gitpython-developers/GitPython/security/advisories/new>. Doing so helps to receive updates and collaborate on the matter, without disclosing it publicly right away.
+
+Vulnerabilities in GitPython's dependencies [gitdb](https://github.com/gitpython-developers/gitdb/blob/main/SECURITY.md) or [smmap](https://github.com/gitpython-developers/smmap/blob/main/SECURITY.md), which primarily exist to support GitPython, can be reported here as well, at that same link. The affected package (`GitPython`, `gitdb`, or `smmap`) can be included in the report, if known.

From 55f36e64d79e0e8acc8f8763af5e7b18a3b214f6 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Sun, 5 Jan 2025 11:51:31 -0500
Subject: [PATCH 242/332] Fix links to gitdb and smmap `SECURITY.md` files

The links in #1991 did not work, as I got the branch names wrong.
---
 SECURITY.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/SECURITY.md b/SECURITY.md
index 3f7d9f27e..0aea34845 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -13,4 +13,4 @@ Only the latest version of GitPython can receive security updates. If a vulnerab
 
 Please report private portions of a vulnerability to <https://github.com/gitpython-developers/GitPython/security/advisories/new>. Doing so helps to receive updates and collaborate on the matter, without disclosing it publicly right away.
 
-Vulnerabilities in GitPython's dependencies [gitdb](https://github.com/gitpython-developers/gitdb/blob/main/SECURITY.md) or [smmap](https://github.com/gitpython-developers/smmap/blob/main/SECURITY.md), which primarily exist to support GitPython, can be reported here as well, at that same link. The affected package (`GitPython`, `gitdb`, or `smmap`) can be included in the report, if known.
+Vulnerabilities in GitPython's dependencies [gitdb](https://github.com/gitpython-developers/gitdb/blob/master/SECURITY.md) or [smmap](https://github.com/gitpython-developers/smmap/blob/master/SECURITY.md), which primarily exist to support GitPython, can be reported here as well, at that same link. The affected package (`GitPython`, `gitdb`, or `smmap`) can be included in the report, if known.

From 80096b95e119e90c9324f5ba898705fda4581c84 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 6 Jan 2025 13:38:24 +0000
Subject: [PATCH 243/332] Bump Vampire/setup-wsl from 4.0.0 to 4.1.0

Bumps [Vampire/setup-wsl](https://github.com/vampire/setup-wsl) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/vampire/setup-wsl/releases)
- [Commits](https://github.com/vampire/setup-wsl/compare/v4.0.0...v4.1.0)

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

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 245844972..68b4fd8f9 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -47,7 +47,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: matrix.os-type == 'windows'
-      uses: Vampire/setup-wsl@v4.0.0
+      uses: Vampire/setup-wsl@v4.1.0
       with:
         distribution: Alpine
         additional-packages: bash

From fb102cfc5a86155d2c8b8f10cf5957f3f5d9e435 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 6 Jan 2025 13:45:04 +0000
Subject: [PATCH 244/332] Bump git/ext/gitdb from `775cfe8` to `9e68ea1`

Bumps [git/ext/gitdb](https://github.com/gitpython-developers/gitdb) from `775cfe8` to `9e68ea1`.
- [Release notes](https://github.com/gitpython-developers/gitdb/releases)
- [Commits](https://github.com/gitpython-developers/gitdb/compare/775cfe8299ea5474f605935469359a9d1cdb49dc...9e68ea1687bbda84776c3605b96bb0b43e846bea)

---
updated-dependencies:
- dependency-name: git/ext/gitdb
  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 775cfe829..9e68ea168 160000
--- a/git/ext/gitdb
+++ b/git/ext/gitdb
@@ -1 +1 @@
-Subproject commit 775cfe8299ea5474f605935469359a9d1cdb49dc
+Subproject commit 9e68ea1687bbda84776c3605b96bb0b43e846bea

From e4f1aa71dd255583ff19c1bd40410e94da8e15af Mon Sep 17 00:00:00 2001
From: Frank Lichtenheld <frank@lichtenheld.com>
Date: Thu, 9 Jan 2025 17:57:51 +0100
Subject: [PATCH 245/332] Repo.rev_parse: Handle <tag>^{commit} correctly

This should resolve to commit object.

Fixes: #1995

Signed-off-by: Frank Lichtenheld <frank@lichtenheld.com>
---
 git/repo/fun.py   | 8 +++++++-
 test/test_repo.py | 4 ++--
 2 files changed, 9 insertions(+), 3 deletions(-)

diff --git a/git/repo/fun.py b/git/repo/fun.py
index 182cf82ed..125ba5936 100644
--- a/git/repo/fun.py
+++ b/git/repo/fun.py
@@ -301,7 +301,13 @@ def rev_parse(repo: "Repo", rev: str) -> AnyGitObject:
 
             # Handle type.
             if output_type == "commit":
-                pass  # Default.
+                obj = cast("TagObject", obj)
+                if obj and obj.type == "tag":
+                    obj = deref_tag(obj)
+                else:
+                    # Cannot do anything for non-tags.
+                    pass
+                # END handle tag
             elif output_type == "tree":
                 try:
                     obj = cast(AnyGitObject, obj)
diff --git a/test/test_repo.py b/test/test_repo.py
index e38da5bb6..bfa1bbb78 100644
--- a/test/test_repo.py
+++ b/test/test_repo.py
@@ -1064,9 +1064,9 @@ def test_rev_parse(self):
         # TODO: Dereference tag into a blob 0.1.7^{blob} - quite a special one.
         # Needs a tag which points to a blob.
 
-        # ref^0 returns commit being pointed to, same with ref~0, and ^{}
+        # ref^0 returns commit being pointed to, same with ref~0, ^{}, and ^{commit}
         tag = rev_parse("0.1.4")
-        for token in ("~0", "^0", "^{}"):
+        for token in ("~0", "^0", "^{}", "^{commit}"):
             self.assertEqual(tag.object, rev_parse("0.1.4%s" % token))
         # END handle multiple tokens
 

From 7751d0b98920f9fc74b2f109e92cb0abf3a98b15 Mon Sep 17 00:00:00 2001
From: David Lakin <github@themoderndev.com>
Date: Fri, 10 Jan 2025 13:52:56 -0500
Subject: [PATCH 246/332] Fuzzing: Fix broken test for Git submodule handling

Ensured submodule names, paths, and commit messages are sanitized to
avoid invalid states that are expected to cause exceptions and should
not halt the fuzzer.

In particular, the changes here:
- Sanitized inputs for submodule names, paths, and commit messages.
- Added validation for submodule SHA and path integrity.
---
 fuzzing/fuzz-targets/fuzz_submodule.py | 59 ++++++++++++++++++--------
 1 file changed, 42 insertions(+), 17 deletions(-)

diff --git a/fuzzing/fuzz-targets/fuzz_submodule.py b/fuzzing/fuzz-targets/fuzz_submodule.py
index d22b0aa5b..afa653d0d 100644
--- a/fuzzing/fuzz-targets/fuzz_submodule.py
+++ b/fuzzing/fuzz-targets/fuzz_submodule.py
@@ -9,11 +9,17 @@
     get_max_filename_length,
 )
 
-# Setup the git environment
+# Setup the Git environment
 setup_git_environment()
 from git import Repo, GitCommandError, InvalidGitRepositoryError
 
 
+def sanitize_input(input_str, max_length=255):
+    """Sanitize and truncate inputs to avoid invalid Git operations."""
+    sanitized = "".join(ch for ch in input_str if ch.isalnum() or ch in ("-", "_", "."))
+    return sanitized[:max_length]
+
+
 def TestOneInput(data):
     fdp = atheris.FuzzedDataProvider(data)
 
@@ -24,12 +30,23 @@ def TestOneInput(data):
         try:
             with tempfile.TemporaryDirectory() as submodule_temp_dir:
                 sub_repo = Repo.init(submodule_temp_dir, bare=fdp.ConsumeBool())
-                sub_repo.index.commit(fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512)))
+                commit_message = sanitize_input(fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 512)))
+                sub_repo.index.commit(commit_message)
 
-                submodule_name = fdp.ConsumeUnicodeNoSurrogates(
-                    fdp.ConsumeIntInRange(1, max(1, get_max_filename_length(repo.working_tree_dir)))
+                submodule_name = sanitize_input(
+                    fdp.ConsumeUnicodeNoSurrogates(
+                        fdp.ConsumeIntInRange(1, get_max_filename_length(repo.working_tree_dir))
+                    )
                 )
-                submodule_path = os.path.join(repo.working_tree_dir, submodule_name)
+
+                submodule_path = os.path.relpath(
+                    os.path.join(repo.working_tree_dir, submodule_name),
+                    start=repo.working_tree_dir,
+                )
+
+                # Ensure submodule_path is valid
+                if not submodule_name or submodule_name.startswith("/") or ".." in submodule_name:
+                    return -1  # Reject invalid input so they are not added to the corpus
 
                 submodule = repo.create_submodule(submodule_name, submodule_path, url=sub_repo.git_dir)
                 repo.index.commit("Added submodule")
@@ -39,25 +56,38 @@ def TestOneInput(data):
                     value_length = fdp.ConsumeIntInRange(1, max(1, fdp.remaining_bytes()))
 
                     writer.set_value(
-                        fdp.ConsumeUnicodeNoSurrogates(key_length), fdp.ConsumeUnicodeNoSurrogates(value_length)
+                        sanitize_input(fdp.ConsumeUnicodeNoSurrogates(key_length)),
+                        sanitize_input(fdp.ConsumeUnicodeNoSurrogates(value_length)),
                     )
                     writer.release()
 
-                submodule.update(init=fdp.ConsumeBool(), dry_run=fdp.ConsumeBool(), force=fdp.ConsumeBool())
+                submodule.update(
+                    init=fdp.ConsumeBool(),
+                    dry_run=fdp.ConsumeBool(),
+                    force=fdp.ConsumeBool(),
+                )
+
                 submodule_repo = submodule.module()
 
-                new_file_name = fdp.ConsumeUnicodeNoSurrogates(
-                    fdp.ConsumeIntInRange(1, max(1, get_max_filename_length(submodule_repo.working_tree_dir)))
+                new_file_name = sanitize_input(
+                    fdp.ConsumeUnicodeNoSurrogates(
+                        fdp.ConsumeIntInRange(1, get_max_filename_length(submodule_repo.working_tree_dir))
+                    )
                 )
                 new_file_path = os.path.join(submodule_repo.working_tree_dir, new_file_name)
                 with open(new_file_path, "wb") as new_file:
                     new_file.write(fdp.ConsumeBytes(fdp.ConsumeIntInRange(1, 512)))
+
                 submodule_repo.index.add([new_file_path])
                 submodule_repo.index.commit("Added new file to submodule")
 
                 repo.submodule_update(recursive=fdp.ConsumeBool())
-                submodule_repo.head.reset(commit="HEAD~1", working_tree=fdp.ConsumeBool(), head=fdp.ConsumeBool())
-                # Use fdp.PickValueInList to ensure at least one of 'module' or 'configuration' is True
+                submodule_repo.head.reset(
+                    commit="HEAD~1",
+                    working_tree=fdp.ConsumeBool(),
+                    head=fdp.ConsumeBool(),
+                )
+
                 module_option_value, configuration_option_value = fdp.PickValueInList(
                     [(True, False), (False, True), (True, True)]
                 )
@@ -82,12 +112,7 @@ def TestOneInput(data):
         ):
             return -1
         except Exception as e:
-            if isinstance(e, ValueError) and "embedded null byte" in str(e):
-                return -1
-            elif isinstance(e, OSError) and "File name too long" in str(e):
-                return -1
-            else:
-                return handle_exception(e)
+            return handle_exception(e)
 
 
 def main():

From 5c547f33063d811f445c82de19b0d2f6aad0e995 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 27 Jan 2025 13:17:13 +0000
Subject: [PATCH 247/332] Bump git/ext/gitdb from `9e68ea1` to `f36c0cc`

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

---
updated-dependencies:
- dependency-name: git/ext/gitdb
  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 9e68ea168..f36c0cc42 160000
--- a/git/ext/gitdb
+++ b/git/ext/gitdb
@@ -1 +1 @@
-Subproject commit 9e68ea1687bbda84776c3605b96bb0b43e846bea
+Subproject commit f36c0cc42ea2f529291e441073f74e920988d4d2

From 9e22a4a5811157e59a61778285d79b686bbec9ad Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 21 Feb 2025 05:17:30 -0500
Subject: [PATCH 248/332] Run `python3.9` explicitly on Cygwin CI

In case somehow another one leaked in.
---
 .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 d42eb0587..0a66a9b46 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -57,7 +57,7 @@ jobs:
 
     - name: Set up virtualenv
       run: |
-        python -m venv .venv
+        python3.9 -m venv .venv
         echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV"
 
     - name: Update PyPA packages

From f401b1020f4177b217d679d71a41df28754bef6f Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 21 Feb 2025 05:19:56 -0500
Subject: [PATCH 249/332] Switch back to cygwin/cygwin-install-action

Since we don't need pinning, and this avoids installing and using
the chocolatey package manager, which we're not using for anything
else.
---
 .github/workflows/cygwin-test.yml | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index 0a66a9b46..fbf9b8307 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -15,7 +15,7 @@ jobs:
 
     defaults:
       run:
-        shell: C:\tools\cygwin\bin\bash.exe --login --norc -eo pipefail -o igncr "{0}"
+        shell: C:\cygwin\bin\bash.exe --login --norc -eo pipefail -o igncr "{0}"
 
     steps:
     - name: Force LF line endings
@@ -27,10 +27,11 @@ jobs:
       with:
         fetch-depth: 0
 
-    - name: Set up Cygwin
-      uses: egor-tensin/setup-cygwin@v4
+    - name: Install Cygwin
+      uses: cygwin/cygwin-install-action@v5
       with:
         packages: python39 python39-pip python39-virtualenv git
+        add-to-path: false  # No need to change $PATH outside the Cygwin environment.
 
     - name: Arrange for verbose output
       run: |

From 4605dd690b6ebe4c0c07f3910607aae407d6070e Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 21 Feb 2025 05:30:50 -0500
Subject: [PATCH 250/332] Install pip in venv in separate step

---
 .github/workflows/cygwin-test.yml | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index fbf9b8307..a2e7f6b2d 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -58,9 +58,13 @@ jobs:
 
     - name: Set up virtualenv
       run: |
-        python3.9 -m venv .venv
+        python3.9 -m venv --without-pip .venv
         echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV"
 
+    - name: Install pip in virtualenv
+      run: |
+        python -m ensurepip
+
     - name: Update PyPA packages
       run: |
         # Get the latest pip, wheel, and prior to Python 3.12, setuptools.

From a5fee410ec8d40264d7a53cc5c4fd9deb0c97dd5 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 21 Feb 2025 05:40:26 -0500
Subject: [PATCH 251/332] Install Cygwin package for wheel

---
 .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 a2e7f6b2d..d4aee4544 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: Install Cygwin
       uses: cygwin/cygwin-install-action@v5
       with:
-        packages: python39 python39-pip python39-virtualenv git
+        packages: python39 python39-pip python39-virtualenv python39-wheel git
         add-to-path: false  # No need to change $PATH outside the Cygwin environment.
 
     - name: Arrange for verbose output

From a2b73cac7c252f3c35169dd8a752614614571d10 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 21 Feb 2025 05:45:30 -0500
Subject: [PATCH 252/332] Use the pip bootstrap script instead

---
 .github/workflows/cygwin-test.yml | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml
index d4aee4544..585c419aa 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: Install Cygwin
       uses: cygwin/cygwin-install-action@v5
       with:
-        packages: python39 python39-pip python39-virtualenv python39-wheel git
+        packages: python39 python39-pip python39-virtualenv git
         add-to-path: false  # No need to change $PATH outside the Cygwin environment.
 
     - name: Arrange for verbose output
@@ -61,9 +61,9 @@ jobs:
         python3.9 -m venv --without-pip .venv
         echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV"
 
-    - name: Install pip in virtualenv
+    - name: Bootstrap pip in virtualenv
       run: |
-        python -m ensurepip
+        wget -qO- https://bootstrap.pypa.io/get-pip.py | python
 
     - name: Update PyPA packages
       run: |

From d597fc949c48726b34c2130424a044020960e5f2 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Fri, 21 Feb 2025 05:51:25 -0500
Subject: [PATCH 253/332] Install wget for Cygwin job to use to get pip
 bootstrap script

---
 .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 585c419aa..278777907 100644
--- a/.github/workflows/cygwin-test.yml
+++ b/.github/workflows/cygwin-test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: Install Cygwin
       uses: cygwin/cygwin-install-action@v5
       with:
-        packages: python39 python39-pip python39-virtualenv git
+        packages: python39 python39-pip python39-virtualenv git wget
         add-to-path: false  # No need to change $PATH outside the Cygwin environment.
 
     - name: Arrange for verbose output

From 1277baa811e5500cf4aaae1569137fe1e6b4c3cb Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Feb 2025 14:53:19 +0000
Subject: [PATCH 254/332] Bump Vampire/setup-wsl from 4.1.0 to 4.1.1

Bumps [Vampire/setup-wsl](https://github.com/vampire/setup-wsl) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/vampire/setup-wsl/releases)
- [Commits](https://github.com/vampire/setup-wsl/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: Vampire/setup-wsl
  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 68b4fd8f9..4850f252c 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -47,7 +47,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: matrix.os-type == 'windows'
-      uses: Vampire/setup-wsl@v4.1.0
+      uses: Vampire/setup-wsl@v4.1.1
       with:
         distribution: Alpine
         additional-packages: bash

From 2ae697d02182d62ed1c17df370d7aa3a2b67cbce Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Wed, 5 Mar 2025 23:00:42 -0500
Subject: [PATCH 255/332] Mark `test_installation` xfail on Cygwin CI

Together with #2007, this works around #2004, allowing all tests to
pass on Cygwin CI.

In #2007, installation of the environment in which tests run was
fixed by downloading and running the `get-pip.py` bootstrap script.
If we were to modify our helper that sets up the (separate) virtual
environment in `test_installation` so that it does the same thing
(or conditionally does so on CI, since the problem does not seem to
happen in local installations), that would likely "fix" this more
thoroughly, allowing the test to pass.

But part of the goal of the installation test is to test that
installation works in a typical environment on the platform it runs
on. So it is not obivous that making it pass in that way would be
an improvement compared to marking it `xfail` with the exception
type that occurs due to #2004. So this just does that, for now.
---
 test/test_installation.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/test/test_installation.py b/test/test_installation.py
index ae6472e98..a35826bd0 100644
--- a/test/test_installation.py
+++ b/test/test_installation.py
@@ -4,11 +4,19 @@
 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 54e1c1bfd14fc055fb8f7324154fd0658ac0d16f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 12 Mar 2025 11:55:36 +0000
Subject: [PATCH 256/332] Bump Vampire/setup-wsl from 4.1.1 to 5.0.0

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

---
updated-dependencies:
- dependency-name: Vampire/setup-wsl
  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 4850f252c..039699af5 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -47,7 +47,7 @@ jobs:
 
     - name: Set up WSL (Windows)
       if: matrix.os-type == 'windows'
-      uses: Vampire/setup-wsl@v4.1.1
+      uses: Vampire/setup-wsl@v5.0.0
       with:
         distribution: Alpine
         additional-packages: bash

From a41a0de46d2fdbb34207161bb0c180bd72958c8b Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 6 Mar 2025 19:06:46 -0500
Subject: [PATCH 257/332] Use WSL1 on CI

This avoids an occasional HTTP 403 error updating WSL for WSL2.

For details on that issue and possible approaches, see:
https://github.com/gitpython-developers/GitPython/pull/2008#pullrequestreview-2665805369
---
 .github/workflows/pythonpackage.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 039699af5..1a0210723 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -49,6 +49,7 @@ jobs:
       if: matrix.os-type == 'windows'
       uses: Vampire/setup-wsl@v5.0.0
       with:
+        wsl-version: 1
         distribution: Alpine
         additional-packages: bash
 

From c13d998c03ec5268b5b6c361fe0c65854041b684 Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 6 Mar 2025 19:45:41 -0500
Subject: [PATCH 258/332] Test on free-threaded Python

See #2005. Right now, this does not limit by operating system, but
that is just to verify that there are no OS-specific 3.13t problems
we should know about right now; once that is verified, the macOS
and Windows jobs will be removed (excluded) for the time being.

The 3.13t jobs added here use `Quansight-Labs/setup-python`, not
`actions/setup-python`. The latter also has the ability to use
3.13t since https://github.com/actions/python-versions/pull/319
and https://github.com/actions/setup-python/pull/973 (see also
https://github.com/actions/setup-python/issues/771), but no version
tag includes this feature yet. It can be used by using `@main` or
`@...` where `...` is an OID. The former would risk pulling in
other untested features we're no trying to test with, while the
latter would not be easy to upgrade automatically as what we have
now (we would be deliberately keeping a hash not at any tag that is
already not the latest hash on any branch). In contrast, the
`Quansight-Labs/setup-python` fork adds this feature while staying
up to date with others. When `actions/setup-python` has a release
(stable or prerelease) with this feature, we can switch to it.

This could probably be done with less code duplication by using a
matrix variable for the action to use. Instead, the "Set up Python"
step is split in two, with opposite `if` conditions, so that each
is capable of being recognized and upgraded by Dependabot if a new
major version is released (in case this ends up remaining in place
longer than expected).
---
 .github/workflows/pythonpackage.yml | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 039699af5..b24c5cc57 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -13,7 +13,7 @@ jobs:
     strategy:
       matrix:
         os-type: [ubuntu, macos, windows]
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"]
         exclude:
         - os-type: macos
           python-version: "3.7"  # Not available for the ARM-based macOS runners.
@@ -40,11 +40,20 @@ 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 56038c3e0382d87ccdb66d53964f038314c157fd Mon Sep 17 00:00:00 2001
From: Eliah Kagan <degeneracypressure@gmail.com>
Date: Thu, 6 Mar 2025 22:39:23 -0500
Subject: [PATCH 259/332] Only test free-threaded Python on Linux

For now, this omits macOS and Windows from the 3.13t ("threaded")
tests.

The plan in #2005 is to start without them, and no OS-specific
problems have been identified so far. In particular, in the
previous commit that adds 3.13t without excluding any operating
systems, all tests in the macOS job passed as expected, and the
Windows job had the same failure with the same message as in #1955,
with no XFAIL changed to XPASS (which, if present, would suggest
GC differences meriting further exploration of 3.13t on Windows).
---
 .github/workflows/pythonpackage.yml | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index b24c5cc57..661df0693 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -17,8 +17,12 @@ jobs:
         exclude:
         - os-type: macos
           python-version: "3.7"  # Not available for the ARM-based macOS runners.
+        - os-type: macos
+          python-version: "3.13t"
         - os-type: windows
           python-version: "3.13"  # FIXME: Fix and enable Python 3.13 on Windows (#1955).
+        - os-type: windows
+          python-version: "3.13t"
         include:
         - os-ver: latest
         - os-type: ubuntu

From 11f7fafada8348c8e8f699c7ab621be6d26b00a5 Mon Sep 17 00:00:00 2001
From: Kamil Kozik <kamilkozik7@gmail.com>
Date: Fri, 7 Mar 2025 18:39:06 +0100
Subject: [PATCH 260/332] `IndexFile._to_relative_path` - fix case where
 absolute path gets stripped of trailing slash

---
 git/index/base.py  |  5 ++++-
 test/test_index.py | 22 ++++++++++++++++++++++
 2 files changed, 26 insertions(+), 1 deletion(-)

diff --git a/git/index/base.py b/git/index/base.py
index 39cc9143c..65b1f9308 100644
--- a/git/index/base.py
+++ b/git/index/base.py
@@ -655,7 +655,10 @@ def _to_relative_path(self, path: PathLike) -> PathLike:
             raise InvalidGitRepositoryError("require non-bare repository")
         if not osp.normpath(str(path)).startswith(str(self.repo.working_tree_dir)):
             raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir))
-        return os.path.relpath(path, self.repo.working_tree_dir)
+        result = os.path.relpath(path, self.repo.working_tree_dir)
+        if str(path).endswith(os.sep) and not result.endswith(os.sep):
+            result += os.sep
+        return result
 
     def _preprocess_add_items(
         self, items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]]
diff --git a/test/test_index.py b/test/test_index.py
index c586a0b5a..c42032e70 100644
--- a/test/test_index.py
+++ b/test/test_index.py
@@ -16,6 +16,7 @@
 import subprocess
 import sys
 import tempfile
+from unittest import mock
 
 from gitdb.base import IStream
 
@@ -1015,6 +1016,27 @@ class Mocked:
         rel = index._to_relative_path(path)
         self.assertEqual(rel, os.path.relpath(path, root))
 
+    def test__to_relative_path_absolute_trailing_slash(self):
+        repo_root = os.path.join(osp.abspath(os.sep), "directory1", "repo_root")
+
+        class Mocked:
+            bare = False
+            git_dir = repo_root
+            working_tree_dir = repo_root
+
+        repo = Mocked()
+        path = os.path.join(repo_root, f"directory2{os.sep}")
+        index = IndexFile(repo)
+
+        expected_path = f"directory2{os.sep}"
+        actual_path = index._to_relative_path(path)
+        self.assertEqual(expected_path, actual_path)
+
+        with mock.patch("git.index.base.os.path") as ospath_mock:
+            ospath_mock.relpath.return_value = f"directory2{os.sep}"
+            actual_path = index._to_relative_path(path)
+            self.assertEqual(expected_path, actual_path)
+
     @pytest.mark.xfail(
         type(_win_bash_status) is WinBashStatus.Absent,
         reason="Can't run a hook on Windows without bash.exe.",

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 261/332] 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 262/332] 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 263/332] 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 264/332] 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 265/332] 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 266/332] 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 267/332] 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 268/332] 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 269/332] 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 270/332] 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 271/332] 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 272/332] 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 273/332] 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 274/332] 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 275/332] 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 276/332] 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 277/332] 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 278/332] 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 279/332] 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 280/332] 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 281/332] 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 282/332] 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 283/332] 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 284/332] 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 285/332] 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 286/332] 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 287/332] 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 288/332] 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 289/332] 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 290/332] 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 291/332] 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 292/332] 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 293/332] 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 294/332] 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 295/332] 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 296/332] 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 297/332] 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 298/332] 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 299/332] 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 300/332] 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 301/332] 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 302/332] 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 303/332] 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 304/332] 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 305/332] 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 306/332] 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 307/332] 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 308/332] 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 309/332] 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 310/332] 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 311/332] 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 312/332] 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 313/332] 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 314/332] 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 315/332] 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 316/332] 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 317/332] 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 318/332] 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 319/332] 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 320/332] 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 321/332] 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 322/332] 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 323/332] 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 324/332] 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 325/332] 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 326/332] 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 327/332] 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 328/332] 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 329/332] 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 330/332] 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 331/332] 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 332/332] 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
 
