| 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"]}
|