diff --git a/README.md b/README.md index 8f8a91d..e3a0c99 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # post-range-diff-comment Posts `git range-diff` of a force push to a pull request as a collapsed -comment: an aligned listing of the commit pairs, then one collapsible -interdiff per rewritten commit. GitHub's "Compare" link diffs the two trees; -this shows how the commits changed. +comment: an aligned listing of the commit pairs, each commit followed by its +lines added, lines removed and files changed (`+3 -1 2f`), then one +collapsible interdiff per rewritten commit. GitHub's "Compare" link diffs the +two trees; this shows how the commits changed. ```yaml on: diff --git a/post-range-diff-comment.sh b/post-range-diff-comment.sh index 2af5d47..22b9449 100755 --- a/post-range-diff-comment.sh +++ b/post-range-diff-comment.sh @@ -82,6 +82,9 @@ if [[ -n ${CREATION_FACTOR:-} ]]; then args+=("--creation-factor=$CREATION_FACTOR") fi git -c core.abbrev=40 range-diff "${args[@]}" "$old_range" "$new_range" >"$WORK/range-diff.txt" +for range in "$old_range" "$new_range"; do + git log --format=%H --numstat "$range" +done >"$WORK/stats.txt" PUSHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" short() { git rev-parse --short "$1"; } @@ -94,6 +97,7 @@ render() { --old-range "$pretty_old" --new-range "$pretty_new" \ --old-base "$old_base" --new-base "$new_base" \ --base-behind "$base_behind" --base-ahead "$base_ahead" \ + --stats "$WORK/stats.txt" \ --pushed-at "$PUSHED_AT" --max-bytes "$1" <"$WORK/range-diff.txt" } render "${MAX_COMMENT_BYTES:-65000}" >"$WORK/comment.md" diff --git a/render.py b/render.py index 9b91eff..9bef94c 100755 --- a/render.py +++ b/render.py @@ -7,8 +7,10 @@ import html import re import sys +from collections.abc import Callable from dataclasses import dataclass, replace from datetime import datetime, timezone +from pathlib import Path # Pair numbers are right-aligned to the width of the longer range, so a header # starts with at most a few spaces. Interdiff lines are indented by exactly @@ -35,6 +37,13 @@ class Pair: body: tuple[str, ...] = () +@dataclass +class Stat: + added: int = 0 + removed: int = 0 + files: int = 0 + + def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument( @@ -72,6 +81,12 @@ def main(argv: list[str]) -> int: required=True, help="commits in the new base missing from the old one", ) + ap.add_argument( + "--stats", + type=Path, + required=True, + help="`git log --format=%%H --numstat` of both ranges", + ) ap.add_argument( "--max-bytes", type=int, @@ -96,6 +111,7 @@ def main(argv: list[str]) -> int: new_base=args.new_base, base_behind=args.base_behind, base_ahead=args.base_ahead, + stats=parse_stats(args.stats.read_text()), max_bytes=args.max_bytes, pushed_at=args.pushed_at, ) @@ -132,6 +148,21 @@ def parse(text: str) -> list[Pair]: ] +def parse_stats(text: str) -> dict[str, Stat]: + stats: dict[str, Stat] = {} + for line in text.splitlines(): + if "\t" not in line: + if line: + stat = stats[line] = Stat() + continue + # numstat prints "-" for the line counts of a binary file. + added, removed, _path = line.split("\t", 2) + stat.added += int(added.replace("-", "0")) + stat.removed += int(removed.replace("-", "0")) + stat.files += 1 + return stats + + def render( pairs: list[Pair], *, @@ -146,6 +177,7 @@ def render( new_base: str, base_behind: int, base_ahead: int, + stats: dict[str, Stat], ) -> str: counts = {op: sum(1 for p in pairs if p.op == op) for op in OP_LABEL} tally = ( @@ -173,7 +205,7 @@ def assemble(pairs: list[Pair], truncated: bool) -> str: parts = ( head + (["", note] if truncated else []) - + ["", render_listing(pairs, repo_url)] + + ["", render_listing(pairs, repo_url, stats)] + ( ["", render_interdiffs(pairs)] if any(p.op == "!" for p in pairs) @@ -198,16 +230,33 @@ def assemble(pairs: list[Pair], truncated: bool) -> str: return out -def render_listing(pairs: list[Pair], repo_url: str) -> str: +def render_listing(pairs: list[Pair], repo_url: str, stats: dict[str, Stat]) -> str: width = number_width(pairs) + stat = stat_column(pairs, stats) lines = [ - f"{p.lnum.rjust(width)}: {sha_html(p.lsha, repo_url)} {p.op} " - f"{p.rnum.rjust(width)}: {sha_html(p.rsha, repo_url)} {html.escape(p.subject)}" + f"{p.lnum.rjust(width)}: {sha_html(p.lsha, repo_url)}{stat(p.lsha)} {p.op} " + f"{p.rnum.rjust(width)}: {sha_html(p.rsha, repo_url)}{stat(p.rsha)} {html.escape(p.subject)}" for p in pairs ] return "
\n" + "\n".join(lines) + "\n
" +def stat_column(pairs: list[Pair], stats: dict[str, Stat]) -> Callable[[str], str]: + cells = { + sha: (f"+{s.added}", f"-{s.removed}", f"{s.files}f") + for p in pairs + for sha in (p.lsha, p.rsha) + if (s := stats.get(sha)) + } + widths = [max(map(len, column)) for column in zip(*cells.values())] + + def column(sha: str) -> str: + cell = cells.get(sha, [""] * len(widths)) + return "".join(f" {part:>{width}}" for part, width in zip(cell, widths)) + + return column + + def render_interdiffs(pairs: list[Pair]) -> str: width = number_width(pairs) blocks = [] diff --git a/tests/fixtures/basic.md b/tests/fixtures/basic.md index 8093ce4..592e09f 100644 --- a/tests/fixtures/basic.md +++ b/tests/fixtures/basic.md @@ -5,10 +5,10 @@ reproduce: git range-diff 9b127fb^..deecb4b 9b127fb^..031a726
-1:  9b127fbefa = 1:  9b127fbefa add b
-2:  521d6e7130 ! 2:  9914d9036b tweak a, add c
-3:  deecb4b7a4 < -:  ---------- add d
--:  ---------- > 3:  031a72677e add e
+1:  9b127fbefa +1 -0 1f = 1:  9b127fbefa +1 -0 1f add b
+2:  521d6e7130 +3 -1 2f ! 2:  9914d9036b +3 -1 2f tweak a, add c
+3:  deecb4b7a4 +1 -0 1f < -:  ----------          add d
+-:  ----------          > 3:  031a72677e +1 -0 1f add e
 
