Coverage for vibefpmgui/git_stats.py: 97%
91 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The Git panel's *Stats*: numbers about the project's repository (its
2commits, the people who made them, branches, tags, files, size) and each
3person's commits over time, from the first commit on, for the history of
4what's checked out (HEAD).
6People are told apart by name, as git log shows them (with the
7repository's ``.mailmap``, if it has one): one person with two email
8addresses is one; the dates are the commits' author dates.
10No Qt: `vibefpmgui.git_stats_view` shows them; they're read on a pool thread.
11"""
13from __future__ import annotations
15import time
16from dataclasses import dataclass, field
17from pathlib import Path
19from .git_status import GitError, _check, _git, find_repository
21DAY = 24 * 3600
24@dataclass
25class Author:
26 name: str
27 email: str # (the one of their latest commit)
28 times: list[int] = field(default_factory=list) # their commits' dates (seconds since 1970), oldest first
30 @property
31 def commits(self) -> int:
32 return len(self.times)
34 @property
35 def first(self) -> int:
36 return self.times[0]
38 @property
39 def last(self) -> int:
40 return self.times[-1]
43@dataclass
44class RepoStats:
45 root: Path
46 head: str # the commit checked out (its hash)
47 authors: list[Author] # most commits first
48 merges: int = 0 # commits merging others
49 branches: int = 0 # local ones
50 remote_branches: int = 0
51 tags: int = 0
52 files: int = 0 # tracked by git
53 size: int = 0 # bytes the repository's objects take on disk (.git)
55 @property
56 def commits(self) -> int:
57 return sum(a.commits for a in self.authors)
59 @property
60 def first(self) -> int:
61 return min(a.first for a in self.authors)
63 @property
64 def last(self) -> int:
65 return max(a.last for a in self.authors)
67 def since(self, seconds: int, now: float | None = None) -> int:
68 """The commits made in the last ``seconds`` (up to ``now``)."""
69 now = time.time() if now is None else now
70 return sum(1 for a in self.authors for t in a.times if now - seconds <= t <= now)
73def parse_log(output: bytes) -> list[Author]:
74 """The people in ``git log --format=%at%x1f%aN%x1f%aE`` (newest commit
75 first, as git lists them), most commits first (then by name)."""
76 people: dict[str, Author] = {}
77 for line in output.decode("utf-8", "replace").splitlines():
78 parts = line.split("\x1f")
79 if len(parts) != 3 or not parts[0].strip().isdigit():
80 continue
81 when, name, email = int(parts[0]), parts[1].strip() or "(no name)", parts[2].strip()
82 person = people.setdefault(name, Author(name, email))
83 person.times.append(when)
84 for person in people.values():
85 person.times.sort()
86 return sorted(people.values(), key=lambda a: (-a.commits, a.name.lower()))
89def parse_size(output: bytes) -> int:
90 """Bytes on disk from ``git count-objects -v`` (its loose and packed
91 objects' sizes, in KiB)."""
92 sizes = {}
93 for line in output.decode("utf-8", "replace").splitlines():
94 key, _, value = line.partition(":")
95 if value.strip().isdigit(): 95 ↛ 93line 95 didn't jump to line 93 because the condition on line 95 was always true
96 sizes[key.strip()] = int(value)
97 return (sizes.get("size", 0) + sizes.get("size-pack", 0)) * 1024
100def _count(output: bytes, separator: bytes = b"\n") -> int:
101 return sum(1 for part in output.split(separator) if part.strip())
104def read_stats(path: Path) -> RepoStats | None:
105 """The statistics of the repository ``path`` is in (None: not in one, or
106 it has no commits yet). Raises GitError if git can't read it."""
107 root = find_repository(path)
108 if root is None:
109 return None
110 code, head, _message = _git(root, "rev-parse", "--verify", "-q", "HEAD")
111 if code != 0 or not head or not head.strip():
112 return None # (no commits yet)
113 log = _check(root, "log", "--format=%at%x1f%aN%x1f%aE", "HEAD")
114 refs = _check(root, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes", "refs/tags")
115 names = refs.decode("utf-8", "replace").splitlines()
116 stats = RepoStats(root, head.decode().strip(), parse_log(log))
117 if not stats.authors: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true
118 raise GitError("git log listed no commits")
119 stats.merges = int(_check(root, "rev-list", "--count", "--merges", "HEAD").strip() or 0)
120 stats.branches = sum(1 for n in names if n.startswith("refs/heads/"))
121 stats.remote_branches = sum(1 for n in names if n.startswith("refs/remotes/") and not n.endswith("/HEAD"))
122 stats.tags = sum(1 for n in names if n.startswith("refs/tags/"))
123 stats.files = _count(_check(root, "ls-files", "-z"), b"\0")
124 stats.size = parse_size(_check(root, "count-objects", "-v"))
125 return stats
128def span_words(seconds: float) -> str:
129 """A length of time in words: "3 years, 2 months", "5 days"."""
130 days = int(seconds // DAY)
131 years, days = divmod(days, 365)
132 months, days = divmod(days, 30)
133 parts = [(years, "year"), (months, "month")] if years else [(months, "month"), (days, "day")]
134 words = [f"{n} {unit}{'s' * (n != 1)}" for n, unit in parts if n]
135 return ", ".join(words) if words else "less than a day"