Skip to content

Commit b0910a4

Browse files
codexByron
authored andcommitted
fix(stats): return filenames instead of Git-quoted spellings
`Commit.stats.files` exposed Git's C-quoted output as dictionary keys. With `core.quotePath=true`, byte names and many Unicode names were quoted; tabs, newlines, quotes and backslashes could also produce keys that were not paths. After parsing the numeric records, unquote filename keys with the existing diff path decoder and lossless byte/text helpers. Unquoting after framing keeps tabs and newlines inside filenames from becoming record delimiters. Native statistics and the existing Gix filename-quoting fallback are unchanged. The scripted Git 2.54.0 regression covers both quotePath settings, byte and Unicode names, literal quoting/backslashes, root/update/delete commits and binary counts. All eight subtests pass with both backends after failing before the fix; the connected CLI stats/rename selection passes five tests and eight subtests. Ruff, mypy and whitespace checks pass.
1 parent d04a6b2 commit b0910a4

4 files changed

Lines changed: 87 additions & 4 deletions

File tree

‎doc/source/changes.rst‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ API changes
6969
* ``Commit.stats`` preserves Unicode line separators in filenames when
7070
``core.quotePath=false``. Git's LF-delimited output remains intact for root,
7171
subsequent and empty commits, including the Gix backend's filename
72-
quoting fallback.
72+
quoting fallback. File keys contain the actual names, unquoting Git's C-style
73+
escapes and preserving undecodable bytes with ``surrogateescape``.
7374
* Imports from a source checkout now read ``git.__version__`` from ``VERSION``.
7475
Built wheels and source distributions continue to carry their stamped release
7576
version.

‎git/objects/commit.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717

1818
from git import _backend
1919
from git.cmd import Git
20+
from git.compat import safe_decode, safe_encode
2021
from git.exc import BadObject, GitCommandError, UnsafeOptionError
21-
from git.diff import Diffable
22+
from git.diff import Diffable, decode_path
2223
from git.util import Actor, Stats, finalize_process, hex_to_bin
2324

2425
from . import base
@@ -47,7 +48,7 @@
4748
cast,
4849
)
4950

50-
from git.types import PathLike
51+
from git.types import Files_TD, PathLike
5152

5253
if TYPE_CHECKING:
5354
from git.refs import SymbolicReference
@@ -529,7 +530,14 @@ def process_lines(lines: List[str]) -> str:
529530
"diff", self.parents[0].hexsha, self.hexsha, "--", numstat=True, no_renames=True, raw=True
530531
).split("\n")
531532
text = process_lines(lines)
532-
return Stats._list_from_string(self.repo, text)
533+
stats = Stats._list_from_string(self.repo, text)
534+
# Unquote after parsing: filenames themselves may contain tabs and newlines.
535+
files: Dict[PathLike, Files_TD] = {
536+
safe_decode(cast(bytes, decode_path(safe_encode(os.fspath(path)), has_ab_prefix=False))): values
537+
for path, values in stats.files.items()
538+
}
539+
stats.files = files
540+
return stats
533541

534542
@property
535543
def trailers_list(self) -> List[Tuple[str, str]]:

‎test/test_commit.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from gitdb import IStream
1616

1717
from git import Actor, Commit, Repo, _backend
18+
from git.compat import safe_decode, safe_encode
1819
from git.exc import UnsafeOptionError
1920
from git.objects.util import tzoffset, utc
2021

@@ -225,6 +226,52 @@ def test_stats_preserve_unicode_line_separators(self):
225226
assert empty_stats.files == {}
226227
assert empty_stats.total == {"insertions": 0, "deletions": 0, "lines": 0, "files": 0}
227228

229+
def test_stats_preserve_quoted_filename_bytes(self):
230+
paths = (
231+
b"byte-\xff",
232+
b'"byte-\\377"',
233+
"utf8-é".encode(),
234+
b"tab\tname",
235+
b"line\nname",
236+
b'quote"name',
237+
b"back\\slash",
238+
)
239+
for quote_path in ("true", "false"):
240+
with self.subTest(quote_path=quote_path), fixture_repo("commit-stats-byte-paths.sh", quote_path) as repo:
241+
for revision, kind, added, deleted in (
242+
("update-1", "A", 1, 0),
243+
("update-2", "M", 1, 0),
244+
("HEAD", "D", 0, 2),
245+
):
246+
with self.subTest(revision=revision):
247+
stats = repo.commit(revision).stats
248+
expected = {
249+
safe_decode(path): {
250+
"insertions": added,
251+
"deletions": deleted,
252+
"lines": added + deleted,
253+
"change_type": kind,
254+
}
255+
for path in paths
256+
}
257+
if kind != "M":
258+
expected["binary\tfile"] = {
259+
"insertions": 0,
260+
"deletions": 0,
261+
"lines": 0,
262+
"change_type": kind,
263+
}
264+
assert stats.files == expected
265+
assert {safe_encode(path) for path in stats.files} == set(paths) | (
266+
{b"binary\tfile"} if kind != "M" else set()
267+
)
268+
assert stats.total == {
269+
"insertions": len(paths) * added,
270+
"deletions": len(paths) * deleted,
271+
"lines": len(paths) * (added + deleted),
272+
"files": len(expected),
273+
}
274+
228275
def test_renames(self):
229276
commit = self.rorepo.commit("185d847ec7647fd2642a82d9205fb3d07ea71715")
230277
files = commit.stats.files
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
git init --initial-branch=main repo
4+
cd repo
5+
git config core.quotePath "$1"
6+
git config diff.algorithm myers
7+
# Tree entries avoid filesystem restrictions on byte and control-character paths.
8+
paths=($'byte-\377' '"byte-\377"' $'utf8-\303\251' $'tab\tname' $'line\nname' 'quote"name' 'back\slash')
9+
binary=$(printf 'binary\0data' | git hash-object -w --stdin)
10+
for multiplier in 1 2; do
11+
blob=$(for ((line=0; line<multiplier; line++)); do printf 'line\n'; done | git hash-object -w --stdin)
12+
tree=$(
13+
{
14+
for path in "${paths[@]}"; do printf '100644 blob %s\t%s\0' "$blob" "$path"; done
15+
printf '100644 blob %s\tbinary\tfile\0' "$binary"
16+
} | git mktree -z
17+
)
18+
if [ "$multiplier" = 1 ]; then
19+
commit=$(git commit-tree "$tree" -m initial)
20+
else
21+
commit=$(git commit-tree "$tree" -p "$commit" -m update)
22+
fi
23+
git tag "update-$multiplier" "$commit"
24+
done
25+
tree=$(git mktree </dev/null)
26+
commit=$(git commit-tree "$tree" -p "$commit" -m remove)
27+
git update-ref refs/heads/main "$commit"

0 commit comments

Comments
 (0)