Skip to content

Commit bc8a5a7

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 936c3c3 commit bc8a5a7

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
@@ -660,6 +660,70 @@ def tree_diff(repository: Any, left: Any, right: Any, paths: Any, patch: bool, o
660660
return result
661661

662662

663+
def commit_stats(commit: Any) -> Any:
664+
"""Count changed lines natively, retaining Git's statistics representation."""
665+
if gix is None:
666+
return NotImplemented
667+
from git.util import Stats
668+
669+
method = "Commit.stats"
670+
commit.repo.git._require_version()
671+
try:
672+
repo = _repository(commit.repo.git, {})
673+
if not hasattr(repo, "diff_tree_to_tree") or not hasattr(repo, "attributes_only"):
674+
raise _Unsupported("diff or attributes feature disabled")
675+
if repo.config_snapshot().string("diff.algorithm") not in (None, b"myers", b"default"):
676+
raise _Unsupported("statistics diff algorithm")
677+
native = repo.find_commit(gix.ObjectId(commit.hexsha))
678+
with native.parent_ids() as parents:
679+
parent = next(parents, None)
680+
before = repo.empty_tree() if parent is None else repo.find_commit(parent).tree()
681+
changes = repo.diff_tree_to_tree(before, native.tree(), gix.DiffOptions().track_path().track_rewrites(None))
682+
index = _index(repo, {"env": commit.repo.git.environment()})
683+
# The blob cache reads index attributes, whereas Git also reads worktree attributes.
684+
attributes = [repo.attributes_only(index, source) for source in ("id_mapping", "worktree_then_id_mapping")]
685+
cache = repo.diff_resource_cache_for_tree_diff()
686+
lines = []
687+
for change in sorted(changes, key=lambda change: change.location()):
688+
mode = change.entry_mode()
689+
previous_mode = change.details.get("previous_entry_mode", mode)
690+
if mode == 0o40000 or previous_mode == 0o40000:
691+
if mode != previous_mode:
692+
raise _Unsupported("statistics directory type change")
693+
continue
694+
if mode == 0o160000 or previous_mode == 0o160000:
695+
raise _Unsupported("statistics gitlinks")
696+
path = change.location()
697+
if any(byte < 32 or byte >= 127 or byte in b'"\\' for byte in path):
698+
raise _Unsupported("statistics filename quoting")
699+
for stack in attributes:
700+
outcome = stack.selected_attribute_matches(["diff"])
701+
stack.at_entry(path, mode).matching_attributes(outcome)
702+
with outcome.iter_selected() as matches:
703+
if any(match.state != "unspecified" for match in matches):
704+
raise _Unsupported("statistics diff attributes (GIX-18)")
705+
for oid in (change.id(), change.details.get("previous_id")):
706+
if oid is not None and repo.find_header(oid).size() > 8 * 1024 * 1024:
707+
raise _Unsupported("large object streaming (GIX-2)")
708+
counts = change.diff(cache).line_counts()
709+
cache.clear_resource_cache()
710+
kind = {"Addition": "A", "Deletion": "D", "Modification": "M"}[change.kind]
711+
if mode & 0o170000 != previous_mode & 0o170000:
712+
kind = "T"
713+
lines.append(
714+
"%s\t%d\t%d\t%s\n"
715+
% (kind, counts.insertions if counts else 0, counts.removals if counts else 0, safe_decode(path))
716+
)
717+
result = Stats._list_from_string(commit.repo, "".join(lines))
718+
except _Unsupported as exc:
719+
return _fallback(method, str(exc))
720+
except gix.Error as exc:
721+
_logger.debug("commit_stats native read: %s", exc)
722+
return _fallback(method, "native read diagnostics")
723+
record(method, "native")
724+
return result
725+
726+
663727
def _mktree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
664728
if args != ["-z", "--missing"]:
665729
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
@@ -298,3 +298,28 @@ def test_native_raw_diff_matches_cli(repo):
298298
native = before.diff(after, **options)
299299
with patch.object(_backend, "gix", None):
300300
assert native == before.diff(after, **options)
301+
302+
303+
def test_native_commit_statistics_match_cli(repo):
304+
root = Path(repo.working_dir)
305+
for name, content in (("file", b"one\ntwo\n"), ("binary", b"a\0b"), ("old", b"gone\n"), ("link", b"text")):
306+
(root / name).write_bytes(content)
307+
repo.index.add(["file", "binary", "old", "link"])
308+
first = repo.index.commit("first", skip_hooks=True)
309+
(root / "file").write_bytes(b"two\nthree\nfour\n")
310+
(root / "binary").write_bytes(b"b\0c")
311+
(root / "old").rename(root / "new")
312+
(root / "link").unlink()
313+
(root / "link").symlink_to("file")
314+
repo.git.add("--all")
315+
second = repo.index.commit("second", skip_hooks=True)
316+
empty = repo.index.commit("empty", skip_hooks=True)
317+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
318+
native = [(commit.stats.total, commit.stats.files) for commit in (first, second, empty)]
319+
with patch.object(_backend, "gix", None):
320+
assert native == [(commit.stats.total, commit.stats.files) for commit in (first, second, empty)]
321+
# Git consults uncommitted attributes too; native tree caches currently do not.
322+
(root / ".gitattributes").write_text("file -diff\n")
323+
stats = second.stats
324+
with patch.object(_backend, "gix", None):
325+
assert (stats.total, stats.files) == (second.stats.total, second.stats.files)

0 commit comments

Comments
 (0)