Skip to content

Commit 6702912

Browse files
committed
fix(refs): read references by exact name instead of revision guessing
`SymbolicReference._get_ref_info_helper` resolved a reference that is not a symbolic ref with `git rev-parse --verify --end-of-options <path>`, and `Repo.currently_rebasing_on()` read `REBASE_HEAD` the same way. `rev-parse` applies the revision-guessing rules from `gitrevisions(7)`: when `<path>` itself does not exist it also tries `refs/<path>`, `refs/tags/<path>`, `refs/heads/<path>` and `refs/remotes/<path>`. A reference that does not exist was therefore reported as existing whenever a look-alike name did. `refs/heads/main` is a valid tag name, and any fetch or clone delivers it as `refs/tags/refs/heads/main`. In a repository whose `HEAD` points at the unborn `refs/heads/main`, Git reports an unborn branch (`git rev-parse --verify HEAD` exits 1 and `git show-ref --verify refs/heads/main` fails), while GitPython's CLI backend reported `head.is_valid()` as `True`, `head.commit` as the tag's commit, and `repo.index.commit()` created the first commit with that foreign commit as its parent and then wrote `refs/heads/main` on top of the tag's history. The same lookup through the Gix backend already answered "does not exist", so the two backends disagreed. A tag named `REBASE_HEAD` likewise made `currently_rebasing_on()` return a commit while no rebase was in progress. Read the exact name instead. `git show-ref --verify --hash -- <path>` reads only the named reference and fails for a missing one; it resolves `HEAD`, `ORIG_HEAD`, `FETCH_HEAD`, `MERGE_HEAD`, `refs/stash`, nested tags and a linked worktree's `HEAD` the same way `rev-parse --verify` did, on both files and reftable storage. `currently_rebasing_on()` first runs `git show-ref --exists -- REBASE_HEAD`, which exits 2 for a missing reference, so an absent rebase is still reported as `None` without matching diagnostic text. The `ValueError` raised for a missing reference is unchanged, and the Gix backend's native lookup is unaffected. The `lookalike` case in `tests/fixtures/reference-security.sh` tags the initial commit as `refs/heads/missing` and `REBASE_HEAD` and points `HEAD` at the unborn branch. `test_missing_references_do_not_resolve_to_look_alike_names` checks the lookup, `is_valid()`, tag access and `currently_rebasing_on()` through the native and the CLI reader, and `test_commit_on_unborn_head_ignores_look_alike_tag` checks that the first commit has no parent. Both fail before this change on each backend and pass after it. Validation: the full test suite with Git 2.52.0 on the CLI and Gix backends (a bootstrapped clone, `init-tests-after-clone.sh`), Ruff check and format, mypy, and ShellCheck on the fixture script.
1 parent 4debc51 commit 6702912

5 files changed

Lines changed: 45 additions & 6 deletions

File tree

‎doc/source/changes.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ Git executable and safety
5050
Text and streamed command output preserve undecodable bytes with
5151
``surrogateescape``, including fetch/push results, clone errors and timeout
5252
diagnostics containing byte names.
53+
* Reference reads on the Git CLI backend use exact names. A missing reference no
54+
longer resolves through Git's revision guessing to a look-alike name such as a
55+
tag called ``refs/heads/main``: an unborn ``HEAD`` is reported as invalid, the
56+
first commit on it has no parent, and ``Repo.currently_rebasing_on()`` ignores a
57+
tag named ``REBASE_HEAD``. This matches the Gix backend.
5358

5459
API changes
5560
-----------

