From d106e43ae00b0a3eec21972eaeb8c7c17d686b8c Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 7 Aug 2026 16:25:58 +0200 Subject: [PATCH] Add a nox diffstat session for sizing a branch's diff Buckets `git diff --numstat` against the merge base by directory, so a branch dominated by tests or docs can still be judged on the production code it touches. Renames count as the edit they contain. The script needs no dependencies, so the nox session is a venv-less wrapper that forwards every argument. --- .scripts/diffstat.py | 106 +++++++++++++++++++++++++++++++++++++++++++ noxfile.py | 17 +++++++ 2 files changed, 123 insertions(+) create mode 100755 .scripts/diffstat.py diff --git a/.scripts/diffstat.py b/.scripts/diffstat.py new file mode 100755 index 000000000..32fda379d --- /dev/null +++ b/.scripts/diffstat.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Summarize a branch's diff as added/removed/net lines, grouped by directory. + +Answers "how big is this change, really" by bucketing ``git diff --numstat`` against the merge +base, so a branch dominated by tests or docs can still be judged on the production code it +touches. Renames count as the edit they contain rather than as a delete plus an add; pass +``--no-renames`` for the opposite reading. + +Usually invoked as ``nox -s diffstat -- ``; runnable directly since it needs no dependencies. + +Usage: + .scripts/diffstat.py # vs merge base with main, 2 levels deep + .scripts/diffstat.py --base release --depth 1 + .scripts/diffstat.py -x tests -x '*/docs/*' # drop paths from the table and totals + .scripts/diffstat.py --no-renames +""" + +import argparse +import re +import subprocess +import sys + + +def git(*argv: str) -> str: + """Run a git command and return its stdout.""" + result = subprocess.run(["git", *argv], text=True, capture_output=True, check=False) + if result.returncode != 0: + print(f"git {' '.join(argv)} failed: {result.stderr.strip()}", file=sys.stderr) + raise SystemExit(1) + return result.stdout + + +def numstat_path(raw: str) -> str: + """Resolve the post-rename path of a ``git diff --numstat`` entry. + + Renames arrive either with the common prefix/suffix factored out (``a/{old => new}/f.py``, + or ``a/{ => new}/f.py`` when one side is empty) or spelled out in full (``old.py => new.py``). + Left as-is, each would land in a bucket of its own instead of under its directory. + """ + match = re.match(r"(.*)\{(.*) => (.*)\}(.*)", raw) + if match: + return (match.group(1) + match.group(3) + match.group(4)).replace("//", "/") + return raw.split(" => ", 1)[-1] + + +def group_key(path: str, depth: int) -> str: + """Truncate a path to its first ``depth`` components, keeping shallower paths whole.""" + parts = path.split("/") + return "/".join(parts[:depth]) if len(parts) > depth else path + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument("--base", default="main", help="ref to diff against; its merge base with HEAD is used") + parser.add_argument("--depth", type=int, default=2, help="number of path components to group by") + parser.add_argument("-x", "--exclude", action="append", default=[], help="pathspec to exclude, repeatable") + parser.add_argument("--no-renames", action="store_true", help="count a rename as a delete plus an add") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + base = git("merge-base", args.base, "HEAD").strip() + diff = ["diff", "--numstat"] + (["--no-renames"] if args.no_renames else []) + diff += [base, "HEAD", "--", *(f":!{pattern}" for pattern in args.exclude)] + + buckets: dict[str, list[int]] = {} + files = binary = 0 + for line in git(*diff).splitlines(): + added, removed, raw = line.split("\t", 2) + files += 1 + if added == "-": + # numstat reports no line counts for binary files + binary += 1 + continue + entry = buckets.setdefault(group_key(numstat_path(raw), args.depth), [0, 0]) + entry[0] += int(added) + entry[1] += int(removed) + + if not files: + print(f"No changes between {args.base} ({base[:8]}) and HEAD") + return 0 + + rows = sorted(((a - r, a, r, key) for key, (a, r) in buckets.items()), reverse=True) + rows.append((sum(r[0] for r in rows), sum(r[1] for r in rows), sum(r[2] for r in rows), "TOTAL")) + width = max(len(row[3]) for row in rows) + rule = f"{'-' * 8} {'-' * 8} {'-' * 8} {'-' * width}" + + excluded = f", excluding {' '.join(args.exclude)}" if args.exclude else "" + print(f"\n{files} files changed vs {args.base} (merge base {base[:8]}){excluded}") + if binary: + print(f"{binary} binary file(s) counted in that total but contributing no lines") + print(f"\n{'net':>8} {'added':>8} {'removed':>8} {'path':<{width}}") + print(rule) + for net, added, removed, key in rows: + if key == "TOTAL": + print(rule) + print(f"{net:>+8} {added:>+8} {f'-{removed}':>8} {key:<{width}}") + print() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/noxfile.py b/noxfile.py index 64a24c7fb..26b19e746 100644 --- a/noxfile.py +++ b/noxfile.py @@ -2,6 +2,7 @@ import re import shutil import subprocess +import sys import tomllib from collections.abc import Generator from contextlib import contextmanager @@ -574,3 +575,19 @@ def test_distributions(session): session.log("") session.log("All compatible packages tested successfully!") + + +@nox.session(venv_backend="none", default=False) +def diffstat(session): + """Summarize the branch's diff as added/removed/net lines, grouped by directory. + + Thin wrapper around `.scripts/diffstat.py`, which needs no dependencies; every argument is + forwarded, so `nox -s diffstat -- --help` is the authoritative list of options. + + Usage: + nox -s diffstat # vs merge base with main, 2 levels deep + nox -s diffstat -- --base release --depth 1 + nox -s diffstat -- -x tests -x '*/docs/*' # drop paths from the table and totals + """ + script = Path(__file__).parent / ".scripts" / "diffstat.py" + session.run(sys.executable, str(script), *session.posargs, external=True)