Viewing files at commit 62758990fe31Commit detailsView latest

suites/scale/scalesuite/sizer.py 306 lines · 12.8 KB · python
1
"""git-sizer-style repository statistics, re-implemented in stdlib Python.
2
3
Metric names, definitions and "level of concern" reference values follow
4
github/git-sizer (MIT, Copyright (c) 2018 GitHub; see suites/scale/third_party/git-sizer/NOTICE and
5
docs/prior-art.md). This is an independent re-implementation: no git-sizer
6
code is copied. Values are gathered with plain git plumbing
7
(``cat-file --batch-check --batch-all-objects``, ``log --all``, ``cat-file
8
--batch`` for trees and tags) and recorded in every scale row's provenance.
9
10
Differences from git-sizer, stated so a number is never misread:
11
- object totals count every object in the object database
12
  (``object_scope: all_objects_in_odb``); git-sizer counts objects reachable
13
  from references. Generated fixtures contain no unreachable objects, so the
14
  two agree on them;
15
- "biggest checkout" metrics are the maximum over every commit's root tree
16
  (git-sizer semantics, ``checkout_scope: all_commits``) while the number of
17
  distinct root trees is at most ``max_checkout_roots``; above that the
18
  maximum is taken over reference tips only (``checkout_scope: ref_tips``),
19
  which is a lower bound, and the row says so.
20
"""
21
22
from __future__ import annotations
23
24
import subprocess
25
import time
26
from pathlib import Path
27
from typing import Any, Dict, List, Optional, Tuple
28
29
from .generator import git_env
30
31
SIZER_VERSION = "scale-sizer-2026-09-28.1"
32
MAX_CHECKOUT_ROOTS = 20_000
33
34
# (symbol, unit, git-sizer reference value for one "star" of concern).
35
METRICS: Tuple[Tuple[str, str, float], ...] = (
36
    ("uniqueCommitCount", "", 500e3),
37
    ("uniqueCommitSize", "B", 250e6),
38
    ("uniqueTreeCount", "", 1.5e6),
39
    ("uniqueTreeSize", "B", 2e9),
40
    ("uniqueTreeEntries", "", 50e6),
41
    ("uniqueBlobCount", "", 1.5e6),
42
    ("uniqueBlobSize", "B", 10e9),
43
    ("uniqueTagCount", "", 25e3),
44
    ("referenceCount", "", 25e3),
45
    ("maxCommitSize", "B", 50e3),
46
    ("maxCommitParentCount", "", 10),
47
    ("maxTreeEntries", "", 1000),
48
    ("maxBlobSize", "B", 10e6),
49
    ("maxHistoryDepth", "", 500e3),
50
    ("maxTagDepth", "", 1.001),
51
    ("maxCheckoutTreeCount", "", 2000),
52
    ("maxCheckoutPathDepth", "", 10),
53
    ("maxCheckoutPathLength", "B", 100),
54
    ("maxCheckoutBlobCount", "", 50e3),
55
    ("maxCheckoutBlobSize", "B", 1e9),
56
    ("maxCheckoutLinkCount", "", 25e3),
57
    ("maxCheckoutSubmoduleCount", "", 100),
58
)
59
REFERENCE_VALUES = {symbol: scale for symbol, _, scale in METRICS}
60
61
62
def level_of_concern(symbol: str, value: Optional[float]) -> Optional[float]:
63
    """value / reference value (git-sizer prints one '*' per whole unit >= 1)."""
64
    if value is None:
65
        return None
66
    return round(float(value) / REFERENCE_VALUES[symbol], 4)
67
68
69
def parse_tree(raw: bytes) -> List[Tuple[bytes, bytes, bytes]]:
70
    """Binary tree object -> [(mode, name, 20-byte oid)]."""
71
    entries = []
72
    pos = 0
73
    size = len(raw)
74
    while pos < size:
75
        space = raw.index(b" ", pos)
76
        nul = raw.index(b"\0", space)
77
        entries.append((raw[pos:space], raw[space + 1:nul], raw[nul + 1:nul + 21]))
78
        pos = nul + 21
79
    return entries
80
81
82
class _Batch:
83
    """A persistent ``git cat-file --batch`` reader."""
84
85
    def __init__(self, git_bin: str, repo: Path) -> None:
86
        self.proc = subprocess.Popen([git_bin, "-C", str(repo), "cat-file", "--batch"],
87
                                     stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=git_env())
88
89
    def read(self, oid_hex: str) -> Tuple[str, bytes]:
90
        assert self.proc.stdin and self.proc.stdout
91
        self.proc.stdin.write(oid_hex.encode() + b"\n")
92
        self.proc.stdin.flush()
93
        header = self.proc.stdout.readline().split()
94
        if len(header) < 3:
95
            raise RuntimeError(f"cat-file --batch: missing object {oid_hex}")
96
        size = int(header[2])
97
        data = self.proc.stdout.read(size)
98
        self.proc.stdout.read(1)
99
        return header[1].decode(), data
100
101
    def close(self) -> None:
102
        if self.proc.stdin:
