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