Skip to content

Commit 9ddf778

Browse files
codexByron
authored andcommitted
fix(refs): keep missing references from resolving to lookalike tags
Fetching a valid tag named `refs/heads/unborn` stores it under `refs/tags/refs/heads/unborn`. When `HEAD` names the absent `refs/heads/unborn`, the CLI reference reader used `git rev-parse --verify` and guessed that tag instead. `head.is_valid()` became true and `IndexFile.commit()` gave the local initial commit a foreign parent. A tag named `REBASE_HEAD` also made `Repo.currently_rebasing_on()` report a rebase. Read direct references with `git show-ref --verify --hash` instead of revision parsing. Probe `REBASE_HEAD` with `git show-ref --exists` to distinguish absence from lookup errors, then use `SymbolicReference.dereference_recursive()` and `Repo.commit()` so the rebase reader shares exact lookup and symbolic-reference validation while retaining recursive annotated-tag peeling. Revision APIs intentionally retain Git's name guessing. Existing native Gix reference reads are unchanged; root-reference existence and reftable retain documented CLI fallbacks. Independently written regressions fetch the misleading tags into an unborn repository and cover SHA-1/SHA-256, files/reftable, forced CLI fallback, parentless initial commits, and absent/present/deleted `REBASE_HEAD`. Positive cases compare existing root refs, packed annotated tags, and detached `HEAD` against Git. All 16 original regressions failed on the CLI baseline; 14 failed under Gix, whose files-based initial commits were already protected. All 24 final reference regression cases pass on each backend. Post-commit review found that `SymbolicReference.commit` peels only one annotated tag; four new regressions failed with `TypeError` before switching to the existing recursive commit resolver. Broader validation exposed the existing GIX-17 grammar bug for this work branch: Gix reads `2313-supersede` as blob `23136a1d1dd1f02de7cde0f7b55f49d76ac77d55`, while Git reads the branch's commit. Extend the existing conservative CLI grammar guard to hexadecimal prefixes followed by a dash. A deterministic fixture verifies existing `<blob-prefix>-topic` and missing `<blob-prefix>-missing` names; its test fails before the guard and passes afterward. The GIX-17 ledger retains the upstream bug as open and records the evidence and adapter fallback. Final targeted validation passes 48 CLI tests and 50 Gix tests, including the installation and general revision-parser checks. Ruff check/format, mypy, basedpyright, ShellCheck, and `git diff --check` pass. Behavioral comparisons use `git version 2.54.0 (Apple Git-157)` and `GixPython 0.3.0`. The Git reference checkout at `6de20f6092dcf9bdb1c8efe03db4b70c82b423dd` documents exact lookup in `Documentation/git-show-ref.adoc`, name guessing in `Documentation/revisions.adoc`, and parentless root commits in `Documentation/git-commit-tree.adoc`. Source report and investigation: Keerthana-64's PR #2313. The replacement does not incorporate that PR's implementation or tests.
1 parent 4debc51 commit 9ddf778

9 files changed

Lines changed: 159 additions & 10 deletions

File tree

‎doc/gix-backend.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,19 @@ and predate the current fallback; they do not establish native coverage.
392392
| `Repo.untracked_files` | Native directory walk | Extra status options and non-root command directories |
393393
| `Repo.ignored` | Native excludes with tracked-file suppression | Symlink/submodule traversal, unsupported path normalization, and unavailable path metadata |
394394

395+
Reference reads require an exact name, unlike revision APIs that intentionally
396+
apply Git's name guessing. `reference_info()` uses
397+
`Repository.try_find_reference()` and verifies the returned name for `HEAD` and
398+
fully qualified refs. Other root names, including `REBASE_HEAD`, retain the CLI
399+
fallback: GixPython's lookup accepts partial names, and its Git-compatible root
400+
reference existence and error behavior has not been verified. A native conversion
401+
needs that behavior, including Git's distinction between an absent and an invalid
402+
reference. `Repo.currently_rebasing_on()` probes `git show-ref --exists` and then
403+
uses the shared exact reference reader; the existence probe remains CLI on both
404+
backends. `exact-refs.sh` tests missing names beside fetched tags with both object
405+
and reference formats. Reftable still uses GIX-1; this change claims no new native
406+
coverage and demonstrates no new Gix compatibility mismatch.
407+
395408
Reference names and revisions are encoded with `surrogateescape` before calling
396409
GixPython's byte-capable `Target.Symbolic`, `Repository.try_find_reference`,
397410
`Repository.rev_parse_single`, `Repository.rev_parse` and `RefEdit` APIs.
@@ -935,7 +948,7 @@ identify the historical fixture where needed.
935948
| GIX-14 | Bug; missing API | For a linked worktree of a bare main repository, `is_bare()` returns true while `git rev-parse --is-bare-repository` returns false; `workdir()` does expose the worktree. Initial gitfile opening can retain a noncanonical target and use an arbitrary gitfile's location as the worktree, unlike Git. Discovery accepts undecodable HEADs until explicit `head()` decoding and ignores dangling `commondir` symlinks that Git rejects. Bindings flatten failure kinds into `gix.Error`. Windows also exposes adapter path-formatting differences and a worker panic on an undecodable `commondir`; see the Windows reproduction below. | Provide Git-compatible classification, canonical gitfile locations and layout validation, plus typed errors instead of panics. Format native locations compatibly with Git's command output. The adapter combines Gix metadata, reopens through Gix and forces HEAD decoding, retaining CLI for remaining validation/diagnostic gaps. Those workarounds do not close these compatibility bugs. A Git-strict mode must cover these cases; strict config and trust settings alone do not. |
936949
| GIX-15 | Bug | Native blame disagrees with Git even with Myers and rewrite tracking selected. In the fixture's `README.md`, lines 150 and 158 are attributed to the opposite commits. Incremental order also differs. | Attribution and incremental-output parity before replacing `Repo.blame` / `blame_incremental`. |
937950
| GIX-16 | Bug; missing API | Native archive streaming takes a tree rather than a commit. Its TAR omits Git's global PAX commit comment, leaves `export-subst` placeholders literal, and writes ordinary modes as `0644` where Git uses `0664`. | Commit-aware export substitution, metadata and permission parity. Both engines respected `export-ignore` in the probe. |
938-
| GIX-17 | Bug | Revision parsing accepts abbreviated IDs with `-dirty`, prefers the OID suffix over an exact describe-shaped tag, and treats escaped braces in message searches differently. | Git-compatible revision grammar and regex semantics. Existing `test_rev_parse.py` cases reproduce all three. |
951+
| GIX-17 | Bug (open) | Revision parsing accepts abbreviated IDs with `-dirty`, prefers the OID suffix over an exact describe-shaped tag, and treats escaped braces in message searches differently. With GixPython 0.3.0 and Git 2.54.0, `2313-supersede` resolves to blob `23136a1d1dd1f02de7cde0f7b55f49d76ac77d55` through Gix while Git resolves the existing branch's commit. The deterministic `rev-parse.sh hex-prefix-collision` fixture creates a branch named `<blob-prefix>-topic`: Gix returns the blob and also accepts `<blob-prefix>-missing`, which Git rejects. | Provide Git-compatible revision grammar and regex semantics. `_check_revision_grammar()` retains CLI for hexadecimal prefixes followed by a dash, as well as the existing guarded forms; this adapter workaround does not close the upstream bug. `test_rev_parse.py` reproduces these differences, including `test_rev_parse_hex_prefix_with_arbitrary_suffix`. |
939952
| GIX-18 | Bug; missing API | The tree-diff resource cache reads attributes from the index; Git also consults worktree attributes. Native binary classification with textconv configured can also differ even in `to_git` mode. | Independent attribute-source control and `--no-textconv` parity. Statistics currently fall back for any effective `diff` attribute. |
940953
| GIX-19 | Unverified | GixPython opens repositories with `extensions.compatObjectFormat`, but translation and object-write compatibility have not been validated. The installed Git reports `compatibility hash algorithm support requires Rust` when asked to translate an ID. This is an unvalidated capability guard, not evidence of missing native object mappings. | Verify compatibility-format reads, writes and OID translation against a Git build that supports them. All operations use CLI in the meantime. |
941954
| GIX-20 | Bug; missing API | `Target.Symbolic(name)` validates full names but rejects standalone names accepted by `git check-ref-format --allow-onelevel`. Four of 430 audited calls differed: `refs` twice, `hellothere`, and `valid_one_level_refname`. Further probes include digits, punctuation and Unicode (`1`, `A1`, `HEAD_1`, `A-B`, `A.B`, `Ä`). | Expose Git-compatible partial/one-level name validation, or correct the native restriction where appropriate. This is a binding/behavior shortcoming to revisit upstream; standalone names rejected natively retain CLI validation. |

‎doc/source/changes.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ Git executable and safety
5454
API changes
5555
-----------
5656

57+
* Reference reads require the exact name. A missing branch no longer resolves to
58+
a similarly named tag, so an unborn branch's initial commit has no parent.
59+
A tag named ``REBASE_HEAD`` no longer reports a rebase in progress. Revision
60+
APIs such as ``Repo.commit(rev)`` retain Git's name guessing.
5761
* Packed tag names containing non-UTF-8 bytes are represented losslessly as
5862
surrogate-escaped strings. ``safe_encode()`` recovers their original bytes,
5963
and Gix reference validation and resolution receive those bytes directly.

‎git/_backend.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def _canonical_repository(repo: Any) -> Any:
166166

167167

168168
def _check_revision_grammar(ref: str) -> None:
169-
if ref.startswith(":/") or "^{/" in ref or "-dirty" in ref or re.search(r"-\d+-g[0-9a-fA-F]+", ref):
169+
if ref.startswith(":/") or "^{/" in ref or "-dirty" in ref or re.search(r"-\d+-g[0-9a-fA-F]+|^[0-9a-fA-F]+-", ref):
170170
raise _Unsupported("revision grammar differences (GIX-17)")
171171

172172

‎git/refs/symbolic.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,8 @@ def _get_ref_info_helper(
198198
if exc.status != 1:
199199
raise ValueError("Invalid symbolic reference %r" % path) from exc
200200
try:
201-
oid = repo.git._call_process_safe("rev_parse", "--verify", "--end-of-options", path)
201+
# Revision parsing can guess a tag when the exact reference is absent.
202+
oid = repo.git._call_process_safe("show_ref", "--verify", "--hash", "--", path)
202203
except GitCommandError as exc:
203204
raise ValueError("Reference at %r does not exist or is invalid" % path) from exc
204205
if not repo.re_hexsha_only.fullmatch(oid):

‎git/repo/base.py‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
)
3636
from git.index import IndexFile
3737
from git.objects import Submodule, RootModule, Commit
38-
from git.refs import HEAD, Head, Reference, TagReference
38+
from git.refs import HEAD, Head, Reference, SymbolicReference, TagReference
3939
from git.remote import Remote, _T_RemoteName, add_progress, to_progress_instance
4040
from git.util import (
4141
Actor,
@@ -88,7 +88,6 @@
8888
if TYPE_CHECKING:
8989
from git.objects import Tree
9090
from git.objects.submodule.base import UpdateProgress
91-
from git.refs.symbolic import SymbolicReference
9291
from git.remote import RemoteProgress
9392
from git.util import IterableList
9493

@@ -1964,11 +1963,11 @@ def currently_rebasing_on(self) -> Commit | None:
19641963
"""
19651964
if not self.git_dir:
19661965
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
1966+
status, stdout, stderr = self.git._call_process_safe(
1967+
"show_ref", "--exists", "--", "REBASE_HEAD", with_extended_output=True, with_exceptions=False
19691968
)
1970-
if status == 1:
1969+
if status == 2:
19711970
return None
19721971
if status:
1973-
raise GitCommandError("git rev-parse", status, stderr, oid)
1974-
return self.commit(oid)
1972+
raise GitCommandError("git show-ref", status, stderr, stdout)
1973+
return self.commit(SymbolicReference.dereference_recursive(self, "REBASE_HEAD"))

‎test/test_refs.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,99 @@ def test_head_rename_retains_literal_prefixed_branch_name(object_format):
163163
assert repo.heads.topic.commit == original_topic
164164

165165

166+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
167+
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
168+
@pytest.mark.parametrize("force_cli", [False, True])
169+
def test_reference_lookup_does_not_guess_missing_names(object_format, ref_format, force_cli):
170+
with fixture_repo("exact-refs.sh", object_format, ref_format) as repo:
171+
status, _, _ = repo.git.rev_parse(
172+
"--verify", "--quiet", "HEAD", with_extended_output=True, with_exceptions=False
173+
)
174+
assert status == 1
175+
with patch.object(_backend, "gix", None) if force_cli else contextlib.nullcontext():
176+
for path in (
177+
"refs/heads/unborn",
178+
"REBASE_HEAD",
179+
"ORIG_HEAD",
180+
"refs/tags/missing",
181+
"refs/remotes/origin/missing",
182+
):
183+
tag = TagReference(repo, "refs/tags/" + path)
184+
assert tag.commit == repo.commit(tag.path)
185+
status, _, _ = repo.git.show_ref(
186+
"--exists", "--", path, with_extended_output=True, with_exceptions=False
187+
)
188+
assert status == 2
189+
with pytest.raises(ValueError, match="does not exist or is invalid"):
190+
SymbolicReference(repo, path).object
191+
assert not repo.head.is_valid()
192+
with pytest.raises(ValueError):
193+
repo.head.commit
194+
# Revision APIs deliberately retain Git's name guessing.
195+
assert repo.commit("REBASE_HEAD") == repo.tags.REBASE_HEAD.commit
196+
assert repo.tags["nested/release"].object.type == "tag"
197+
198+
199+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
200+
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
201+
def test_initial_commit_does_not_inherit_lookalike_tag_history(object_format, ref_format):
202+
with fixture_repo("exact-refs.sh", object_format, ref_format, write=True) as repo:
203+
foreign = repo.tags["refs/heads/unborn"].commit
204+
initial = repo.index.commit("Local initial commit")
205+
assert initial.parents == []
206+
assert repo.head.commit == initial
207+
assert repo.tags["refs/heads/unborn"].commit == foreign
208+
assert repo.git.rev_list("--count", "HEAD") == "1"
209+
210+
211+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
212+
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
213+
def test_rebase_detection_ignores_tag_named_rebase_head(object_format, ref_format):
214+
with fixture_repo("exact-refs.sh", object_format, ref_format, write=True) as repo:
215+
assert repo.currently_rebasing_on() is None
216+
replayed = repo.tags.REBASE_HEAD.commit
217+
repo.git.update_ref("REBASE_HEAD", replayed.hexsha)
218+
assert repo.currently_rebasing_on() == replayed
219+
repo.git.update_ref("-d", "REBASE_HEAD")
220+
assert repo.currently_rebasing_on() is None
221+
222+
223+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
224+
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
225+
def test_rebase_detection_peels_nested_annotated_tags(object_format, ref_format):
226+
with fixture_repo("exact-refs.sh", object_format, ref_format, "nested", write=True) as repo:
227+
tag = repo.tags["nested/outer"].object
228+
assert tag.object.type == "tag"
229+
repo.git.update_ref("REBASE_HEAD", tag.hexsha)
230+
expected = repo.tags.REBASE_HEAD.commit
231+
assert repo.git.rev_parse("REBASE_HEAD^{commit}") == expected.hexsha
232+
assert repo.currently_rebasing_on() == expected
233+
234+
235+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
236+
@pytest.mark.parametrize("ref_format", ["files", "reftable"])
237+
def test_exact_reference_reads_preserve_existing_targets(object_format, ref_format):
238+
with fixture_repo("exact-refs.sh", object_format, ref_format, "existing", write=True) as repo:
239+
commit = repo.tags.REBASE_HEAD.commit
240+
paths = [
241+
"HEAD",
242+
"refs/heads/unborn",
243+
"FETCH_HEAD",
244+
"refs/tags/nested/release",
245+
"refs/stash",
246+
"ORIG_HEAD",
247+
"MERGE_HEAD",
248+
"REBASE_HEAD",
249+
]
250+
repo.git.pack_refs("--all")
251+
for path in paths:
252+
expected = repo.git.rev_parse("--verify", path)
253+
assert SymbolicReference(repo, path).object.hexsha == expected
254+
repo.git.update_ref("--no-deref", "HEAD", commit.hexsha)
255+
assert repo.head.is_detached
256+
assert repo.head.commit == commit
257+
258+
166259
class TestRefs(TestBase):
167260
def test_from_path(self):
168261
# Should be able to create any reference directly.

‎test/test_rev_parse.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ def test_rev_parse_names_hex_and_describe_forms(rev_parse_repo):
8080
assert collision.rev_parse(describe_name).hexsha == release.hexsha
8181

8282

83+
def test_rev_parse_hex_prefix_with_arbitrary_suffix():
84+
with fixture_repo("rev-parse.sh", "hex-prefix-collision") as repo:
85+
prefix = (Path(repo.working_tree_dir).parent / "hex-prefix").read_text().strip()
86+
name = prefix + "-topic"
87+
assert repo.rev_parse(name).hexsha == repo.git.rev_parse("--verify", name)
88+
missing = prefix + "-missing"
89+
with pytest.raises(GitCommandError):
90+
repo.git.rev_parse("--verify", missing)
91+
with pytest.raises(BadName):
92+
repo.rev_parse(missing)
93+
94+
8395
def test_rev_parse_navigation_and_peeling(rev_parse_repo):
8496
repo = rev_parse_repo["repo"]
8597
root = rev_parse_repo["root"]

‎tests/fixtures/exact-refs.sh‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
4+
git init --object-format="$1" --ref-format="$2" --initial-branch=main source
5+
git -C source commit --allow-empty -m 'Remote history'
6+
for name in refs/heads/unborn REBASE_HEAD ORIG_HEAD refs/tags/missing refs/remotes/origin/missing; do
7+
git -C source tag "$name"
8+
done
9+
git -C source tag -a nested/release -m 'Annotated release'
10+
if [ "${3-}" = nested ]; then
11+
git -C source tag -a nested/outer nested/release -m 'Nested annotated release'
12+
fi
13+
git init --object-format="$1" --ref-format="$2" --initial-branch=unborn repo
14+
git -C repo fetch ../source 'refs/tags/*:refs/tags/*'
15+
if [ "${3-}" = existing ]; then
16+
commit=$(git -C source rev-parse HEAD)
17+
for path in refs/heads/unborn refs/stash ORIG_HEAD REBASE_HEAD; do
18+
git -C repo update-ref "$path" "$commit"
19+
done
20+
printf '%s\n' "$commit" >repo/.git/MERGE_HEAD
21+
fi

‎tests/fixtures/rev-parse.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ describe-collision)
4040
abbrev=$(git rev-parse --short=7 "$merge")
4141
git tag "anything-9-g$abbrev" "$release"
4242
;;
43+
hex-prefix-collision)
44+
blob=$(git rev-parse HEAD:README.md)
45+
prefix=$(git rev-parse --short=7 "$blob")
46+
git branch "$prefix-topic" "$release"
47+
printf '%s\n' "$prefix" > ../hex-prefix
48+
;;
4349
rev_parse_preserves_path_metadata_with_colons)
4450
blob=$(git rev-parse HEAD:dir/file.txt)
4551
subtree=$(printf '100644 blob %s\tname:with:colons\n' "$blob" | git mktree)

0 commit comments

Comments
 (0)