103
            self.proc.stdin.close()
104
        if self.proc.stdout:
105
            self.proc.stdout.close()
106
        self.proc.wait()
107
108
109
class _TreeStats:
110
    __slots__ = ("trees", "blobs", "blob_size", "links", "submodules", "depth", "path_len")
111
112
    def __init__(self) -> None:
113
        self.trees = 1
114
        self.blobs = 0
115
        self.blob_size = 0
116
        self.links = 0
117
        self.submodules = 0
118
        self.depth = 0
119
        self.path_len = 0
120
121
122
def measure(repo: Path, git_bin: str = "git", max_checkout_roots: int = MAX_CHECKOUT_ROOTS) -> Dict[str, Any]:
123
    """Compute the git-sizer metric set for ``repo`` (bare or not)."""
124
    started = time.perf_counter()
125
    env = git_env()
126
    sizes: Dict[bytes, int] = {}
127
    counts = {"commit": 0, "tree": 0, "blob": 0, "tag": 0}
128
    totals = {"commit": 0, "tree": 0, "blob": 0, "tag": 0}
129
    max_commit = max_blob = 0
130
    tag_ids: List[str] = []
131
    proc = subprocess.Popen([git_bin, "-C", str(repo), "cat-file", "--batch-check", "--batch-all-objects",
132
                             "--unordered"], stdout=subprocess.PIPE, env=env)
133
    assert proc.stdout is not None
134
    for line in proc.stdout:
135
        oid, kind, size_text = line.split()
136
        kind_s = kind.decode()
137
        size = int(size_text)
138
        counts[kind_s] = counts.get(kind_s, 0) + 1
139
        totals[kind_s] = totals.get(kind_s, 0) + size
140
        if kind_s == "blob":
141
            sizes[bytes.fromhex(oid.decode())] = size
142
            max_blob = max(max_blob, size)
143
        elif kind_s == "commit":
144
            max_commit = max(max_commit, size)
145
        elif kind_s == "tag":
146
            tag_ids.append(oid.decode())
147
    proc.stdout.close()
148
    if proc.wait() != 0:
149
        raise RuntimeError("git cat-file --batch-all-objects failed")
150
151
    # History: generation depth over parents, walked parents-first.
152
    depth: Dict[str, int] = {}
153
    roots: Dict[str, None] = {}
154
    max_parents = 0
155
    log = subprocess.Popen([git_bin, "-C", str(repo), "log", "--all", "--topo-order", "--reverse",
156
                            "--format=%H %T %P"], stdout=subprocess.PIPE, env=env)
157
    assert log.stdout is not None
158
    for line in log.stdout:
159
        parts = line.decode().split()
160
        if len(parts) < 2:
161
            continue
162
        commit, tree, parents = parts[0], parts[1], parts[2:]
163
        max_parents = max(max_parents, len(parents))
164
        depth[commit] = 1 + max((depth.get(parent, 0) for parent in parents), default=0)
165
        roots[tree] = None
166
    log.stdout.close()
167
    log.wait()
168
    max_history = max(depth.values(), default=0)
169
170
    refs = subprocess.run([git_bin, "-C", str(repo), "for-each-ref", "--format=%(objectname) %(objecttype)"],
171
                          check=True, capture_output=True, text=True, env=env).stdout.split("\n")
172
    ref_lines = [line for line in refs if line]
173
174
    batch = _Batch(git_bin, repo)
175
    try:
176
        # Tag chains.
177
        tag_depth_memo: Dict[str, int] = {}
178
179
        def tag_depth(oid: str) -> int:
180
            if oid in tag_depth_memo:
181
                return tag_depth_memo[oid]
182
            _, data = batch.read(oid)
183
            header = dict(line.split(" ", 1) for line in data.split(b"\n\n", 1)[0].decode().split("\n") if " " in line)
184
            value = 1 + (tag_depth(header["object"]) if header.get("type") == "tag" else 0)
185
            tag_depth_memo[oid] = value
186
            return value
187
188
        max_tag_depth = max((tag_depth(oid) for oid in tag_ids), default=0)
189
190
        if len(roots) <= max_checkout_roots:
191
            scope = "all_commits"
192
            root_trees = list(roots)
193
        else:
194
            scope = "ref_tips"
195
            tips = subprocess.run([git_bin, "-C", str(repo), "for-each-ref", "--format=%(objectname)^{tree}"],
196
                                  check=True, capture_output=True, text=True, env=env).stdout.split()
197
            root_trees = sorted(set(subprocess.run(
198
                [git_bin, "-C", str(repo), "rev-parse"] + tips, check=True, capture_output=True, text=True,
199
                env=env).stdout.split())) if tips else []
200
201
        memo: Dict[bytes, _TreeStats] = {}
202
        max_tree_entries = 0
203
        tree_entries_total = 0
204
205
        def stats_for(root_hex: str) -> _TreeStats:
206
            nonlocal max_tree_entries, tree_entries_total
207
            root = bytes.fromhex(root_hex)
208
            stack: List[Tuple[bytes, bool]] = [(root, False)]