‎git/refs/symbolic.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,11 @@ def _get_ref_info_helper(
197197
except GitCommandError as exc:
198198
if exc.status != 1:
199199
raise ValueError("Invalid symbolic reference %r" % path) from exc
200+
# `rev-parse --verify` applies Git's name-guessing rules, so a missing
201+
# `refs/heads/x` would resolve to a tag named `refs/heads/x`. Read the
202+
# exact reference instead, which is what the native lookup does.
200203
try:
201-
oid = repo.git._call_process_safe("rev_parse", "--verify", "--end-of-options", path)
204+
oid = repo.git._call_process_safe("show_ref", "--verify", "--hash", "--", path)
202205
except GitCommandError as exc:
203206
raise ValueError("Reference at %r does not exist or is invalid" % path) from exc
204207
if not repo.re_hexsha_only.fullmatch(oid):

‎git/repo/base.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1964,11 +1964,13 @@ def currently_rebasing_on(self) -> Commit | None:
19641964
"""
19651965
if not self.git_dir:
19661966
return None
1967-
status, oid, stderr = self.git._call_process_safe(
1968-
"rev_parse", "--verify", "--quiet", "REBASE_HEAD", with_extended_output=True, with_exceptions=False
1967+
# Read the pseudo-reference by its exact name: `rev-parse --verify` would
1968+
# also accept a tag named `REBASE_HEAD` while no rebase is in progress.
1969+
status, _, stderr = self.git._call_process_safe(
1970+
"show_ref", "--exists", "--", "REBASE_HEAD", with_extended_output=True, with_exceptions=False
19691971
)
1970-
if status == 1:
1972+
if status == 2:
19711973
return None
19721974
if status:
1973-
raise GitCommandError("git rev-parse", status, stderr, oid)
1974-
return self.commit(oid)
1975+
raise GitCommandError("git show-ref", status, stderr)
1976+
return self.commit(self.git._call_process_safe("show_ref", "--verify", "--hash", "--", "REBASE_HEAD"))

‎test/test_refs.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,29 @@ def test_custom_symbolic_reference_names_do_not_recurse_during_construction(self
11341134
with self.assertRaises(ValueError):
11351135
ref.set_object(self.next_commit)
11361136

1137+
def test_missing_references_do_not_resolve_to_look_alike_names(self):
1138+
with self.fixture("lookalike"):
1139+
# Git's revision guessing finds the tag, but no such branch exists.
1140+
self.assertEqual(self.repo.git.rev_parse("--verify", "refs/heads/missing"), self.commit.hexsha)
1141+
self.assertEqual(self.repo.git.symbolic_ref("HEAD"), "refs/heads/missing")
1142+
for backend in (_backend.gix, None):
1143+
with patch.object(_backend, "gix", backend):
1144+
with self.assertRaisesRegex(ValueError, "does not exist"):
1145+
SymbolicReference._get_ref_info(self.repo, "refs/heads/missing")
1146+
self.assertFalse(self.repo.head.is_valid())
1147+
self.assertFalse(Head(self.repo, "refs/heads/missing").is_valid())
1148+
self.assertEqual(self.repo.tags["refs/heads/missing"].commit, self.commit)
1149+
self.assertEqual(self.repo.tags["REBASE_HEAD"].commit, self.commit)
1150+
self.assertIsNone(self.repo.currently_rebasing_on())
1151+
1152+
def test_commit_on_unborn_head_ignores_look_alike_tag(self):
1153+
with self.fixture("lookalike", write=True):
1154+
with patch.object(_backend, "gix", None):
1155+
commit = self.repo.index.commit("root")
1156+
self.assertEqual(commit.parents, [])
1157+
self.assertEqual(self.repo.head.commit, commit)
1158+
self.assertEqual(self.repo.git.rev_parse("--verify", "refs/heads/missing"), commit.hexsha)
1159+
11371160
def test_reference_reads_reject_symlinks_outside_repository(self):
11381161
with self.fixture("external"):
11391162
with self.assertRaisesRegex(ValueError, "escapes the repository"):

‎tests/fixtures/reference-security.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ case "$1" in
4040
write_ref "$terminal" 'ref: refs/heads/link-1'
4141
fi
4242
;;
43+
lookalike)
44+
# Valid names Git's revision guessing would accept for missing references.
45+
git tag refs/heads/missing "$initial"
46+
git tag REBASE_HEAD "$initial"
47+
write_ref HEAD 'ref: refs/heads/missing'
48+
;;
4349
custom)
4450
write_ref CUSTOM_A 'ref: CUSTOM_B'
4551
write_ref CUSTOM_B 'ref: CUSTOM_A'

0 commit comments

Comments
 (0)