diff --git a/tests/fixtures/basic.numstat b/tests/fixtures/basic.numstat new file mode 100644 index 0000000..5c6d03f --- /dev/null +++ b/tests/fixtures/basic.numstat @@ -0,0 +1,20 @@ +deecb4b7a41ec39b40cdfc935ab28eb769c3aead + +1 0 d +521d6e7130970908efb2c4370c589d194041d6ad + +2 1 a +1 0 c +9b127fbefa8c868ee2aec33496f658793715ea4b + +1 0 b +031a72677e2ee12123ab2c8ef794bc3d0c521d8b + +1 0 e +9914d9036bfa9513e1e505143e051833500db892 + +2 1 a +1 0 c +9b127fbefa8c868ee2aec33496f658793715ea4b + +1 0 b diff --git a/tests/test_action.py b/tests/test_action.py index be2844d..9030ad6 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -107,7 +107,9 @@ def run_action( return result.stdout def test_force_push_full_checkout(self): - self.assertIn("; 1 commit; same base +1 -1 1f < -:", out) def test_rebase_onto_newer_base(self): self.git("checkout", "-q", "main") diff --git a/tests/test_render.py b/tests/test_render.py index 329c90e..00c1be5 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -28,6 +28,7 @@ def render_basic(max_bytes=65000): new_base=NEW_BASE, base_behind=0, base_ahead=12, + stats=render.parse_stats((FIXTURES / "basic.numstat").read_text()), ) @@ -58,11 +59,27 @@ def test_body_line_that_looks_like_a_header_stays_a_body(self): class RenderTest(unittest.TestCase): def test_listing_is_one_aligned_pre_block(self): pairs = render.parse("1: aaaa = 1: aaaa first\n10: bbbb < -: ---- tenth\n") - out = render.render_listing(pairs, REPO) + out = render.render_listing(pairs, REPO, {}) self.assertEqual(out.count("
"), 1)
         self.assertIn("\n 1:   b\nbbbb\ncccc\n\n0\t12\tc\n"
+        )
+        self.assertEqual(stats["aaaa"], render.Stat(added=3, removed=1, files=2))
+        self.assertEqual(stats["bbbb"], render.Stat())
+        self.assertEqual(stats["cccc"], render.Stat(removed=12, files=1))
+
+    def test_listing_aligns_stats(self):
+        pairs = render.parse("1:  aaaa ! 1:  bbbb one\n2:  cccc < -:  ---- two\n")
+        stats = {"aaaa": render.Stat(5, 0, 1), "bbbb": render.Stat(120, 7, 10)}
+        first, second = render.render_listing(pairs, REPO, stats).splitlines()[1:3]
+        self.assertIn("aaaa   +5 -0  1f ! 1:", first)
+        self.assertIn("bbbb +120 -7 10f one", first)
+        self.assertIn("cccc             < -:  ----------             two", second)
+
     def test_relative_time(self):
         self.assertEqual(
             render.relative_time("2026-09-17T00:10:17Z"),
@@ -89,7 +106,7 @@ def base(new, behind, ahead):
 
     def test_subject_is_escaped(self):
         pairs = render.parse("1:  aaaa = 1:  aaaa