209
            pending: Dict[bytes, List[Tuple[bytes, bytes, bytes]]] = {}
210
            while stack:
211
                oid, expanded = stack.pop()
212
                if oid in memo:
213
                    continue
214
                if not expanded:
215
                    _, raw = batch.read(oid.hex())
216
                    entries = parse_tree(raw)
217
                    pending[oid] = entries
218
                    max_tree_entries = max(max_tree_entries, len(entries))
219
                    tree_entries_total += len(entries)
220
                    stack.append((oid, True))
221
                    for mode, _, child in entries:
222
                        if mode == b"40000" and child not in memo:
223
                            stack.append((child, False))
224
                    continue
225
                entries = pending.pop(oid)
226
                stats = _TreeStats()
227
                for mode, name, child in entries:
228
                    if mode == b"40000":
229
                        sub = memo[child]
230
                        stats.trees += sub.trees
231
                        stats.blobs += sub.blobs
232
                        stats.blob_size += sub.blob_size
233
                        stats.links += sub.links
234
                        stats.submodules += sub.submodules
235
                        stats.depth = max(stats.depth, 1 + sub.depth)
236
                        stats.path_len = max(stats.path_len, len(name) + 1 + sub.path_len)
237
                    else:
238
                        if mode == b"120000":
239
                            stats.links += 1
240
                        elif mode == b"160000":
241
                            stats.submodules += 1
242
                        else:
243
                            stats.blobs += 1
244
                            stats.blob_size += sizes.get(child, 0)
245
                        stats.depth = max(stats.depth, 1)
246
                        stats.path_len = max(stats.path_len, len(name))
247
                memo[oid] = stats
248
            return memo[root]
249
250
        best = {"trees": 0, "blobs": 0, "blob_size": 0, "links": 0, "submodules": 0, "depth": 0, "path_len": 0}
251
        for root_hex in root_trees:
252
            stats = stats_for(root_hex)
253
            for key in best:
254
                best[key] = max(best[key], getattr(stats, key))
255
    finally:
256
        batch.close()
257
258
    values: Dict[str, Any] = {
259
        "uniqueCommitCount": counts.get("commit", 0),
260
        "uniqueCommitSize": totals.get("commit", 0),
261
        "uniqueTreeCount": counts.get("tree", 0),
262
        "uniqueTreeSize": totals.get("tree", 0),
263
        # Tree entries are only counted for trees reachable from the scanned
264
        # roots; with checkout_scope all_commits that is every commit tree.
265
        "uniqueTreeEntries": tree_entries_total if scope == "all_commits" else None,
266
        "uniqueBlobCount": counts.get("blob", 0),
267
        "uniqueBlobSize": totals.get("blob", 0),
268
        "uniqueTagCount": counts.get("tag", 0),
269
        "referenceCount": len(ref_lines),
270
        "maxCommitSize": max_commit,
271
        "maxCommitParentCount": max_parents,
272
        "maxTreeEntries": max_tree_entries,
273
        "maxBlobSize": max_blob,
274
        "maxHistoryDepth": max_history,
275
        "maxTagDepth": max_tag_depth,
276
        "maxCheckoutTreeCount": best["trees"],
277
        "maxCheckoutPathDepth": best["depth"],
278
        "maxCheckoutPathLength": best["path_len"],
279
        "maxCheckoutBlobCount": best["blobs"],
280
        "maxCheckoutBlobSize": best["blob_size"],
281
        "maxCheckoutLinkCount": best["links"],
282
        "maxCheckoutSubmoduleCount": best["submodules"],
283
    }
284
    concern = {symbol: level_of_concern(symbol, values[symbol]) for symbol, _, _ in METRICS}
285
    return {
286
        "sizer_version": SIZER_VERSION,
287
        "metric_definitions": "github/git-sizer (MIT) re-implemented; see suites/scale/third_party/git-sizer/NOTICE",
288
        "object_scope": "all_objects_in_odb",
289
        "checkout_scope": scope,
290
        "checkout_roots_scanned": len(root_trees),
291
        "values": values,
292
        "level_of_concern": concern,
293
        "max_level_of_concern": max((v for v in concern.values() if v is not None), default=0.0),
294
        "elapsed_s": round(time.perf_counter() - started, 3),
295
    }
296
297
298
def compact(stats: Dict[str, Any]) -> Dict[str, Any]:
299
    """The subset copied into every row (full stats live in the fixture dir)."""
300
    values = stats["values"]
301
    keep = ("uniqueCommitCount", "uniqueBlobCount", "uniqueBlobSize", "referenceCount", "maxHistoryDepth",
302
            "maxCheckoutBlobCount", "maxCheckoutBlobSize", "maxCheckoutPathDepth", "maxCheckoutPathLength",
303
            "maxCheckoutTreeCount", "maxTreeEntries", "maxBlobSize")
304
    return {"sizer_version": stats["sizer_version"], "checkout_scope": stats["checkout_scope"],
305
            **{key: values.get(key) for key in keep},
306
            "max_level_of_concern": stats["max_level_of_concern"]}