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

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). 

5 

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. 

9 

10No Qt: `vibefpmgui.git_stats_view` shows them; they're read on a pool thread. 

11""" 

12 

13from __future__ import annotations 

14 

15import time 

16from dataclasses import dataclass, field 

17from pathlib import Path 

18 

19from .git_status import GitError, _check, _git, find_repository 

20 

21DAY = 24 * 3600 

22 

23 

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 

29 

30 @property 

31 def commits(self) -> int: 

32 return len(self.times) 

33 

34 @property 

35 def first(self) -> int: 

36 return self.times[0] 

37 

38 @property 

39 def last(self) -> int: 

40 return self.times[-1] 

41 

42 

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) 

54 

55 @property 

56 def commits(self) -> int: 

57 return sum(a.commits for a in self.authors) 

58 

59 @property 

60 def first(self) -> int: 

61 return min(a.first for a in self.authors) 

62 

63 @property 

64 def last(self) -> int: 

65 return max(a.last for a in self.authors) 

66 

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) 

71 

72 

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())) 

87 

88 

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 

98 

99 

100def _count(output: bytes, separator: bytes = b"\n") -> int: 

101 return sum(1 for part in output.split(separator) if part.strip()) 

102 

103 

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 

126 

127 

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"