Skip to content

Commit bfb24b8

Browse files
codexByron
authored andcommitted
feat(gix): compute commit statistics natively
Count changed lines with native Myers blob diffs against the first-parent or empty tree, and retain the existing Stats representation. Cover root, binary, rename, type-change and empty-commit parity. Keep diff attributes, other algorithms, gitlinks, quoted paths and large blobs on Git to preserve its attribute-source and formatting semantics (GIX-2/18). The staged version passes Ruff and Python syntax checks. Its SHA-1/SHA-256 native smoke and focused pytest selection pass: 29 passed in 7.68s.
1 parent a544299 commit bfb24b8

3 files changed

Lines changed: 92 additions & 0 deletions

File tree

‎git/_backend.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,70 @@ def tree_diff(repository: Any, left: Any, right: Any, paths: Any, patch: bool, o
664664
return result
665665

666666

667+
def commit_stats(commit: Any) -> Any:
668+
"""Count changed lines natively, retaining Git's statistics representation."""
669+
if gix is None:
670+
return NotImplemented
671+
from git.util import Stats
672+
673+
method = "Commit.stats"
674+
commit.repo.git._require_version()
675+
try:
676+
repo = _repository(commit.repo.git, {})
677+
if not hasattr(repo, "diff_tree_to_tree") or not hasattr(repo, "attributes_only"):
678+
raise _Unsupported("diff or attributes feature disabled")
679+
if repo.config_snapshot().string("diff.algorithm") not in (None, b"myers", b"default"):
680+
raise _Unsupported("statistics diff algorithm")
681+
native = repo.find_commit(gix.ObjectId(commit.hexsha))
682+
with native.parent_ids() as parents:
683+
parent = next(parents, None)
684+
before = repo.empty_tree() if parent is None else repo.find_commit(parent).tree()
685+
changes = repo.diff_tree_to_tree(before, native.tree(), gix.DiffOptions().track_path().track_rewrites(None))
686+
index = _index(repo, {"env": commit.repo.git.environment()})
687+
# The blob cache reads index attributes, whereas Git also reads worktree attributes.
688+
attributes = [repo.attributes_only(index, source) for source in ("id_mapping", "worktree_then_id_mapping")]
689+
cache = repo.diff_resource_cache_for_tree_diff()
690+
lines = []
691+
for change in sorted(changes, key=lambda change: change.location()):
692+
mode = change.entry_mode()
693+
previous_mode = change.details.get("previous_entry_mode", mode)
694+
if mode == 0o40000 or previous_mode == 0o40000:
695+
if mode != previous_mode:
696+
raise _Unsupported("statistics directory type change")
697+
continue
698+
if mode == 0o160000 or previous_mode == 0o160000:
699+
raise _Unsupported("statistics gitlinks")
700+
path = change.location()
701+
if any(byte < 32 or byte >= 127 or byte in b'"\\' for byte in path):
702+
raise _Unsupported("statistics filename quoting")
703+
for stack in attributes:
704+
outcome = stack.selected_attribute_matches(["diff"])
705+
stack.at_entry(path, mode).matching_attributes(outcome)
706+
with outcome.iter_selected() as matches:
707+
if any(match.state != "unspecified" for match in matches):
708+
raise _Unsupported("statistics diff attributes (GIX-18)")
709+
for oid in (change.id(), change.details.get("previous_id")):
710+
if oid is not None and repo.find_header(oid).size() > 8 * 1024 * 1024:
711+
raise _Unsupported("large object streaming (GIX-2)")
712+
counts = change.diff(cache).line_counts()
713+
cache.clear_resource_cache()
714+
kind = {"Addition": "A", "Deletion": "D", "Modification": "M"}[change.kind]
715+
if mode & 0o170000 != previous_mode & 0o170000:
716+
kind = "T"
717+
lines.append(
718+
"%s\t%d\t%d\t%s\n"
719+
% (kind, counts.insertions if counts else 0, counts.removals if counts else 0, safe_decode(path))
720+
)
721+
result = Stats._list_from_string(commit.repo, "".join(lines))
722+
except _Unsupported as exc:
723+
return _fallback(method, str(exc))
724+
except gix.Error as exc:
725+
_logger.debug("commit_stats native read: %s", exc)
726+
return _fallback(method, "native read diagnostics")
727+
record(method, "native")
728+
return result
729+
730+
667731
def _mktree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
668732
if args != ["-z", "--missing"]:
669733
raise _Unsupported("tree writing options")

‎git/objects/commit.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,9 @@ def stats(self) -> Stats:
438438
:return:
439439
:class:`Stats`
440440
"""
441+
native = _backend.commit_stats(self)
442+
if native is not NotImplemented:
443+
return native
441444

442445
def process_lines(lines: List[str]) -> str:
443446
text = ""

‎test/test_gix_backend.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,28 @@ def test_native_raw_diff_matches_cli(repo):
299299
native = before.diff(after, **options)
300300
with patch.object(_backend, "gix", None):
301301
assert native == before.diff(after, **options)
302+
303+
304+
def test_native_commit_statistics_match_cli(repo):
305+
root = Path(repo.working_dir)
306+
for name, content in (("file", b"one\ntwo\n"), ("binary", b"a\0b"), ("old", b"gone\n"), ("link", b"text")):
307+
(root / name).write_bytes(content)
308+
repo.index.add(["file", "binary", "old", "link"])
309+
first = repo.index.commit("first", skip_hooks=True)
310+
(root / "file").write_bytes(b"two\nthree\nfour\n")
311+
(root / "binary").write_bytes(b"b\0c")
312+
(root / "old").rename(root / "new")
313+
(root / "link").unlink()
314+
(root / "link").symlink_to("file")
315+
repo.git.add("--all")
316+
second = repo.index.commit("second", skip_hooks=True)
317+
empty = repo.index.commit("empty", skip_hooks=True)
318+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
319+
native = [(commit.stats.total, commit.stats.files) for commit in (first, second, empty)]
320+
with patch.object(_backend, "gix", None):
321+
assert native == [(commit.stats.total, commit.stats.files) for commit in (first, second, empty)]
322+
# Git consults uncommitted attributes too; native tree caches currently do not.
323+
(root / ".gitattributes").write_text("file -diff\n")
324+
stats = second.stats
325+
with patch.object(_backend, "gix", None):
326+
assert (stats.total, stats.files) == (second.stats.total, second.stats.files)

0 commit comments

Comments
 (0)