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'