Skip to content

Commit 43b1ee8

Browse files
codexByron
authored andcommitted
fix(refs): preserve Unicode whitespace in command records
Git separates reference and remote URL records with LF, but Unicode `splitlines()` also split valid names at U+0085, U+2028 and U+2029. Enumeration could return a truncated, nonexistent reference. Fetch parsing and progress handling also stripped valid Unicode whitespace from the ends of names. Split these outputs only at LF, trim fetch column padding as ASCII spaces, and remove only CR/LF framing from progress records. Preserve empty remote-URL output behavior. Reference storage and native lookup remain owned by Git/Gix. Scripted SHA-1/SHA-256 repositories contain leading, middle and trailing separators in branch, tag and remote-ref names. Tests compare Git's inventory, resolve the names, feed bytes/text through the fetch progress handler, check local stale refs and retain Unicode separators in URLs. All 26 focused cases pass on each backend after reproducing the failures. Existing progress tests, Ruff, mypy and whitespace checks pass.
1 parent fe923e8 commit 43b1ee8

6 files changed

Lines changed: 83 additions & 8 deletions

File tree

‎doc/source/changes.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ API changes
5454
* Packed tag names containing non-UTF-8 bytes are represented losslessly as
5555
surrogate-escaped strings. ``safe_encode()`` recovers their original bytes,
5656
and Gix reference validation and resolution receive those bytes directly.
57+
Reference enumeration, fetch results, stale references and remote URLs preserve
58+
Unicode line separators within names instead of treating them as record breaks.
5759
* Raw diffs preserve non-UTF-8 filename bytes in ``a_rawpath``, ``b_rawpath``,
5860
and raw rename fields. Staged-file diffs no longer fail while decoding legacy
5961
filenames; blob contents remain byte streams regardless of their encoding.

