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"
DAY = 86400
@dataclass
class Author:
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]
Author(name: str, email: str, times: list[int] = <factory>)
name: str
email: str
times: list[int]
commits: int
31    @property
32    def commits(self) -> int:
33        return len(self.times)
first: int
35    @property
36    def first(self) -> int:
37        return self.times[0]
last: int
39    @property
40    def last(self) -> int:
41        return self.times[-1]
@dataclass
class RepoStats:
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)
RepoStats( root: pathlib.Path, head: str, authors: list[Author], merges: int = 0, branches: int = 0, remote_branches: int = 0, tags: int = 0, files: int = 0, size: int = 0)
root: pathlib.Path
head: str
authors: list[Author]
merges: int = 0
branches: int = 0
remote_branches: int = 0
tags: int = 0
files: int = 0
size: int = 0
commits: int
56    @property
57    def commits(self) -> int:
58        return sum(a.commits for a in self.authors)
first: int
60    @property
61    def first(self) -> int:
62        return min(a.first for a in self.authors)
last: int
64    @property
65    def last(self) -> int:
66        return max(a.last for a in self.authors)
def since(self, seconds: int, now: float | None = None) -> int:
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).

def parse_log(output: bytes) -> list[Author]:
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).

def parse_size(output: bytes) -> int:
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).

def read_stats(path: pathlib.Path) -> RepoStats | None:
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.

def span_words(seconds: float) -> str:
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".