.+)$")
+# One brace group per line is all real git emits (the common prefix/suffix
+# around the single differing middle segment).
+_BRACED = re.compile(r"^(?P.*)\{(?P.*) => (?P.*)\}(?P.*)$")
+
+
+def _count(field: str) -> float | None:
+ """A numstat count column: int, or None for the binary-file dash."""
+ return None if field == "-" else float(field)
+
+
+def _split_path(path_spec: str) -> tuple[str, str | None]:
+ """-> (new_path, old_path or None if not a rename)."""
+ m = _BRACED.match(path_spec)
+ if m:
+ new = f"{m['pre']}{m['new']}{m['post']}".replace("//", "/")
+ old = f"{m['pre']}{m['old']}{m['post']}".replace("//", "/")
+ return new, old
+ if " => " in path_spec:
+ old, _, new = path_spec.partition(" => ")
+ return new, old
+ return path_spec, None
+
+
+def _parse(report_path: str) -> list[dict[str, Any]]:
+ with open(report_path, "r", encoding="utf-8", errors="ignore") as fh:
+ text = fh.read()
+
+ records: list[dict[str, Any]] = []
+ malformed = 0
+ for raw_line in text.splitlines():
+ if not raw_line.strip():
+ continue
+ m = _LINE.match(raw_line)
+ if not m:
+ malformed += 1
+ continue
+ new_path, old_path = _split_path(m["path"])
+ records.append(
+ {
+ "path": new_path,
+ "old_path": old_path,
+ "path_as_printed": m["path"],
+ "lines_added": _count(m["added"]),
+ "lines_deleted": _count(m["deleted"]),
+ }
+ )
+
+ if not records:
+ raise ValueError(
+ f"{report_path} does not look like a `git diff --numstat` export: no "
+ "line matched the expected '\\t\\t' shape "
+ "(tab-separated counts, '-' for binary files). An empty diff also "
+ "produces an empty file — if the session made no changes there is "
+ "nothing to digest. Produce this artifact with: "
+ "git diff --numstat -M .. > session.numstat"
+ )
+ if malformed:
+ # Mixed content (a numstat pasted into some other log) digests the
+ # matching lines but must not do so silently.
+ raise ValueError(
+ f"{report_path}: {malformed} line(s) do not match the numstat "
+ f"'\\t\\t' shape next to {len(records)} that "
+ "do — this file is not a clean `git diff --numstat` export. "
+ "Re-capture with: git diff --numstat -M .. > session.numstat"
+ )
+ return records
+
+
+def load_units(report_path: str) -> list[NormalizedUnit]:
+ units: list[NormalizedUnit] = []
+ for index, rec in enumerate(_parse(report_path)):
+ units.append(
+ NormalizedUnit(
+ name=rec["path"],
+ index=index,
+ duration_us=None, # a change digest has no time dimension
+ raw_ref=report_path,
+ metrics={
+ "lines_added": rec["lines_added"],
+ "lines_deleted": rec["lines_deleted"],
+ },
+ domain=DOMAIN_REPO_CHANGE,
+ )
+ )
+ return units
+
+
+def raw_metrics(report_path: str, kernel_index: int, name_filter: str) -> dict[str, Any]:
+ """The parsed line behind one unit — old path and rename/binary facts."""
+ records = _parse(report_path)
+ if kernel_index >= len(records):
+ raise IndexError(f"unit index {kernel_index} not present in {report_path}")
+ rec = records[kernel_index]
+ out: dict[str, Any] = {
+ "path": rec["path"],
+ "path_as_printed": rec["path_as_printed"],
+ "renamed": rec["old_path"] is not None,
+ "old_path": rec["old_path"],
+ "is_binary": rec["lines_added"] is None and rec["lines_deleted"] is None,
+ "lines_added": rec["lines_added"],
+ "lines_deleted": rec["lines_deleted"],
+ }
+ wanted = None if name_filter.lower() == "all" else name_filter.lower()
+ if wanted is not None:
+ out = {k: v for k, v in out.items() if wanted in k.lower()}
+ return out
diff --git a/src/perfdigest/core/metrics.py b/src/perfdigest/core/metrics.py
index a7c5606..58dad06 100644
--- a/src/perfdigest/core/metrics.py
+++ b/src/perfdigest/core/metrics.py
@@ -30,6 +30,9 @@
* ``build_diag`` — one crate's build diagnostics from a saved
``cargo build --message-format=json`` stream; error/warning counts,
NO timing of any kind (duration stays honestly ``None``)
+ * ``repo_change`` — one changed file from a saved ``git diff --numstat``
+ (RepoState: the session-retrospective change digest); line counts only,
+ no time dimension at all
THE absence rule (the single most dangerous failure mode if violated):
@@ -55,6 +58,7 @@
DOMAIN_CI_STEP = "ci_step"
DOMAIN_BENCHMARK = "benchmark"
DOMAIN_BUILD_DIAG = "build_diag"
+DOMAIN_REPO_CHANGE = "repo_change"
@dataclass(frozen=True)
diff --git a/src/perfdigest/platform/detect.py b/src/perfdigest/platform/detect.py
index e6ca9d0..12bf3d0 100644
--- a/src/perfdigest/platform/detect.py
+++ b/src/perfdigest/platform/detect.py
@@ -28,6 +28,7 @@
"gha_log": "gh", # CIDigest: capture needs the GitHub CLI, digest needs nothing
"criterion": "cargo", # `cargo bench` writes target/criterion/**/new/estimates.json
"cargo_diag": "cargo", # BuildDigest: --message-format=json is a cargo flag
+ "git_numstat": "git", # RepoState: `git diff --numstat -M` is the capture
}
diff --git a/src/perfdigest/server/app.py b/src/perfdigest/server/app.py
index 2ab38a4..97081b1 100644
--- a/src/perfdigest/server/app.py
+++ b/src/perfdigest/server/app.py
@@ -17,6 +17,7 @@ def _register_backends() -> None:
from perfdigest.adapters.clang_time_trace import backend as _clang_tt # noqa: F401
from perfdigest.adapters.criterion import backend as _criterion # noqa: F401
from perfdigest.adapters.gha_log import backend as _gha_log # noqa: F401
+ from perfdigest.adapters.git_numstat import backend as _git_numstat # noqa: F401
from perfdigest.adapters.linux_perf import backend as _perf # noqa: F401
from perfdigest.adapters.metal import backend as _metal # noqa: F401
from perfdigest.adapters.ninja_log import backend as _ninja_log # noqa: F401
diff --git a/tests/fixtures/repo_lab_renames_sample.numstat b/tests/fixtures/repo_lab_renames_sample.numstat
new file mode 100644
index 0000000..75657fc
--- /dev/null
+++ b/tests/fixtures/repo_lab_renames_sample.numstat
@@ -0,0 +1,11 @@
+1 0 NOTES => TASKS.md
+- - data/assets.dat
+- - data/logo.bin
+0 0 docs/{ => api}/config.py
+1 0 docs/read me.txt
+0 0 {src => docs}/util.py
+1 0 "docs/\303\266l\303\247\303\274m.txt"
+4 1 src/engine.py
+0 3 src/legacy.py
+2 0 src/new_module.py
+1 0 src/{parser.py => parser_v2.py}
diff --git a/tests/fixtures/repo_session_early_sample.numstat b/tests/fixtures/repo_session_early_sample.numstat
new file mode 100644
index 0000000..ad89219
--- /dev/null
+++ b/tests/fixtures/repo_session_early_sample.numstat
@@ -0,0 +1,27 @@
+20 4 src/perfdigest/adapters/chrome_trace/trace_reader.py
+0 0 src/perfdigest/adapters/clang_time_trace/__init__.py
+88 0 src/perfdigest/adapters/clang_time_trace/backend.py
+30 0 src/perfdigest/adapters/clang_time_trace/mapping.py
+104 0 src/perfdigest/adapters/clang_time_trace/time_trace_reader.py
+1 0 src/perfdigest/adapters/gha_log/__init__.py
+96 0 src/perfdigest/adapters/gha_log/backend.py
+204 0 src/perfdigest/adapters/gha_log/gha_log_reader.py
+26 0 src/perfdigest/adapters/gha_log/mapping.py
+1 0 src/perfdigest/adapters/ninja_log/__init__.py
+65 0 src/perfdigest/adapters/ninja_log/backend.py
+26 0 src/perfdigest/adapters/ninja_log/mapping.py
+143 0 src/perfdigest/adapters/ninja_log/ninja_log_reader.py
+13 2 src/perfdigest/core/metrics.py
+3 0 src/perfdigest/platform/detect.py
+3 0 src/perfdigest/server/app.py
+333 0 tests/fixtures/ci_digest_macos_sample.gha.log
+7 0 tests/fixtures/ninja_log_sample.ninja_log
+1 0 tests/fixtures/ninja_log_sample/a.c
+1 0 tests/fixtures/ninja_log_sample/b.c
+12 0 tests/fixtures/ninja_log_sample/build.ninja
+3 0 tests/fixtures/ninja_log_sample/c.c
+83 0 tests/fixtures/template_heavy.cpp
+1 0 tests/fixtures/template_heavy.ftime-trace.json
+202 0 tests/test_clang_time_trace.py
+321 0 tests/test_gha_log.py
+153 0 tests/test_ninja_log.py
diff --git a/tests/fixtures/repo_session_sample.numstat b/tests/fixtures/repo_session_sample.numstat
new file mode 100644
index 0000000..87ef8df
--- /dev/null
+++ b/tests/fixtures/repo_session_sample.numstat
@@ -0,0 +1,46 @@
+20 4 src/perfdigest/adapters/chrome_trace/trace_reader.py
+0 0 src/perfdigest/adapters/clang_time_trace/__init__.py
+88 0 src/perfdigest/adapters/clang_time_trace/backend.py
+30 0 src/perfdigest/adapters/clang_time_trace/mapping.py
+104 0 src/perfdigest/adapters/clang_time_trace/time_trace_reader.py
+1 0 src/perfdigest/adapters/criterion/__init__.py
+76 0 src/perfdigest/adapters/criterion/backend.py
+131 0 src/perfdigest/adapters/criterion/criterion_reader.py
+29 0 src/perfdigest/adapters/criterion/mapping.py
+1 0 src/perfdigest/adapters/gha_log/__init__.py
+110 0 src/perfdigest/adapters/gha_log/backend.py
+204 0 src/perfdigest/adapters/gha_log/gha_log_reader.py
+26 0 src/perfdigest/adapters/gha_log/mapping.py
+1 0 src/perfdigest/adapters/ninja_log/__init__.py
+65 0 src/perfdigest/adapters/ninja_log/backend.py
+26 0 src/perfdigest/adapters/ninja_log/mapping.py
+143 0 src/perfdigest/adapters/ninja_log/ninja_log_reader.py
+4 0 src/perfdigest/core/backend.py
+17 2 src/perfdigest/core/metrics.py
+4 0 src/perfdigest/platform/detect.py
+9 0 src/perfdigest/report_store/cache.py
+16 1 src/perfdigest/report_store/discovery.py
+4 0 src/perfdigest/server/app.py
+3 3 src/perfdigest/server/tools.py
+333 0 tests/fixtures/ci_digest_macos_sample.gha.log
+431 0 tests/fixtures/ci_perf_green_runA_sample.gha.log
+431 0 tests/fixtures/ci_perf_green_runB_sample.gha.log
+268 0 tests/fixtures/ci_test_failed_sample.gha.log
+244 0 tests/fixtures/ci_test_green_sample.gha.log
+11 0 tests/fixtures/criterion_sample/Cargo.toml
+28 0 tests/fixtures/criterion_sample/benches/fib_bench.rs
+1 0 tests/fixtures/criterion_sample/criterion/fib/fib_20/new/estimates.json
+1 0 tests/fixtures/criterion_sample/criterion/fib_plain/new/estimates.json
+3 0 tests/fixtures/criterion_sample/src/lib.rs
+7 0 tests/fixtures/ninja_log_sample.ninja_log
+1 0 tests/fixtures/ninja_log_sample/a.c
+1 0 tests/fixtures/ninja_log_sample/b.c
+12 0 tests/fixtures/ninja_log_sample/build.ninja
+3 0 tests/fixtures/ninja_log_sample/c.c
+83 0 tests/fixtures/template_heavy.cpp
+1 0 tests/fixtures/template_heavy.ftime-trace.json
+219 0 tests/test_ci_prev_green.py
+202 0 tests/test_clang_time_trace.py
+174 0 tests/test_criterion.py
+322 0 tests/test_gha_log.py
+153 0 tests/test_ninja_log.py
diff --git a/tests/test_git_numstat.py b/tests/test_git_numstat.py
new file mode 100644
index 0000000..e06a1b2
--- /dev/null
+++ b/tests/test_git_numstat.py
@@ -0,0 +1,234 @@
+"""git numstat backend (RepoState) — session-retrospective change digests.
+
+Two fixture families, both genuinely produced (nothing hand-written):
+
+* ``repo_session_early_sample.numstat`` / ``repo_session_sample.numstat`` —
+ REAL captures of `git diff --numstat -M` run on THIS repository for the
+ v1.2 lane's own footprint (meta: the sessions that built these backends):
+ ``v1.1.1..499fb2c`` (after the gha_log merge, 27 files) and
+ ``v1.1.1..a82e7fd`` (after the previous-green merge, 46 files). Paths and
+ counts only; scanned before committing.
+* ``repo_lab_renames_sample.numstat`` — a REAL `git diff --numstat -M
+ state-a..state-b` capture from a throwaway lab repository (git 2.53) built
+ with real commits specifically to exhibit every numstat shape at once:
+ binary files (guaranteed non-text bytes), a whole-path rename with an edit
+ (``NOTES => TASKS.md``), braced renames with a common prefix and/or suffix
+ (``src/{parser.py => parser_v2.py}``, ``{src => docs}/util.py``,
+ ``docs/{ => api}/config.py`` — empty brace side), a no-edit rename (the
+ genuine-zero case), a path with a space, and a non-ASCII path (git prints
+ it C-quoted; the reader keeps it verbatim). The capture command and repo
+ script are in the reader's docstring lineage; no line was invented or
+ edited after capture.
+
+Also real (observed while building the lab, kept out of the fixture): git
+only pairs a rename when similarity stays above its threshold — a heavily
+edited move splits into an add+delete pair, and that split IS the correct
+digest of what git reported.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from perfdigest.adapters import registry
+from perfdigest.core.digest import NOT_AVAILABLE
+from perfdigest.server import tools
+
+FMT = "git-numstat"
+
+LAB = "repo_lab_renames_sample"
+EARLY = "repo_session_early_sample"
+FINAL = "repo_session_sample"
+
+
+def _fx(fixtures_dir, name):
+ p = fixtures_dir / f"{name}.numstat"
+ assert p.exists(), f"committed fixture missing: {p}"
+ return str(p)
+
+
+# ---------------------------------------------------------------------------
+# Registry dispatch
+# ---------------------------------------------------------------------------
+
+
+def test_registered_formats_domain_platforms():
+ b = registry.get_backend(FMT)
+ assert b.name == "git_numstat"
+ assert b.domain == "repo_change"
+ assert registry.get_backend("numstat").name == "git_numstat"
+ # A change digest is plain text: digesting is universal.
+ assert b.platforms == frozenset({"linux", "darwin", "win32"})
+ assert b.suffixes == (".numstat",)
+
+
+# ---------------------------------------------------------------------------
+# Real session fixture: the lane's own footprint
+# ---------------------------------------------------------------------------
+
+
+def test_real_session_fixture_parses_in_file_order(fixtures_dir):
+ units = tools.list_kernels(_fx(fixtures_dir, FINAL), FMT)
+ assert len(units) == 46 # files the v1.2 lane touched by a82e7fd
+ assert [u["index"] for u in units] == list(range(46))
+ assert all(u["domain"] == "repo_change" for u in units)
+ # No time dimension in a change digest: every duration honestly absent.
+ assert all(u["duration_us"] is None for u in units)
+
+
+def test_real_session_file_metrics(fixtures_dir):
+ m = tools.get_metrics(
+ _fx(fixtures_dir, FINAL), FMT, "src/perfdigest/adapters/gha_log/gha_log_reader.py"
+ )["metrics"]
+ assert m["lines_added"] == 204.0 # the task-1 reader, exactly as merged
+ assert m["lines_deleted"] == 0.0
+
+
+def test_summarize_falls_back_to_file_order_and_carries_line_counts(fixtures_dir):
+ # Durations are all None -> no duration ranking exists; the digest states
+ # file order and hands the agent lines_added as the fact to sort by
+ # (ranking is the model's job — facts, not verdicts).
+ s = tools.summarize_report(
+ _fx(fixtures_dir, FINAL), FMT, top_n=3, metrics=["lines_added", "lines_deleted"]
+ )
+ assert s["domain"] == "repo_change"
+ assert s["sorted_by"] == "file_order"
+ assert "coverage_pct_of_total_duration" not in s # nothing to fabricate
+ assert all("lines_added" in u["metrics"] for u in s["units"])
+
+
+def test_footprint_evolution_across_two_real_snapshots(fixtures_dir):
+ # The usage-prompt flow: same session base, two moments. gha_log/backend.py
+ # really grew by 14 lines between the two merges (task 2's usage-prompt
+ # extension) — the numstat pair carries that as a plain fact.
+ cmp = tools.compare_metrics(
+ _fx(fixtures_dir, EARLY),
+ _fx(fixtures_dir, FINAL),
+ FMT,
+ "src/perfdigest/adapters/gha_log/backend.py",
+ )
+ assert cmp["metrics"]["lines_added"]["a"] == 96.0
+ assert cmp["metrics"]["lines_added"]["b"] == 110.0
+ assert cmp["metrics"]["lines_added"]["delta"] == 14.0
+ assert cmp["metrics"]["lines_deleted"]["delta"] == 0.0
+
+
+# ---------------------------------------------------------------------------
+# Lab fixture: every real numstat path shape
+# ---------------------------------------------------------------------------
+
+
+def test_lab_fixture_full_unit_listing(fixtures_dir):
+ units = tools.list_kernels(_fx(fixtures_dir, LAB), FMT)
+ assert [u["name"] for u in units] == [
+ "TASKS.md", # NOTES => TASKS.md
+ "data/assets.dat", # new binary
+ "data/logo.bin", # modified binary
+ "docs/api/config.py", # docs/{ => api}/config.py
+ "docs/read me.txt", # space kept verbatim
+ "docs/util.py", # {src => docs}/util.py
+ '"docs/\\303\\266l\\303\\247\\303\\274m.txt"', # C-quoted by git, verbatim
+ "src/engine.py",
+ "src/legacy.py",
+ "src/new_module.py",
+ "src/parser_v2.py", # src/{parser.py => parser_v2.py}
+ ]
+
+
+def test_binary_file_is_honestly_absent_end_to_end(fixtures_dir):
+ # THE headline honesty case, through the full tool path: numstat prints
+ # '-\t-' for a binary — that is "no line counts exist", never zero.
+ m = tools.get_metrics(_fx(fixtures_dir, LAB), FMT, "data/logo.bin")["metrics"]
+ assert m["lines_added"] == NOT_AVAILABLE
+ assert m["lines_deleted"] == NOT_AVAILABLE
+
+
+def test_pure_rename_zero_is_measured_not_absent(fixtures_dir):
+ # The contrast case that keeps the dash honest: a no-edit rename really
+ # was measured at 0 added / 0 deleted — a genuine 0.0, not the sentinel.
+ m = tools.get_metrics(_fx(fixtures_dir, LAB), FMT, "docs/util.py")["metrics"]
+ assert m["lines_added"] == 0.0
+ assert m["lines_deleted"] == 0.0
+
+
+def test_rename_units_are_named_by_new_path_with_old_path_in_expand(fixtures_dir):
+ p = _fx(fixtures_dir, LAB)
+ for new_path, old_path in [
+ ("TASKS.md", "NOTES"), # whole-path form
+ ("src/parser_v2.py", "src/parser.py"), # common-prefix brace
+ ("docs/util.py", "src/util.py"), # common-suffix brace
+ ("docs/api/config.py", "docs/config.py"), # empty-old brace side
+ ]:
+ raw = tools.expand(p, FMT, new_path, "all")["metrics"]
+ assert raw["renamed"] is True
+ assert raw["old_path"] == old_path
+ # ...and an unrenamed unit says so.
+ raw = tools.expand(p, FMT, "src/engine.py", "all")["metrics"]
+ assert raw["renamed"] is False and raw["old_path"] is None
+
+
+def test_expand_marks_binary_and_keeps_the_printed_path(fixtures_dir):
+ raw = tools.expand(_fx(fixtures_dir, LAB), FMT, "assets", "all")["metrics"]
+ assert raw["is_binary"] is True
+ assert raw["path_as_printed"] == "data/assets.dat"
+ renamed = tools.expand(_fx(fixtures_dir, LAB), FMT, "TASKS", "all")["metrics"]
+ assert renamed["path_as_printed"] == "NOTES => TASKS.md"
+
+
+def test_mixed_added_and_deleted_counts(fixtures_dir):
+ m = tools.get_metrics(_fx(fixtures_dir, LAB), FMT, "src/engine.py")["metrics"]
+ assert m["lines_added"] == 4.0
+ assert m["lines_deleted"] == 1.0
+
+
+# ---------------------------------------------------------------------------
+# Non-numstat input: loud, named errors
+# ---------------------------------------------------------------------------
+
+
+def test_non_numstat_input_raises_actionable_error(tmp_path):
+ prose = tmp_path / "notes.numstat"
+ prose.write_text("session summary:\nwe changed some files today\n")
+ with pytest.raises(ValueError, match="git diff --numstat"):
+ tools.list_kernels(str(prose), FMT)
+
+ js = tmp_path / "config.numstat"
+ js.write_text(json.dumps({"files": 3}))
+ with pytest.raises(ValueError, match="git diff --numstat"):
+ tools.list_kernels(str(js), FMT)
+
+ empty = tmp_path / "empty.numstat"
+ empty.write_text("")
+ # An empty diff writes an empty file; nothing to digest is said out loud.
+ with pytest.raises(ValueError, match="empty"):
+ tools.list_kernels(str(empty), FMT)
+
+
+def test_mixed_content_is_loud_not_silently_partial(tmp_path, fixtures_dir):
+ # A numstat pasted into a bigger log digests SOME lines — refusing quietly
+ # dropping the rest is the same honesty rule wearing its parser hat.
+ real = (fixtures_dir / f"{LAB}.numstat").read_text()
+ polluted = tmp_path / "polluted.numstat"
+ polluted.write_text("$ git diff --numstat -M state-a..\n" + real)
+ with pytest.raises(ValueError, match="not a clean"):
+ tools.list_kernels(str(polluted), FMT)
+
+
+# ---------------------------------------------------------------------------
+# Capture advisory
+# ---------------------------------------------------------------------------
+
+
+def test_capture_command_is_range_based_and_file_bound():
+ from perfdigest.adapters.git_numstat import backend as ns_backend
+ from perfdigest.platform.detect import PlatformInfo
+
+ info = PlatformInfo(
+ os="linux", is_wsl=False, shell="posix", machine_model=None,
+ cpu=None, gpu_vendors=(), profilers_on_path={},
+ )
+ cmd = ns_backend._capture_command("v1.1.1", info)
+ assert cmd.startswith("git diff --numstat -M v1.1.1.. > session.numstat")
+ assert "uncommitted-only" in cmd # the second capture shape is advertised