Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/source/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------
Expand Down
5 changes: 4 additions & 1 deletion git/refs/symbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 7 additions & 5 deletions git/repo/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
23 changes: 23 additions & 0 deletions test/test_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/reference-security.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading