Skip to content

Commit a5163e8

Browse files
codexByron
authored andcommitted
fix(refs): preserve non-UTF-8 names at native API boundaries (#2064)
Packed-reference enumeration now delegates to Git/Gix and no longer has the original UTF-8 file-reader failure. However, tag paths retain undecodable bytes as surrogate escapes, and passing those strings back into GixPython raises `UnicodeEncodeError` before validation or reference lookup. Make `safe_encode()` the byte-preserving inverse of `safe_decode()`. Use it at the supported Gix reference validation, lookup, revision, reflog and edit boundaries, and when serializing Git reference transactions. GixPython 0.3.0 accepts bytes in these APIs, so this fixes adapter conversion without adding a reference-storage implementation or requiring an upstream workaround. Add a scripted packed-ref fixture with lightweight and annotated non-UTF-8 tag names for both SHA-1 and SHA-256. Git 2.54.0's `for-each-ref` supplies the reference inventory; `git-for-each-ref.adoc` documents the `refname` and `objectname` projections used for the baseline. The regression checks byte round trips, tag/commit resolution, CLI parity and absence of CLI fallback during native resolution. Packed records are fixture inputs because APFS rejects those bytes in loose reference filenames. Validation: 42 reference tests and 24 subtests passed with CLI; 144 reference and backend tests and 24 subtests passed with Gix. Ruff, ShellCheck and mypy passed. Windows behavior will be validated by CI.
1 parent b3d736a commit a5163e8

7 files changed

Lines changed: 71 additions & 17 deletions

File tree

‎doc/gix-backend.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,15 @@ repository-format validation gap.
389389
| `Repo.untracked_files` | Native directory walk | Extra status options and non-root command directories |
390390
| `Repo.ignored` | Native excludes with tracked-file suppression | Symlink/submodule traversal, unsupported path normalization, and unavailable path metadata |
391391

392+
Reference names and revisions are encoded with `surrogateescape` before calling
393+
GixPython's byte-capable `Target.Symbolic`, `Repository.try_find_reference`,
394+
`Repository.rev_parse_single`, `Repository.rev_parse` and `RefEdit` APIs.
395+
This preserves non-UTF-8 packed tag names without interpreting reference storage
396+
in Python. `test_non_utf8_tag_names_preserve_bytes_and_resolve` covers lightweight
397+
and annotated tags in SHA-1 and SHA-256 repositories and forbids CLI fallback
398+
during native resolution. This corrects adapter input conversion; GixPython 0.3.0
399+
already accepts the original bytes through those APIs.
400+
392401
Repository-backed native operations fall back for reftable (GIX-1) and repositories
393402
with `extensions.compatObjectFormat` (GIX-19). Process-returning calls, timeouts,
394403
custom storage/environment, global Git options, and unhandled command options

‎doc/source/changes.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ Git executable and safety
5151
API changes
5252
-----------
5353

54+
* Packed tag names containing non-UTF-8 bytes remain losslessly represented as
55+
surrogate-escaped strings. ``safe_encode()`` recovers their original bytes,
56+
and Gix reference validation and resolution receive those bytes directly.
5457
* Raw diffs preserve non-UTF-8 filename bytes in ``a_rawpath``, ``b_rawpath``,
5558
and raw rename fields. Staged-file diffs no longer fail while decoding legacy
5659
filenames; blob contents remain byte streams regardless of their encoding.

‎git/_backend.py‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from threading import Lock
1717
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple, cast
1818

19-
from git.compat import defenc, safe_decode
19+
from git.compat import defenc, safe_decode, safe_encode
2020
from git.exc import GitCommandError
2121

2222
_selection = os.environ.get("GIT_PYTHON_BACKEND")
@@ -218,7 +218,7 @@ def _oid(repo: Any, ref: Any, env: Optional[Dict[str, Any]] = None) -> Any:
218218
_index(repo, {"env": env or {}})
219219
if not hasattr(repo, "rev_parse_single"):
220220
raise _Unsupported("revision feature disabled")
221-
return repo.rev_parse_single(ref)
221+
return repo.rev_parse_single(safe_encode(ref))
222222

223223

224224
def object_data(command: Any, ref: bytes, *, stream: bool = False) -> Any:
@@ -296,7 +296,7 @@ def revision_info(command: Any, ref: str) -> Any:
296296
repo = _repository(command, {})
297297
if ref.startswith(":"):
298298
_index(repo, {"env": command.environment()})
299-
spec = repo.rev_parse(ref)
299+
spec = repo.rev_parse(safe_encode(ref))
300300
oid = spec.single()
301301
if oid is None:
302302
raise _Unsupported("multiple revisions")
@@ -362,7 +362,7 @@ def check_ref_name(name: str) -> Any:
362362
if gix is None:
363363
return NotImplemented
364364
try:
365-
gix.Target.Symbolic(name)
365+
gix.Target.Symbolic(safe_encode(name))
366366
except gix.Error as exc:
367367
if "/" not in name:
368368
return _fallback("Reference.validate", "standalone reference names (GIX-20)")
@@ -380,10 +380,11 @@ def reference_info(command: Any, path: str) -> Any:
380380
if path != "HEAD" and not path.startswith("refs/"):
381381
raise _Unsupported("partial reference names")
382382
repo = _repository(command, {})
383-
reference = repo.try_find_reference(path)
383+
encoded_path = safe_encode(path)
384+
reference = repo.try_find_reference(encoded_path)
384385
result = None
385386
if reference is not None:
386-
if reference.name() != os.fsencode(path):
387+
if reference.name() != encoded_path:
387388
raise _Unsupported("partial reference lookup")
388389
target = reference.target()
389390
name = target.try_name()
@@ -400,7 +401,7 @@ def reference_info(command: Any, path: str) -> Any:
400401
def _symbolic_ref(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
401402
if len(args) != 4 or args[:3] != ["--quiet", "--no-recurse", "--"]:
402403
raise _Unsupported("reference mutation or options")
403-
reference = repo.try_find_reference(args[-1])
404+
reference = repo.try_find_reference(safe_encode(args[-1]))
404405
if reference is None:
405406
raise _Unsupported("missing reference diagnostics")
406407
target = reference.target().try_name()
@@ -415,7 +416,7 @@ def _for_each_ref(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
415416
split = args.index("--") if "--" in args else len(args)
416417
if args[1:split] or len(args[split + 1 :]) > 1:
417418
raise _Unsupported("root refs or reference options")
418-
prefix = os.fsencode(args[-1]) if len(args) > split + 1 else b""
419+
prefix = safe_encode(args[-1]) if len(args) > split + 1 else b""
419420
if any(char in prefix for char in b"*?["):
420421
raise _Unsupported("reference glob patterns")
421422
with repo.references().all() as refs:
@@ -739,7 +740,7 @@ def _date(signature: Any) -> bytes:
739740

740741
def _reflog(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
741742
if len(args) == 3 and args[:2] == ["exists", "--"]:
742-
reference = repo.try_find_reference(args[-1])
743+
reference = repo.try_find_reference(safe_encode(args[-1]))
743744
if reference is None:
744745
raise _Unsupported("orphan reflog lookup")
745746
if not reference.log_exists():
@@ -761,7 +762,7 @@ def _reflog(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
761762
or args[-1] != "--"
762763
):
763764
raise _Unsupported("reflog writing or format")
764-
reference = repo.try_find_reference(args[-2])
765+
reference = repo.try_find_reference(safe_encode(args[-2]))
765766
if reference is None:
766767
raise _Unsupported("orphan reflog lookup")
767768
cursor = reference.log_iter().rev()
@@ -1174,7 +1175,7 @@ def _update_ref(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
11741175
if delete:
11751176
if len(operands) != 1 or deref:
11761177
raise _Unsupported("reference deletion options")
1177-
edit = gix.RefEdit.delete(operands[0], gix.PreviousValue.Any).with_deref(False)
1178+
edit = gix.RefEdit.delete(safe_encode(operands[0]), gix.PreviousValue.Any).with_deref(False)
11781179
_write("update_ref", lambda: repo.edit_references_as([edit]))
11791180
return b""
11801181
if len(operands) != 2:
@@ -1184,7 +1185,8 @@ def _update_ref(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
11841185
kind = repo.find_header(oid).kind()
11851186
if (name == "HEAD" or name.startswith("refs/heads/")) and kind != "commit":
11861187
raise _Unsupported("branch target validation")
1187-
reference = repo.try_find_reference(name)
1188+
encoded_name = safe_encode(name)
1189+
reference = repo.try_find_reference(encoded_name)
11881190
if reference is not None:
11891191
if deref and name != "HEAD" and reference.target().try_name() is not None:
11901192
raise _Unsupported("symbolic reference update (GIX-10)")
@@ -1196,13 +1198,15 @@ def _update_ref(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
11961198
for _ in range(5):
11971199
if head is None or head.target().try_name() is None:
11981200
break
1199-
if head.target().try_name() == os.fsencode(name):
1201+
if head.target().try_name() == encoded_name:
12001202
raise _Unsupported("active branch HEAD reflog (GIX-10)")
12011203
head = head.follow()
12021204
log = gix.LogChange()
12031205
log.force_create_reflog = force_log
12041206
log.message = message
1205-
edit = gix.RefEdit.update_with_log(name, gix.Target.Object(oid), gix.PreviousValue.Any, log).with_deref(deref)
1207+
edit = gix.RefEdit.update_with_log(encoded_name, gix.Target.Object(oid), gix.PreviousValue.Any, log).with_deref(
1208+
deref
1209+
)
12061210
signature = _committer(repo, kwargs.get("env", {}))
12071211
_write("update_ref", lambda: repo.edit_references_as([edit], signature))
12081212
return b""

‎git/compat.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ def safe_encode(s: AnyStr) -> bytes: ...
6565

6666

6767
def safe_encode(s: Optional[AnyStr]) -> Optional[bytes]:
68-
"""Safely encode a binary string to Unicode."""
68+
"""Encode text to bytes, preserving bytes recovered by :func:`safe_decode`."""
6969
if isinstance(s, str):
70-
return s.encode(defenc)
70+
return s.encode(defenc, "surrogateescape")
7171
elif isinstance(s, bytes):
7272
return s
7373
elif s is None:

‎git/refs/symbolic.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from git import _backend
1313
from git.cmd import Git
14+
from git.compat import safe_encode
1415
from git.exc import GitCommandError, UnsafeOptionError
1516
from git.objects.base import Object
1617
from git.objects.commit import Commit
@@ -462,7 +463,7 @@ def _transaction(repo: "Repo", commands: str, logmsg: Union[str, None] = None) -
462463
if first_line:
463464
options.extend(("-m", first_line))
464465
with tempfile.TemporaryFile() as stream:
465-
stream.write(commands.encode("utf-8"))
466+
stream.write(safe_encode(commands))
466467
stream.seek(0)
467468
repo.git._call_process_safe("update_ref", "--no-deref", "--stdin", "-z", *options, istream=stream)
468469

‎test/test_refs.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,53 @@
1010
from pathlib import Path
1111
from unittest.mock import patch
1212

13+
import pytest
14+
1315
from gitdb.exc import BadName
1416

1517
import git.refs as refs
1618
from git import (
1719
Commit,
1820
GitCommandError,
1921
GitConfigParser,
22+
Git,
2023
Head,
2124
Reference,
2225
RefLog,
2326
RemoteReference,
2427
SymbolicReference,
2528
TagReference,
29+
_backend,
2630
)
31+
from git.compat import safe_encode
2732
from git.exc import UnsafeOptionError
2833
from git.objects.tag import TagObject
2934
from git.util import Actor
3035
from test.lib import PathLikeMock, TestBase, requires_symlinks, with_rw_repo
3136
from test.lib.fixtures import fixture_repo, read_only_fixture, writable_fixture
3237

3338

39+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
40+
def test_non_utf8_tag_names_preserve_bytes_and_resolve(object_format):
41+
with fixture_repo("legacy-refs.sh", object_format) as repo:
42+
tags = repo.tags
43+
assert len(tags) == 2
44+
assert {safe_encode(tag.path) for tag in tags} == {b"refs/tags/release-\xf5", b"refs/tags/annotated-\xe3"}
45+
native_guard = (
46+
patch.object(Git, "execute", side_effect=AssertionError("unexpected reference CLI call"))
47+
if _backend.name == "gix"
48+
else contextlib.nullcontext()
49+
)
50+
with native_guard:
51+
for tag in tags:
52+
assert tag.commit == repo.head.commit
53+
assert repo.rev_parse(tag.path).hexsha == tag.object.hexsha
54+
assert repo.commit(tag.path) == repo.head.commit
55+
if _backend.name == "gix":
56+
with patch.object(_backend, "gix", None):
57+
assert {tag.path for tag in repo.tags} == {tag.path for tag in tags}
58+
59+
3460
class TestRefs(TestBase):
3561
def test_from_path(self):
3662
# Should be able to create any reference directly.

‎tests/fixtures/legacy-refs.sh‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
git init --initial-branch=main --object-format="$1" repo
4+
cd repo
5+
tree=$(git mktree </dev/null)
6+
commit=$(printf initial | git commit-tree "$tree")
7+
git update-ref refs/heads/main "$commit"
8+
tag=$(printf 'object %s\ntype commit\ntag annotated\ntagger Example <example@example.invalid> 1100000000 +0000\n\nrelease\n' "$commit" | git hash-object -t tag -w --stdin)
9+
# Packed refs can carry bytes that the fixture filesystem rejects in loose names.
10+
printf '# pack-refs with: peeled fully-peeled sorted\n%s refs/tags/annotated-\343\n^%s\n%s refs/tags/release-\365\n' \
11+
"$tag" "$commit" "$commit" >.git/packed-refs

0 commit comments

Comments
 (0)