‎git/refs/symbolic.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ def _iter_items(
698698
output = repo.git._call_process_safe(
699699
"for_each_ref", "--format=%(refname)", *options, "--", *([prefix] if prefix else [])
700700
)
701-
for path in sorted(output.splitlines()):
701+
for path in sorted(output.split("\n")):
702702
try:
703703
yield cls.from_path(repo, path)
704704
except ValueError:

‎git/remote.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ def _from_line(cls, repo: "Repo", line: str, refspecs: Sequence[str] = (), refs:
380380
if match is None:
381381
raise ValueError("Failed to parse fetch output: %r" % line)
382382
control, operation, source, destination, note = match.groups()
383-
source = source.strip()
383+
source = source.strip(" ")
384384
if control not in cls._flag_map:
385385
raise ValueError("Unknown fetch status: %r" % control)
386386
flags = cls._flag_map[cast(flagKeyLiteral, control)]
@@ -393,7 +393,7 @@ def _from_line(cls, repo: "Repo", line: str, refspecs: Sequence[str] = (), refs:
393393
old_commit = None
394394
if control in (" ", "+"):
395395
old_commit = repo.rev_parse(operation.split("..", 1)[0])
396-
destination = destination.strip()
396+
destination = destination.strip(" ")
397397
if destination == "FETCH_HEAD":
398398
ref = SymbolicReference(repo, destination)
399399
else:
@@ -618,7 +618,9 @@ def delete_url(self, url: str, **kwargs: Any) -> "Remote":
618618
@property
619619
def urls(self) -> Iterator[str]:
620620
""":return: Iterator yielding all configured URL targets on a remote as strings"""
621-
yield from self.repo.git._call_process_safe("remote", "get-url", "--all", "--", self.name).splitlines()
621+
output = self.repo.git._call_process_safe("remote", "get-url", "--all", "--", self.name)
622+
if output:
623+
yield from output.split("\n")
622624

623625
@property
624626
def refs(self) -> IterableList[RemoteReference]:
@@ -655,7 +657,7 @@ def stale_refs(self) -> IterableList[Reference]:
655657
out_refs: IterableList[Reference] = IterableList(RemoteReference._id_attribute_, "%s/" % self.name)
656658
for line in self.repo.git._call_process_safe(
657659
"remote", "prune", "--dry-run", "--", self, _allow_network=True
658-
).splitlines()[2:]:
660+
).split("\n")[2:]:
659661
# expecting
660662
# * [would prune] origin/new_branch
661663
token = " * [would prune] "
@@ -817,7 +819,7 @@ def _get_fetch_info_from_stderr(
817819
fetch_info_lines.append(line)
818820
continue
819821

820-
refs = self.repo.git._call_process_safe("for_each_ref", "--format=%(refname)").splitlines()
822+
refs = self.repo.git._call_process_safe("for_each_ref", "--format=%(refname)").split("\n")
821823
try:
822824
configured = self.config_reader.config.get_values(self._config_section_name(), "fetch")
823825
except (cp.NoSectionError, cp.NoOptionError):

‎git/util.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@ def new_message_handler(self) -> Callable[[str], None]:
812812
"""
813813

814814
def handler(line: AnyStr) -> None:
815-
self._parse_progress_line(line.rstrip())
815+
self._parse_progress_line(line.rstrip(b"\r\n") if isinstance(line, bytes) else line.rstrip("\r\n"))
816816

817817
# END handler
818818

‎test/test_refs.py‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import unittest
1010
from itertools import chain
1111
from pathlib import Path
12-
from unittest.mock import patch
12+
from unittest.mock import Mock, patch
1313

1414
import pytest
1515

@@ -24,6 +24,8 @@
2424
Head,
2525
Reference,
2626
RefLog,
27+
Remote,
28+
RemoteProgress,
2729
RemoteReference,
2830
SymbolicReference,
2931
TagReference,
@@ -37,6 +39,58 @@
3739
from test.lib.fixtures import fixture_repo, read_only_fixture, writable_fixture
3840

3941

42+
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
43+
def test_reference_names_preserve_unicode_line_separators(object_format):
44+
names = [
45+
before + separator + after
46+
for separator in ("\x85", "\u2028", "\u2029")
47+
for before, after in (("", "after"), ("before", "after"), ("before", ""))
48+
]
49+
with fixture_repo("ref-line-separators.sh", object_format) as repo:
50+
expected = {
51+
f"{prefix}/{name}" for prefix in ("refs/heads", "refs/tags", "refs/remotes/origin") for name in names
52+
} | {"refs/heads/main"}
53+
assert set(repo.git.for_each_ref("--format=%(refname)").split("\n")) == expected
54+
assert {ref.path for ref in repo.references} == expected
55+
assert {head.name for head in repo.heads} == set(names) | {"main"}
56+
assert {tag.name for tag in repo.tags} == set(names)
57+
assert {ref.remote_head for ref in RemoteReference.iter_items(repo, remote="origin")} == set(names)
58+
for ref in repo.references:
59+
assert ref.commit == repo.head.commit
60+
61+
62+
@pytest.mark.parametrize("separator", ["\x85", "\u2028", "\u2029"])
63+
@pytest.mark.parametrize("before,after", [("", "after"), ("before", "after"), ("before", "")])
64+
@pytest.mark.parametrize("as_bytes", [False, True])
65+
def test_fetch_resolves_unicode_line_separator_names_from_inventory(separator, before, after, as_bytes):
66+
name = before + separator + after
67+
with fixture_repo("ref-line-separators.sh", "sha1") as repo:
68+
progress = RemoteProgress()
69+
line = f" * [new branch] {name} -> origin/{name}"
70+
progress.new_message_handler()((line + "\n").encode() if as_bytes else line + "\n")
71+
assert progress.other_lines == [line]
72+
with patch("git.remote.handle_process_output"):
73+
result = Remote(repo, "inventory")._get_fetch_info_from_stderr(Mock(), progress)
74+
assert len(result) == 1
75+
assert result[0].ref.path == f"refs/remotes/origin/{name}"
76+
assert result[0].remote_ref_path == name
77+
78+
79+
def test_remote_urls_preserve_unicode_line_separators():
80+
with fixture_repo("ref-line-separators.sh", "sha1") as repo:
81+
assert list(repo.remote("unicode").urls) == [
82+
f"https://example.invalid/before{separator}after" for separator in ("\x85", "\u2028", "\u2029")
83+
]
84+
with patch.object(Git, "_call_process_safe", return_value=""):
85+
assert list(Remote(repo, "empty").urls) == []
86+
87+
88+
def test_stale_refs_preserve_unicode_line_separators():
89+
with fixture_repo("ref-line-separators.sh", "sha1") as repo:
90+
origin = repo.remote("origin")
91+
assert {ref.path for ref in origin.stale_refs} == {ref.path for ref in origin.refs}
92+
93+
4094
@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
4195
def test_non_utf8_tag_names_preserve_bytes(object_format):
4296
with fixture_repo("legacy-refs.sh", object_format) as repo:
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
git init --initial-branch=main --object-format="$1" repo
4+
git init --bare --initial-branch=main --object-format="$1" upstream
5+
cd repo
6+
git remote add origin ../upstream
7+
tree=$(git mktree </dev/null)
8+
commit=$(git commit-tree "$tree" -m initial)
9+
git update-ref refs/heads/main "$commit"
10+
for separator in $'\302\205' $'\342\200\250' $'\342\200\251'; do
11+
git config --add remote.unicode.url "https://example.invalid/before${separator}after"
12+
for prefix in refs/heads refs/tags refs/remotes/origin; do
13+
for name in "${separator}after" "before${separator}after" "before${separator}"; do
14+
git update-ref "$prefix/$name" "$commit"
15+
done
16+
done
17+
done

0 commit comments

Comments
 (0)