From 67029122adf4f51aebd3f8766f0fc4620fb38413 Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Tue, 27 Oct 2026 07:45:43 +0530 Subject: [PATCH] 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 `, and `Repo.currently_rebasing_on()` read `REBASE_HEAD` the same way. `rev-parse` applies the revision-guessing rules from `gitrevisions(7)`: when `` itself does not exist it also tries `refs/`, `refs/tags/`, `refs/heads/` and `refs/remotes/`. 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 -- ` 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. --- doc/source/changes.rst | 5 +++++ git/refs/symbolic.py | 5 ++++- git/repo/base.py | 12 +++++++----- test/test_refs.py | 23 +++++++++++++++++++++++ tests/fixtures/reference-security.sh | 6 ++++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/doc/source/changes.rst b/doc/source/changes.rst index c8c71e9f8..6758d4e07 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -50,6 +50,11 @@ Git executable and safety Text and streamed command output preserve undecodable bytes with ``surrogateescape``, including fetch/push results, clone errors and timeout diagnostics containing byte names. +* Reference reads on the Git CLI backend use exact names. A missing reference no + longer resolves through Git's revision guessing to a look-alike name such as a + tag called ``refs/heads/main``: an unborn ``HEAD`` is reported as invalid, the + first commit on it has no parent, and ``Repo.currently_rebasing_on()`` ignores a + tag named ``REBASE_HEAD``. This matches the Gix backend. API changes ----------- diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 68552f672..1128bf797 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -197,8 +197,11 @@ def _get_ref_info_helper( except GitCommandError as exc: if exc.status != 1: raise ValueError("Invalid symbolic reference %r" % path) from exc + # `rev-parse --verify` applies Git's name-guessing rules, so a missing + # `refs/heads/x` would resolve to a tag named `refs/heads/x`. Read the + # exact reference instead, which is what the native lookup does. try: - oid = repo.git._call_process_safe("rev_parse", "--verify", "--end-of-options", path) + oid = repo.git._call_process_safe("show_ref", "--verify", "--hash", "--", path) except GitCommandError as exc: raise ValueError("Reference at %r does not exist or is invalid" % path) from exc if not repo.re_hexsha_only.fullmatch(oid): diff --git a/git/repo/base.py b/git/repo/base.py index 0de87d01c..be64230bd 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1964,11 +1964,13 @@ def currently_rebasing_on(self) -> Commit | None: """ if not self.git_dir: return None - status, oid, stderr = self.git._call_process_safe( - "rev_parse", "--verify", "--quiet", "REBASE_HEAD", with_extended_output=True, with_exceptions=False + # Read the pseudo-reference by its exact name: `rev-parse --verify` would + # also accept a tag named `REBASE_HEAD` while no rebase is in progress. + status, _, stderr = self.git._call_process_safe( + "show_ref", "--exists", "--", "REBASE_HEAD", with_extended_output=True, with_exceptions=False ) - if status == 1: + if status == 2: return None if status: - raise GitCommandError("git rev-parse", status, stderr, oid) - return self.commit(oid) + raise GitCommandError("git show-ref", status, stderr) + return self.commit(self.git._call_process_safe("show_ref", "--verify", "--hash", "--", "REBASE_HEAD")) diff --git a/test/test_refs.py b/test/test_refs.py index 1e47101c1..de0e0ac2a 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -1134,6 +1134,29 @@ def test_custom_symbolic_reference_names_do_not_recurse_during_construction(self with self.assertRaises(ValueError): ref.set_object(self.next_commit) + def test_missing_references_do_not_resolve_to_look_alike_names(self): + with self.fixture("lookalike"): + # Git's revision guessing finds the tag, but no such branch exists. + self.assertEqual(self.repo.git.rev_parse("--verify", "refs/heads/missing"), self.commit.hexsha) + self.assertEqual(self.repo.git.symbolic_ref("HEAD"), "refs/heads/missing") + for backend in (_backend.gix, None): + with patch.object(_backend, "gix", backend): + with self.assertRaisesRegex(ValueError, "does not exist"): + SymbolicReference._get_ref_info(self.repo, "refs/heads/missing") + self.assertFalse(self.repo.head.is_valid()) + self.assertFalse(Head(self.repo, "refs/heads/missing").is_valid()) + self.assertEqual(self.repo.tags["refs/heads/missing"].commit, self.commit) + self.assertEqual(self.repo.tags["REBASE_HEAD"].commit, self.commit) + self.assertIsNone(self.repo.currently_rebasing_on()) + + def test_commit_on_unborn_head_ignores_look_alike_tag(self): + with self.fixture("lookalike", write=True): + with patch.object(_backend, "gix", None): + commit = self.repo.index.commit("root") + self.assertEqual(commit.parents, []) + self.assertEqual(self.repo.head.commit, commit) + self.assertEqual(self.repo.git.rev_parse("--verify", "refs/heads/missing"), commit.hexsha) + def test_reference_reads_reject_symlinks_outside_repository(self): with self.fixture("external"): with self.assertRaisesRegex(ValueError, "escapes the repository"): diff --git a/tests/fixtures/reference-security.sh b/tests/fixtures/reference-security.sh index e90deec97..969f71d4a 100644 --- a/tests/fixtures/reference-security.sh +++ b/tests/fixtures/reference-security.sh @@ -40,6 +40,12 @@ case "$1" in write_ref "$terminal" 'ref: refs/heads/link-1' fi ;; + lookalike) + # Valid names Git's revision guessing would accept for missing references. + git tag refs/heads/missing "$initial" + git tag REBASE_HEAD "$initial" + write_ref HEAD 'ref: refs/heads/missing' + ;; custom) write_ref CUSTOM_A 'ref: CUSTOM_B' write_ref CUSTOM_B 'ref: CUSTOM_A'