Skip to content

Commit 1424148

Browse files
Byroncodex
andcommitted
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(). Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 <codex@openai.com>
1 parent d160fb4 commit 1424148

4 files changed

Lines changed: 100 additions & 43 deletions

File tree

doc/source/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Security fixes for
99

1010
* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-g5vv-9gxw-82hx
1111
* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-whh4-5q6c-9v3x
12+
* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-239g-whfq-7xj9
1213

1314
If you can, also try and provide feedback on the upcoming v4 branch
1415
https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome.

git/repo/base.py

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545

4646
from .fun import (
4747
find_submodule_git_dir,
48-
find_worktree_git_dir,
4948
is_git_dir,
5049
rev_parse,
5150
touch,
@@ -290,37 +289,43 @@ def __init__(
290289
raise NoSuchPathError(epath)
291290

292291
# Walk up the path to find the `.git` dir.
293-
curpath = epath
294-
git_dir = None
292+
curpath = os.fspath(epath) if epath is not None else ""
293+
git_dir: Optional[str] = None
295294
while curpath:
296295
# ABOUT osp.NORMPATH
297296
# It's important to normalize the paths, as submodules will otherwise
298297
# initialize their repo instances with paths that depend on path-portions
299298
# that will not exist after being removed. It's just cleaner.
300-
if (
301-
osp.isfile(osp.join(curpath, "gitdir"))
302-
and osp.isfile(osp.join(curpath, "commondir"))
303-
and osp.isfile(osp.join(curpath, "HEAD"))
304-
):
305-
git_dir = curpath
306-
307-
if "GIT_WORK_TREE" in os.environ:
308-
self._working_tree_dir = os.getenv("GIT_WORK_TREE")
309-
else:
310-
# Linked worktree administrative directories store the path to the
311-
# worktree's .git file in their gitdir file (without "gitdir: " prefix).
312-
with open(osp.join(git_dir, "gitdir")) as fp:
313-
worktree_gitfile = fp.read().strip()
314-
315-
if not osp.isabs(worktree_gitfile):
316-
worktree_gitfile = osp.normpath(osp.join(git_dir, worktree_gitfile))
317-
318-
self._working_tree_dir = osp.dirname(worktree_gitfile)
299+
dotgit = osp.join(curpath, ".git")
300+
sm_gitpath = find_submodule_git_dir(dotgit)
301+
if sm_gitpath is not None:
302+
# Worktrees can use relative paths as of Git 2.48, so join to curpath.
303+
git_dir = osp.normpath(osp.join(curpath, os.fspath(sm_gitpath)))
304+
self._working_tree_dir = curpath
305+
break
319306

307+
# Like Git, do not fall back to a bare repository or parent directory when
308+
# a non-directory .git entry exists but is not a valid gitfile.
309+
if osp.exists(dotgit) and not osp.isdir(dotgit):
320310
break
321311

322312
if is_git_dir(curpath):
323313
git_dir = curpath
314+
if osp.isfile(osp.join(curpath, "gitdir")) and osp.isfile(osp.join(curpath, "commondir")):
315+
if "GIT_WORK_TREE" in os.environ:
316+
self._working_tree_dir = os.getenv("GIT_WORK_TREE")
317+
else:
318+
# Linked worktree administrative directories store the path to
319+
# the worktree's .git file in gitdir (without a "gitdir: " prefix).
320+
with open(osp.join(git_dir, "gitdir")) as fp:
321+
worktree_gitfile = fp.read().strip()
322+
323+
if not osp.isabs(worktree_gitfile):
324+
worktree_gitfile = osp.normpath(osp.join(git_dir, worktree_gitfile))
325+
326+
self._working_tree_dir = osp.dirname(worktree_gitfile)
327+
break
328+
324329
# from man git-config : core.worktree
325330
# Set the path to the root of the working tree. If GIT_COMMON_DIR
326331
# environment variable is set, core.worktree is ignored and not used for
@@ -340,21 +345,6 @@ def __init__(
340345
self._working_tree_dir = os.getenv("GIT_WORK_TREE")
341346
break
342347

343-
dotgit = osp.join(curpath, ".git")
344-
sm_gitpath = find_submodule_git_dir(dotgit)
345-
if sm_gitpath is not None:
346-
git_dir = osp.normpath(sm_gitpath)
347-
348-
sm_gitpath = find_submodule_git_dir(dotgit)
349-
if sm_gitpath is None:
350-
sm_gitpath = find_worktree_git_dir(dotgit)
351-
352-
if sm_gitpath is not None:
353-
# worktrees can use relative paths as of Git 2.48, so we join to curpath
354-
git_dir = osp.normpath(osp.join(curpath, sm_gitpath))
355-
self._working_tree_dir = curpath
356-
break
357-
358348
if not search_parent_directories:
359349
break
360350
curpath, tail = osp.split(curpath)

git/repo/fun.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,38 @@ def is_git_dir(d: PathLike) -> bool:
6666
throw if we see directories which just look like a worktree dir, but are none.
6767
"""
6868
if osp.isdir(d):
69-
if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir(
70-
osp.join(d, "refs")
71-
):
72-
headref = osp.join(d, "HEAD")
73-
return osp.isfile(headref) or (osp.islink(headref) and os.readlink(headref).startswith("refs"))
74-
elif (
69+
headref = osp.join(d, "HEAD")
70+
if osp.islink(headref):
71+
try:
72+
valid_head = os.readlink(headref).startswith("refs/")
73+
except OSError:
74+
valid_head = False
75+
else:
76+
try:
77+
with open(headref, "rb") as fp:
78+
head = fp.read(256)
79+
except OSError:
80+
valid_head = False
81+
else:
82+
valid_head = (head.startswith(b"ref:") and head[4:].lstrip().startswith(b"refs/")) or bool(
83+
re.match(rb"(?:[0-9A-Fa-f]{64}|[0-9A-Fa-f]{40})", head)
84+
)
85+
86+
common_dir = os.getenv("GIT_COMMON_DIR")
87+
if common_dir is None:
88+
try:
89+
common_dir = (Path(d) / "commondir").read_text().rstrip("\r\n")
90+
except FileNotFoundError:
91+
common_dir = os.fspath(d)
92+
except OSError:
93+
common_dir = ""
94+
else:
95+
common_dir = osp.realpath(osp.join(d, common_dir)) if common_dir else ""
96+
97+
object_dir = os.getenv("GIT_OBJECT_DIRECTORY") or osp.join(common_dir, "objects")
98+
if valid_head and osp.isdir(object_dir) and osp.isdir(osp.join(common_dir, "refs")):
99+
return True
100+
if (
75101
osp.isfile(osp.join(d, "gitdir"))
76102
and osp.isfile(osp.join(d, "commondir"))
77103
and osp.isfile(osp.join(d, "gitfile"))

test/test_repo.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,46 @@ def test_new_should_raise_on_non_existent_path(self):
122122
nonexistent = osp.join(tdir, "foobar")
123123
self.assertRaises(NoSuchPathError, Repo, nonexistent)
124124

125+
def test_repo_discovery_prefers_dotgit(self):
126+
layouts = {
127+
"linked-worktree": {
128+
"gitdir": ".git\n",
129+
"commondir": ".git\n",
130+
"HEAD": "ref: refs/heads/main\n",
131+
},
132+
"bare": {"objects": None, "refs": None, "HEAD": "ref: refs/heads/main\n"},
133+
}
134+
135+
with tempfile.TemporaryDirectory() as tdir:
136+
for name, entries in layouts.items():
137+
path = Path(tdir) / name
138+
Repo.init(path).close()
139+
for entry, contents in entries.items():
140+
item = path / entry
141+
if contents is None:
142+
item.mkdir()
143+
else:
144+
item.write_text(contents)
145+
146+
with self.subTest(layout=name):
147+
expected_git_dir = Git(path).rev_parse("--absolute-git-dir")
148+
assert osp.samefile(Repo(path).git_dir, expected_git_dir)
149+
150+
def test_repo_discovery_rejects_invalid_metadata(self):
151+
with tempfile.TemporaryDirectory() as tdir:
152+
path = Path(tdir)
153+
(path / "objects").mkdir()
154+
(path / "refs").mkdir()
155+
(path / "HEAD").write_text("not a ref")
156+
157+
with self.subTest(metadata="HEAD"):
158+
self.assertRaises(InvalidGitRepositoryError, Repo, path)
159+
160+
(path / "HEAD").write_text("ref: refs/heads/main\n")
161+
(path / ".git").write_text("not a gitfile")
162+
with self.subTest(metadata=".git"):
163+
self.assertRaises(InvalidGitRepositoryError, Repo, path)
164+
125165
@with_rw_repo("0.3.2.1")
126166
def test_repo_creation_from_different_paths(self, rw_repo):
127167
r_from_gitdir = Repo(rw_repo.git_dir)

0 commit comments

Comments
 (0)