new file mode 100644
@@ -0,0 +1,394 @@
+From cdfa4af79da77cb35c8a0a9629573eb46a8c6869 Mon Sep 17 00:00:00 2001
+From: Byron <sebastian.thiel@icloud.com>
+Date: Tue, 25 Aug 2026 04:57:29 +0200
+Subject: [PATCH] fix: prefer `.git` during repository discovery
+
+<!-- agent -->
+GitPython considered worktree administration and bare-repository signatures
+before a worktree's real .git entry. Align discovery with Git so .git files
+and directories win, malformed .git files stop discovery, and candidate git
+directories validate HEAD plus commondir-backed object and ref storage. This
+addresses GHSA-239g-whfq-7xj9.
+
+Regression coverage compares ambiguous layouts with git rev-parse and rejects
+invalid HEAD/.git metadata.
+
+Git baseline: 15c6308cf7ad276b306aa5b3ababfbdebfb1a917; setup.c
+setup_git_directory_gently_1(), is_git_directory(), and validate_headref().
+
+Repository validation can use GIT_COMMON_DIR for refs and objects. Preserve the
+same value on Repo so later config, ref, and object access uses the directory
+that made discovery succeed.
+
+Capture GIT_COMMON_DIR before the first repository config read so bare-state
+detection uses the same metadata location as discovery. Resolve relative
+environment values immediately so later working-directory changes cannot
+retarget the Repo.
+
+Review feedback: relative GIT_COMMON_DIR left Git subprocesses resolving GIT_DIR
+and GIT_COMMON_DIR from a different working directory; malformed commondir data,
+empty GIT_OBJECT_DIRECTORY, and the linked-worktree signature were also handled
+inconsistently.
+
+Pin the repository environment to resolved paths, reject invalid
+metadata without consulting the process working directory, and
+restore HEAD-based linked-worktree detection. Keep the loose HEAD
+and dangling .git behavior because both match Git setup.c at baseline
+15c6308cf7ad276b306aa5b3ababfbdebfb1a917.
+
+Review feedback identified that chained or self-referential .git pointers
+recurse, filesystem-encoded metadata can fail text decoding, and a relative
+GIT_DIR is not retained for later Git commands.
+
+Parse one regular, size-bounded Gitfile exactly once, decode Gitfile and
+commondir paths with the filesystem codec, and retain the resolved GIT_DIR
+for subprocesses. This rejects cycles like Git instead of recursing and keeps
+commands stable after working-directory changes.
+
+Review feedback noted that a dangling commondir symlink was treated as absent,
+allowing local objects and refs to validate the repository.
+
+Distinguish a truly missing commondir from a dangling symlink. This follows
+Git's get_common_dir_noenv(), whose file_exists check uses lstat before
+attempting to read the entry.
+
+Python 3.9 on Windows raised UnicodeDecodeError while the repository-discovery
+regression test parsed invalid commondir bytes, causing the Python package test
+(windows, 3.9) check to fail.
+
+Treat UnicodeError like an unreadable metadata file in both commondir and
+gitfile parsing. Invalid bytes now make discovery reject the candidate
+repository, matching Git's behavior.
+
+Review feedback noted that GIT_OBJECT_DIRECTORY made discovery succeed without
+becoming the Repo ODB root, while a relative value could later be resolved from
+the Git wrapper's different working directory.
+
+Resolve the environment value against the construction directory, use it as the
+ODB root, and preserve the absolute value for later Git commands. The regression
+moves the only object store outside the git directory and verifies access
+through both GitDB and git cat-file after the original environment and current
+directory are restored.
+
+Review feedback identified four setup mismatches: non-missing .git stat failures
+could fall through to another repository, explicit GIT_DIR could be redirected
+through a nested .git entry, Gitfile reads were not bounded to the stat-reported
+size, and alternates ignored GIT_OBJECT_DIRECTORY.
+
+Match Git setup.c by bypassing discovery for an environment-selected GIT_DIR,
+stopping discovery when Gitfile stat fails for reasons other than a missing
+path, and reading exactly the previously observed Gitfile size. Resolve
+alternates below the active ODB root so custom object stores remain internally
+consistent.
+
+The commit review noted that treating every present GIT_DIR as explicit broke
+two documented cases: an empty GIT_DIR must fall back to current-directory
+discovery, while an empty Repo path must still use a nonempty GIT_DIR.
+
+CVE: CVE-2026-87817
+Upstream-Status: Backport [https://github.com/gitpython-developers/GitPython/commit/c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b]
+
+Backport Changes:
+- Omitted test/test_repo.py because the current PyPI source archive
+ does not include the test suite and the python3-git recipe does
+ not run it.
+
+Assisted-by: GPT 5.6
+Co-authored-by: GPT 5.6 <codex@openai.com>
+(cherry picked from commit c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b)
+Signed-off-by: Yogita Urade <yurade@cisco.com>
+---
+ git/repo/base.py | 89 ++++++++++++++++++++++++++++++++---------------
+ git/repo/fun.py | 90 +++++++++++++++++++++++++++++++++---------------
+ 2 files changed, 124 insertions(+), 55 deletions(-)
+
+diff --git a/git/repo/base.py b/git/repo/base.py
+index d8cc5306..7543e3cc 100644
+--- a/git/repo/base.py
++++ b/git/repo/base.py
+@@ -45,7 +45,6 @@ from git.util import (
+
+ from .fun import (
+ find_submodule_git_dir,
+- find_worktree_git_dir,
+ is_git_dir,
+ rev_parse,
+ touch,
+@@ -227,6 +226,10 @@ class Repo:
+ ) -> None:
+ R"""Create a new :class:`Repo` instance.
+
++ .. note::
++ Repositories using reftable may be opened, but GitPython's direct reference
++ access does not support reftable.
++
+ :param path:
+ The path to either the root git directory or the bare git repo::
+
+@@ -261,7 +264,11 @@ class Repo:
+ :class:`Repo`
+ """
+
+- epath = path or os.getenv("GIT_DIR")
++ git_dir_env = os.getenv("GIT_DIR")
++ object_dir_env = os.getenv("GIT_OBJECT_DIRECTORY")
++ if object_dir_env is not None:
++ object_dir_env = osp.abspath(object_dir_env)
++ epath = path or git_dir_env
+ if not epath:
+ epath = os.getcwd()
+ if Git.is_cygwin():
+@@ -285,15 +292,47 @@ class Repo:
+ raise NoSuchPathError(epath)
+
+ # Walk up the path to find the `.git` dir.
+- curpath = epath
+- git_dir = None
++ curpath = os.fspath(epath) if epath is not None else ""
++ git_dir: Optional[str] = None
++ explicit_git_dir = not path and bool(git_dir_env)
+ while curpath:
+ # ABOUT osp.NORMPATH
+ # It's important to normalize the paths, as submodules will otherwise
+ # initialize their repo instances with paths that depend on path-portions
+ # that will not exist after being removed. It's just cleaner.
++ if not explicit_git_dir:
++ dotgit = osp.join(curpath, ".git")
++ try:
++ sm_gitpath = find_submodule_git_dir(dotgit)
++ except OSError:
++ break
++ if sm_gitpath is not None:
++ # Worktrees can use relative paths as of Git 2.48, so join to curpath.
++ git_dir = osp.normpath(osp.join(curpath, os.fspath(sm_gitpath)))
++ self._working_tree_dir = curpath
++ break
++
++ # Like Git, do not fall back to a bare repository or parent directory when
++ # a non-directory .git entry exists but is not a valid gitfile.
++ if osp.exists(dotgit) and not osp.isdir(dotgit):
++ break
+ if is_git_dir(curpath):
+ git_dir = curpath
++ if osp.isfile(osp.join(curpath, "gitdir")) and osp.isfile(osp.join(curpath, "commondir")):
++ if "GIT_WORK_TREE" in os.environ:
++ self._working_tree_dir = os.getenv("GIT_WORK_TREE")
++ else:
++ # Linked worktree administrative directories store the path to
++ # the worktree's .git file in gitdir (without a "gitdir: " prefix).
++ with open(osp.join(git_dir, "gitdir")) as fp:
++ worktree_gitfile = fp.read().strip()
++
++ if not osp.isabs(worktree_gitfile):
++ worktree_gitfile = osp.normpath(osp.join(git_dir, worktree_gitfile))
++
++ self._working_tree_dir = osp.dirname(worktree_gitfile)
++ break
++
+ # from man git-config : core.worktree
+ # Set the path to the root of the working tree. If GIT_COMMON_DIR
+ # environment variable is set, core.worktree is ignored and not used for
+@@ -313,21 +352,7 @@ class Repo:
+ self._working_tree_dir = os.getenv("GIT_WORK_TREE")
+ break
+
+- dotgit = osp.join(curpath, ".git")
+- sm_gitpath = find_submodule_git_dir(dotgit)
+- if sm_gitpath is not None:
+- git_dir = osp.normpath(sm_gitpath)
+-
+- sm_gitpath = find_submodule_git_dir(dotgit)
+- if sm_gitpath is None:
+- sm_gitpath = find_worktree_git_dir(dotgit)
+-
+- if sm_gitpath is not None:
+- git_dir = expand_path(sm_gitpath, expand_vars)
+- self._working_tree_dir = curpath
+- break
+-
+- if not search_parent_directories:
++ if explicit_git_dir or not search_parent_directories:
+ break
+ curpath, tail = osp.split(curpath)
+ if not tail:
+@@ -338,6 +363,16 @@ class Repo:
+ raise InvalidGitRepositoryError(epath)
+ self.git_dir = git_dir
+
++ common_dir_env = os.getenv("GIT_COMMON_DIR")
++ if common_dir_env is not None:
++ self._common_dir = osp.abspath(common_dir_env)
++ else:
++ try:
++ common_dir = os.fsdecode((Path(self.git_dir) / "commondir").read_bytes()).rstrip("\r\n")
++ self._common_dir = osp.join(self.git_dir, common_dir)
++ except OSError:
++ self._common_dir = ""
++
+ self._bare = False
+ try:
+ self._bare = self.config_reader("repository").getboolean("core", "bare")
+@@ -345,12 +380,6 @@ class Repo:
+ # Let's not assume the option exists, although it should.
+ pass
+
+- try:
+- common_dir = (Path(self.git_dir) / "commondir").read_text().splitlines()[0].strip()
+- self._common_dir = osp.join(self.git_dir, common_dir)
+- except OSError:
+- self._common_dir = ""
+-
+ # Adjust the working directory in case we are actually bare - we didn't know
+ # that in the first place.
+ if self._bare:
+@@ -359,9 +388,15 @@ class Repo:
+
+ self.working_dir: PathLike = self._working_tree_dir or self.common_dir
+ self.git = self.GitCommandWrapperType(self.working_dir)
++ if common_dir_env is not None:
++ self.git.update_environment(GIT_DIR=os.fspath(self.git_dir), GIT_COMMON_DIR=os.fspath(self.common_dir))
++ elif git_dir_env is not None:
++ self.git.update_environment(GIT_DIR=os.fspath(self.git_dir))
++ if object_dir_env is not None:
++ self.git.update_environment(GIT_OBJECT_DIRECTORY=object_dir_env)
+
+ # Special handling, in special times.
+- rootpath = osp.join(self.common_dir, "objects")
++ rootpath = object_dir_env if object_dir_env is not None else osp.join(self.common_dir, "objects")
+ if issubclass(odbt, GitCmdObjectDB):
+ self.odb = odbt(rootpath, self.git)
+ else:
+@@ -969,7 +1004,7 @@ class Repo:
+ The method does not check for the existence of the paths in `alts`, as the
+ caller is responsible.
+ """
+- alternates_path = osp.join(self.common_dir, "objects", "info", "alternates")
++ alternates_path = osp.join(self.odb.root_path(), "info", "alternates")
+ if not alts:
+ if osp.isfile(alternates_path):
+ os.remove(alternates_path)
+diff --git a/git/repo/fun.py b/git/repo/fun.py
+index e44d9c64..29faaf29 100644
+--- a/git/repo/fun.py
++++ b/git/repo/fun.py
+@@ -57,22 +57,61 @@ def touch(filename: str) -> str:
+ def is_git_dir(d: PathLike) -> bool:
+ """This is taken from the git setup.c:is_git_directory function.
+
++ .. note::
++ This function recognizes repositories using reftable through their
++ compatibility files, but GitPython's direct reference access does not support
++ reftable.
++
+ :raise git.exc.WorkTreeRepositoryUnsupported:
+ If it sees a worktree directory. It's quite hacky to do that here, but at least
+ clearly indicates that we don't support it. There is the unlikely danger to
+ throw if we see directories which just look like a worktree dir, but are none.
+ """
+ if osp.isdir(d):
+- if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir(
+- osp.join(d, "refs")
+- ):
+- headref = osp.join(d, "HEAD")
+- return osp.isfile(headref) or (osp.islink(headref) and os.readlink(headref).startswith("refs"))
+- elif (
+- osp.isfile(osp.join(d, "gitdir"))
+- and osp.isfile(osp.join(d, "commondir"))
+- and osp.isfile(osp.join(d, "gitfile"))
+- ):
++ headref = osp.join(d, "HEAD")
++ if osp.islink(headref):
++ try:
++ valid_head = os.readlink(headref).startswith("refs/")
++ except OSError:
++ valid_head = False
++ else:
++ try:
++ with open(headref, "rb") as fp:
++ head = fp.read(256)
++ except OSError:
++ valid_head = False
++ else:
++ valid_head = (head.startswith(b"ref:") and head[4:].lstrip().startswith(b"refs/")) or bool(
++ re.match(rb"(?:[0-9A-Fa-f]{64}|[0-9A-Fa-f]{40})", head)
++ )
++
++ common_dir = os.getenv("GIT_COMMON_DIR")
++ if common_dir == "":
++ return False
++ if common_dir is None:
++ common_dir_file = Path(d) / "commondir"
++ try:
++ common_dir = os.fsdecode(common_dir_file.read_bytes()).rstrip("\r\n")
++ except FileNotFoundError:
++ if osp.lexists(common_dir_file):
++ return False
++ common_dir = os.fspath(d)
++ except (OSError, UnicodeError):
++ return False
++ else:
++ if not common_dir:
++ return False
++ try:
++ common_dir = osp.realpath(osp.join(d, common_dir))
++ except (OSError, ValueError):
++ return False
++
++ object_dir = os.getenv("GIT_OBJECT_DIRECTORY")
++ if object_dir is None:
++ object_dir = osp.join(common_dir, "objects")
++ if valid_head and osp.isdir(object_dir) and osp.isdir(osp.join(common_dir, "refs")):
++ return True
++ if osp.isfile(osp.join(d, "gitdir")) and osp.isfile(osp.join(d, "commondir")) and osp.isfile(headref):
+ raise WorkTreeRepositoryUnsupported(d)
+ return False
+
+@@ -81,19 +120,20 @@ def find_worktree_git_dir(dotgit: PathLike) -> Optional[str]:
+ """Search for a gitdir for this worktree."""
+ try:
+ statbuf = os.stat(dotgit)
+- except OSError:
++ except (FileNotFoundError, NotADirectoryError):
+ return None
+- if not stat.S_ISREG(statbuf.st_mode):
++ if not stat.S_ISREG(statbuf.st_mode) or statbuf.st_size > (1 << 20):
+ return None
+
+ try:
+- lines = Path(dotgit).read_text().splitlines()
+- for key, value in [line.strip().split(": ") for line in lines]:
+- if key == "gitdir":
+- return value
+- except ValueError:
+- pass
+- return None
++ with open(dotgit, "rb") as fp:
++ content_bytes = fp.read(statbuf.st_size)
++ if len(content_bytes) != statbuf.st_size:
++ return None
++ content = os.fsdecode(content_bytes).rstrip("\r\n")
++ except (OSError, UnicodeError):
++ return None
++ return content[8:] if len(content) >= 9 and content.startswith("gitdir: ") else None
+
+
+ def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]:
+@@ -101,15 +141,9 @@ def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]:
+ if is_git_dir(d):
+ return d
+
+- try:
+- with open(d) as fp:
+- content = fp.read().rstrip()
+- except IOError:
+- # It's probably not a file.
+- pass
+- else:
+- if content.startswith("gitdir: "):
+- path = content[8:]
++ path = find_worktree_git_dir(d)
++ if path is None:
++ return None
+
+ if Git.is_cygwin():
+ # Cygwin creates submodules prefixed with `/cygdrive/...` suffixes.
@@ -47,6 +47,7 @@ SRC_URI += "file://CVE-2026-42284.patch \
file://CVE-2026-78677.patch \
file://CVE-2026-78676.patch \
file://CVE-2026-78675.patch \
+ file://CVE-2026-87817.patch \
"
SRC_URI[sha256sum] = "35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"
This patch applies the upstream fix as referenced in [2], using the commit shown in [1]. [1] https://github.com/gitpython-developers/GitPython/commit/c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b [2] https://nvd.nist.gov/vuln/detail/CVE-2026-87817 Signed-off-by: Yogita Urade <yurade@cisco.com> --- .../python/python3-git/CVE-2026-87817.patch | 394 ++++++++++++++++++ .../python/python3-git_3.1.43.bb | 1 + 2 files changed, 395 insertions(+) create mode 100644 meta/recipes-devtools/python/python3-git/CVE-2026-87817.patch