From bb6190f3a6ba4d8910326d0678318f98873000f2 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:00:34 +0800 Subject: [PATCH 1/7] Report code churn as rework, not volume Added + deleted lines measures how big a PR is, which the diff already says. The question worth asking in review is different: is this change writing new code, or rewriting code we wrote recently? A pattern of the second is how an encoding problem shows up -- the same area fixed again and again, each fix reasonable on its own. The metric is rework: of the lines this PR deletes, how many were themselves written inside the window (default 30 days). Ages come from blaming the merge base, and are measured against the merge base's own commit time rather than wall-clock now, so re-running CI later reports the same number. Reported alongside it: the median age of deleted lines, which is the sharper signal of the two -- near zero means the change is undoing work from the same few days -- and the files in the diff that other commits in the window also deleted from. Hotspots count commits that DELETED from a file, not commits that touched it. By the touch definition test/unit/test_e2e.cpp led every commit measured (38-46) for the uninteresting reason that every PR appends a scenario to it; by the delete definition it drops to 7 and the files actually being rewritten come first. The rework percentage is withheld below 20 deleted lines. A two-line diff that happens to touch recent code is not "100% rework", and a ratio over a handful of lines says nothing. Calibration against this repo's own history, which is what the metric is for: Make stale removal file-granular (fixing that day's work) 85.7% 0.0d Split command identity into key and signature 67.6% 11.2d Delete StringId's relational operators (21 old sites) 37.5% 121.7d No baseline artifact: comparing a rework ratio against the single previous commit on main is noise, and the median age is self-explanatory without calibration. A trailing-window comparison would be the way to add one. Runs in the existing pr-metrics-comment job, which already checks out and only runs on pull requests, so churn needs no artifact round-trip -- only fetch-depth 0, since blame cannot see history in a shallow clone. Costs 0.6s on the largest commit in recent history. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA --- .github/scripts/metrics | 162 +++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 8 ++ 2 files changed, 170 insertions(+) diff --git a/.github/scripts/metrics b/.github/scripts/metrics index e3c1fa48..f464c5ae 100755 --- a/.github/scripts/metrics +++ b/.github/scripts/metrics @@ -28,6 +28,16 @@ SIZE_COLUMNS = [ "file_kb", ] +CHURN_COLUMNS = [ + "window_days", + "added", + "removed", + "rework_lines", + "rework_pct", + "median_age_days", + "hot_files", +] + COVERAGE_COLUMNS = [ "overall", "median", @@ -301,6 +311,114 @@ def cmd_coverage(args): return 0 +def git(*args): + run = subprocess.run( + ["git", *args], capture_output=True, text=True, check=False + ) + return run.stdout if run.returncode == 0 else "" + + +BLAME_HEADER = re.compile(r"^\^?([0-9a-f]{40}) \d+ (\d+)") + + +def blame_line_times(rev, path): + """Commit time of the last change to each line of path, as of rev. + + Porcelain emits a commit's metadata only the first time that commit appears, + so the time is resolved at the content line, by which point it is either just + parsed or already cached. + """ + times = {} + sha_time = {} + sha = None + lineno = None + for line in git("blame", "--porcelain", rev, "--", path).splitlines(): + if line.startswith("\t"): + if sha is not None: + times[lineno] = sha_time.get(sha) + sha = lineno = None + continue + header = BLAME_HEADER.match(line) + if header: + sha = header.group(1) + lineno = int(header.group(2)) + elif line.startswith("committer-time ") and sha is not None: + sha_time[sha] = int(line.split(" ", 1)[1]) + return times + + +def removed_line_numbers(base, head, path): + """Line numbers in base's copy of path that this diff deletes.""" + removed = [] + for line in git("diff", "-U0", "-M", f"{base}..{head}", "--", path).splitlines(): + if not line.startswith("@@"): + continue + old = line.split(" ")[1] # "-a,b" + start, _, count = old[1:].partition(",") + count = int(count) if count else 1 + removed.extend(range(int(start), int(start) + count)) + return removed + + +def cmd_churn(args): + base = git("merge-base", args.base, args.head).strip() or args.base + base_time = int(git("show", "-s", "--format=%ct", base).strip() or 0) + window = args.window_days * 86400 + + added = removed = 0 + paths = [] + for line in git("diff", "--numstat", "-M", f"{base}..{args.head}").splitlines(): + cells = line.split("\t") + if len(cells) != 3 or cells[0] == "-": # binary + continue + added += int(cells[0]) + removed += int(cells[1]) + paths.append(cells[2]) + + ages = [] + for path in paths: + line_times = blame_line_times(base, path) + if not line_times: + continue # added by this PR, so nothing of it is being reworked + for lineno in removed_line_numbers(base, args.head, path): + when = line_times.get(lineno) + if when is not None: + ages.append(base_time - when) + + rework = sum(1 for age in ages if age < window) + rework_pct = (rework / len(ages) * 100) if ages else 0.0 + median_age = (statistics.median(ages) / 86400) if ages else 0.0 + + # Commits that DELETED from the file, not merely touched it: a test file that + # every PR appends to is not a hotspot, one that keeps being rewritten is. + heat = [] + for path in paths: + rewrites = 0 + for line in git( + "log", "--numstat", "--format=%H", + f"--since={args.window_days} days ago", base, "--", path + ).splitlines(): + cells = line.split("\t") + if len(cells) == 3 and cells[1] not in ("-", "0"): + rewrites += 1 + if rewrites > 1: + heat.append((rewrites, path)) + heat.sort(reverse=True) + hot = ", ".join(f"{p} ({n})" for n, p in heat[:3]) or "none" + + print("\t".join(CHURN_COLUMNS)) + print("\t".join(str(v) for v in ( + args.window_days, + added, + removed, + rework, + f"{rework_pct:.1f}", + f"{median_age:.1f}", + hot, + ))) + return 0 + + def read_tsv(path): if not path: return [] @@ -439,6 +557,39 @@ def coverage_section(cur, base): return lines +def churn_section(cur): + window = cur["window_days"] + removed = num(cur["removed"]) + rework_pct = num(cur["rework_pct"]) + # A ratio over a handful of lines says nothing; report the count and withhold + # the percentage rather than let a 2-line diff read as "100% rework". + share = ( + f"{rework_pct:g}%" if removed >= 20 + else "n/a" + ) + lines = [ + f"### Code churn (rework window: {window}d)", + "", + "| Added | Removed | Reworked | Rework | Median age of removed |", + "|---|---|---|---|---|", + "| {} | {} | {} | {} | {} |".format( + annotate(num(cur["added"]), None, "abs"), + annotate(removed, None, "abs"), + "{} lines".format(cur["rework_lines"]), + share, + "{} d".format(cur["median_age_days"]), + ), + "", + "Rework is the share of removed lines that were themselves written within the " + f"last {window} days — code being rewritten, as distinct from code being added. " + "The median age says which: near 0 means this change is undoing work from the " + "same few days.", + ] + if cur["hot_files"] != "none": + lines += ["", "Repeatedly touched in the window: {}.".format(cur["hot_files"])] + return lines + + def cmd_render(args): sections = [] baseline_used = False @@ -461,6 +612,10 @@ def cmd_render(args): baseline_used |= bool(base) sections.append(size_section(size, base)) + churn = read_tsv(args.churn) + if churn: + sections.append(churn_section(churn[0])) + cov = read_tsv(args.coverage) if cov: base_rows = read_tsv(args.baseline_coverage) @@ -506,6 +661,12 @@ def main() -> int: coverage.add_argument("summary_json") coverage.set_defaults(func=cmd_coverage) + churn = sub.add_parser("churn") + churn.add_argument("base") + churn.add_argument("head") + churn.add_argument("--window-days", type=int, default=30) + churn.set_defaults(func=cmd_churn) + size = sub.add_parser("size") size.add_argument("binaries", nargs="+") size.set_defaults(func=cmd_size) @@ -515,6 +676,7 @@ def main() -> int: render.add_argument("--stat") render.add_argument("--size") render.add_argument("--coverage") + render.add_argument("--churn") render.add_argument("--baseline-perf") render.add_argument("--baseline-stat") render.add_argument("--baseline-size") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 850e3457..f34f99a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -285,6 +285,13 @@ jobs: actions: read steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 # churn blames removed lines; a shallow clone has no history + - name: Code churn + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: .github/scripts/metrics churn "$BASE" "$HEAD" > churn-metrics.tsv - name: Download perf metrics uses: actions/download-artifact@v4 with: @@ -330,6 +337,7 @@ jobs: --perf perf-metrics.tsv --stat stat-metrics.tsv \ --size size-metrics.tsv \ --coverage coverage-metrics.tsv \ + --churn churn-metrics.tsv \ --baseline-perf baseline/perf/perf-metrics.tsv \ --baseline-stat baseline/stat/stat-metrics.tsv \ --baseline-size baseline/size/size-metrics.tsv \ From 42b064309e497bcc59d91ba3bcbab5d775c5d6d4 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:01:20 +0800 Subject: [PATCH 2/7] Fail loudly when churn has no history to read Every git call in cmd_churn degrades to empty output rather than raising, which is right for the one case where emptiness is the answer (blaming a file the PR adds), but it means a shallow clone or an unreachable base would be reported as zero churn instead of as a failure. Silence and "nothing was reworked" looked identical. Both now refuse: a shallow repository names the fetch-depth it needs, and a base with no merge base against head says so. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA --- .github/scripts/metrics | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/scripts/metrics b/.github/scripts/metrics index f464c5ae..ccc1f357 100755 --- a/.github/scripts/metrics +++ b/.github/scripts/metrics @@ -361,7 +361,16 @@ def removed_line_numbers(base, head, path): def cmd_churn(args): - base = git("merge-base", args.base, args.head).strip() or args.base + # Every git call here degrades to empty output, so a missing history would be + # reported as zero churn rather than as a failure. Refuse instead. + if git("rev-parse", "--is-shallow-repository").strip() == "true": + print("churn needs full history (fetch-depth: 0)", file=sys.stderr) + return 1 + + base = git("merge-base", args.base, args.head).strip() + if not base: + print(f"no merge base for {args.base}..{args.head}", file=sys.stderr) + return 1 base_time = int(git("show", "-s", "--format=%ct", base).strip() or 0) window = args.window_days * 86400 From e42b024beba2324938d850ad45c58019c51a577b Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:47:07 +0800 Subject: [PATCH 3/7] Exclude test code from churn; list the top ten rewritten files Tests are rewritten whenever the code they cover is, so counting them measured the same production change twice and diluted the signal with churn that is working as intended. test/ is 408 of the repo's 694 tracked files, which is why it dominated every reading. Excluding it moves the numbers without changing the ranking: before after Make stale removal file-granular 112 rm 85.7% 109 rm 85.3% Split identity into key and signature 142 rm 67.6% 104 rm 69.2% Delete StringId's relational operators 24 rm 37.5% 24 rm 37.5% The last row is unchanged because that PR removed nothing from tests at all. The hotspot list goes to ten, which no longer fits a sentence, so it renders as a table inside a
block rather than pushing the summary out of view. Ties now break by path so the list is stable between runs rather than depending on which file the diff happened to list first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA --- .github/scripts/metrics | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/scripts/metrics b/.github/scripts/metrics index ccc1f357..7027b141 100755 --- a/.github/scripts/metrics +++ b/.github/scripts/metrics @@ -28,6 +28,12 @@ SIZE_COLUMNS = [ "file_kb", ] +# Tests are rewritten whenever the code they cover is, so counting them measures the +# production change twice and dilutes the signal with churn that is working as intended. +CHURN_EXCLUDE_PREFIXES = ("test/",) + +CHURN_HOT_FILES = 10 + CHURN_COLUMNS = [ "window_days", "added", @@ -380,6 +386,8 @@ def cmd_churn(args): cells = line.split("\t") if len(cells) != 3 or cells[0] == "-": # binary continue + if cells[2].startswith(CHURN_EXCLUDE_PREFIXES): + continue added += int(cells[0]) removed += int(cells[1]) paths.append(cells[2]) @@ -412,8 +420,8 @@ def cmd_churn(args): rewrites += 1 if rewrites > 1: heat.append((rewrites, path)) - heat.sort(reverse=True) - hot = ", ".join(f"{p} ({n})" for n, p in heat[:3]) or "none" + heat.sort(key=lambda e: (-e[0], e[1])) + hot = "; ".join(f"{p} ({n})" for n, p in heat[:CHURN_HOT_FILES]) or "none" print("\t".join(CHURN_COLUMNS)) print("\t".join(str(v) for v in ( @@ -592,10 +600,22 @@ def churn_section(cur): "Rework is the share of removed lines that were themselves written within the " f"last {window} days — code being rewritten, as distinct from code being added. " "The median age says which: near 0 means this change is undoing work from the " - "same few days.", + "same few days. Test code is excluded: it is rewritten whenever the code it " + "covers is, so counting it measures the same change twice.", ] if cur["hot_files"] != "none": - lines += ["", "Repeatedly touched in the window: {}.".format(cur["hot_files"])] + lines += [ + "", + f"
Also rewritten in the last {window} days", + "", + "| File | Commits deleting from it |", + "|---|---|", + ] + for entry in cur["hot_files"].split("; "): + match = re.match(r"^(.*) \((\d+)\)$", entry) + if match: + lines.append(f"| `{match.group(1)}` | {match.group(2)} |") + lines += ["", "
"] return lines From 0865a26da59957351541e5004713d19affe0c756 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:07:02 +0800 Subject: [PATCH 4/7] Distinguish an unmeasured churn reading from a measured zero A purely additive PR removes nothing, so there are no line ages to take a median of -- and the table printed "0.0 d". That is the strongest churn reading the metric can produce, shown for the situation with the least churn possible. The same cell said the same thing whether a change was undoing that morning's work or adding a new file. An empty measurement now reports n/a in the TSV and renders as an em dash, which keeps it distinct from the measured zero that matters: fixing code written the same day still reads 0.0 d, and that is the reading the metric exists to surface. The related failure is worse and is now an error rather than a number. If a diff removes lines but none of them can be dated -- blame failing on a rename, a base that is present but unreadable -- the ages list is empty for a reason that has nothing to do with churn, and the old code reported 0 reworked lines out of a real removal count. It now exits nonzero naming how many lines it could not date. Counts were correct throughout and are unchanged: this PR is 199 added, 0 removed against main, which git agrees with, because its later commits only edited lines the PR itself introduced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA --- .github/scripts/metrics | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/scripts/metrics b/.github/scripts/metrics index 7027b141..359f9512 100755 --- a/.github/scripts/metrics +++ b/.github/scripts/metrics @@ -402,9 +402,20 @@ def cmd_churn(args): if when is not None: ages.append(base_time - when) - rework = sum(1 for age in ages if age < window) - rework_pct = (rework / len(ages) * 100) if ages else 0.0 - median_age = (statistics.median(ages) / 86400) if ages else 0.0 + # No ages means nothing was measured, which is not the same as "nothing was + # reworked": a median of 0.0 days is the strongest churn reading there is, and + # printing it for an empty measurement says the opposite of the truth. + if not ages: + if removed: + print( + f"could not date any of the {removed} removed lines", file=sys.stderr + ) + return 1 + rework, rework_pct, median_age = 0, "n/a", "n/a" + else: + rework = sum(1 for age in ages if age < window) + rework_pct = f"{rework / len(ages) * 100:.1f}" + median_age = f"{statistics.median(ages) / 86400:.1f}" # Commits that DELETED from the file, not merely touched it: a test file that # every PR appends to is not a hotspot, one that keeps being rewritten is. @@ -429,8 +440,8 @@ def cmd_churn(args): added, removed, rework, - f"{rework_pct:.1f}", - f"{median_age:.1f}", + rework_pct, + median_age, hot, ))) return 0 @@ -580,10 +591,8 @@ def churn_section(cur): rework_pct = num(cur["rework_pct"]) # A ratio over a handful of lines says nothing; report the count and withhold # the percentage rather than let a 2-line diff read as "100% rework". - share = ( - f"{rework_pct:g}%" if removed >= 20 - else "n/a" - ) + share = f"{rework_pct:g}%" if rework_pct is not None and removed >= 20 else "—" + median = cur["median_age_days"] lines = [ f"### Code churn (rework window: {window}d)", "", @@ -592,9 +601,9 @@ def churn_section(cur): "| {} | {} | {} | {} | {} |".format( annotate(num(cur["added"]), None, "abs"), annotate(removed, None, "abs"), - "{} lines".format(cur["rework_lines"]), + "{} lines".format(cur["rework_lines"]) if removed else "—", share, - "{} d".format(cur["median_age_days"]), + f"{median} d" if num(median) is not None else "—", ), "", "Rework is the share of removed lines that were themselves written within the " From d90c13e5bc289af8f54aac4594ade4e3f6922777 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:36:23 +0800 Subject: [PATCH 5/7] State the two scopes in the churn report; record why deletions still gate hotspots The table and the file list answer different questions over different ranges, and nothing said so. The counts are the PR's own diff, merge-base..head. The file list is the same files' history reachable from the merge base -- so it is what happened BEFORE the PR, deliberately excluding the PR's own commits, and it is context for the diff rather than part of it. Both now say which they are. The hotspot rule counts only commits that deleted from a file. That was introduced because test/unit/test_e2e.cpp led every reading purely by being appended to, and tests are now excluded outright, so the rule was worth re-justifying rather than keeping out of habit. It still discriminates: over a recent window, 59 of 102 production files count differently under touched-vs-deleted-from, and four are append-only with more than one touch -- include/pup/core/string_id.hpp, include/pup/cli/context.hpp, include/pup/core/region.hpp and src/index/writer.cpp. string_id.hpp is the clearest: two commits added deleted operators to it and neither removed anything, so it grows without being rewritten. Under the touched rule it would report as a hotspot. The measurement is recorded at the site. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA --- .github/scripts/metrics | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/scripts/metrics b/.github/scripts/metrics index 359f9512..d40fca52 100755 --- a/.github/scripts/metrics +++ b/.github/scripts/metrics @@ -417,8 +417,13 @@ def cmd_churn(args): rework_pct = f"{rework / len(ages) * 100:.1f}" median_age = f"{statistics.median(ages) / 86400:.1f}" - # Commits that DELETED from the file, not merely touched it: a test file that - # every PR appends to is not a hotspot, one that keeps being rewritten is. + # Commits reachable from the base, so this is the file's history BEFORE the PR -- + # context for the diff above, not part of it. + # + # Counted only if the commit DELETED from the file. Growth is not churn, and the + # distinction survives excluding tests: of 102 production files touched in a recent + # window, 59 count differently under the two rules and 4 are append-only with + # multiple touches (string_id.hpp, context.hpp, region.hpp, index/writer.cpp). heat = [] for path in paths: rewrites = 0 @@ -606,8 +611,9 @@ def churn_section(cur): f"{median} d" if num(median) is not None else "—", ), "", - "Rework is the share of removed lines that were themselves written within the " - f"last {window} days — code being rewritten, as distinct from code being added. " + "Rework is the share of the lines *this PR* removes that were themselves " + f"written within the last {window} days — code being rewritten, as distinct " + "from code being added. " "The median age says which: near 0 means this change is undoing work from the " "same few days. Test code is excluded: it is rewritten whenever the code it " "covers is, so counting it measures the same change twice.", @@ -615,7 +621,8 @@ def churn_section(cur): if cur["hot_files"] != "none": lines += [ "", - f"
Also rewritten in the last {window} days", + f"
Same files, rewritten in the {window} days " + "before this PR", "", "| File | Commits deleting from it |", "|---|---|", From a6bbe95176715493bc67eb51f9ad2025a388be62 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:52:06 +0800 Subject: [PATCH 6/7] Measure churn across the codebase, not within one PR A per-PR rework number answers "is this change rewriting recent work", which is a property of the change. The question worth a standing metric is a property of the tree: how much of what we wrote recently is already gone. Churn is now, over a window ending at the analyzed commit: of the lines written in that window, how many are no longer present. One `git log --numstat --since` pass gives what was written per file; one blame pass over those files counts what survived; the difference is work written and then discarded or rewritten inside the same window. Dated against the analyzed commit rather than wall-clock now, so a re-run agrees. On a pull request it analyzes the PR head, so the reading is the state of the tree if this lands -- not a score for the PR. window written churned rate 7d 3230 471 14.6% 14d 9335 1151 12.3% 30d 9916 1221 12.3% 90d 10399 1296 12.5% Stable across windows, and higher over the shortest one, which is what you would expect if fresh code is what gets revised. Exclusions are now tests, third_party/, examples/ and the generated bootstrap scripts. The bootstrap scripts earned their exclusion by ranking 2nd and 3rd on the first run: `make bootstrap` regenerates them from the Tupfiles, so their churn is a mechanical echo, at roughly ten times the volume, of a change already counted at its source. Top of the list is src/cli/cmd_build.cpp at 249 lines written then discarded, which is where the identity and join work has been concentrated -- the metric finds the area we already know has been rewritten repeatedly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA --- .github/scripts/metrics | 167 ++++++++++++++++++--------------------- .github/workflows/ci.yml | 3 +- 2 files changed, 79 insertions(+), 91 deletions(-) diff --git a/.github/scripts/metrics b/.github/scripts/metrics index d40fca52..306e02cd 100755 --- a/.github/scripts/metrics +++ b/.github/scripts/metrics @@ -28,19 +28,27 @@ SIZE_COLUMNS = [ "file_kb", ] -# Tests are rewritten whenever the code they cover is, so counting them measures the -# production change twice and dilutes the signal with churn that is working as intended. -CHURN_EXCLUDE_PREFIXES = ("test/",) +# Churn is only worth measuring where our defects come from. Tests are rewritten +# whenever the code they cover is, so counting them measures the same change twice; +# third_party/ is vendored, its history is imports; examples/ is build recipes for +# other people's source. +CHURN_EXCLUDE_PREFIXES = ("test/", "third_party/", "examples/") + +# Generated and committed: `make bootstrap` regenerates these from the Tupfiles, so +# their churn is a mechanical echo, at ~10x the volume, of a change already counted +# at its source. Ranked 2nd and 3rd before they were excluded. +CHURN_EXCLUDE_FILES = ("bootstrap-linux.sh", "bootstrap-macos.sh") CHURN_HOT_FILES = 10 CHURN_COLUMNS = [ "window_days", + "files", "added", "removed", - "rework_lines", - "rework_pct", - "median_age_days", + "surviving", + "churned", + "churn_pct", "hot_files", ] @@ -367,86 +375,75 @@ def removed_line_numbers(base, head, path): def cmd_churn(args): + """Churn across the whole codebase over a window, not the diff of one change. + + Of the lines written in the window, how many are already gone? Work that was + written and then discarded inside the same window is the codebase telling you + where it is being rewritten rather than built. + """ # Every git call here degrades to empty output, so a missing history would be # reported as zero churn rather than as a failure. Refuse instead. if git("rev-parse", "--is-shallow-repository").strip() == "true": print("churn needs full history (fetch-depth: 0)", file=sys.stderr) return 1 - base = git("merge-base", args.base, args.head).strip() - if not base: - print(f"no merge base for {args.base}..{args.head}", file=sys.stderr) + rev = git("rev-parse", args.rev).strip() + if not rev: + print(f"unknown revision {args.rev}", file=sys.stderr) return 1 - base_time = int(git("show", "-s", "--format=%ct", base).strip() or 0) - window = args.window_days * 86400 + # Dated against the analyzed commit, not wall-clock now, so a re-run agrees. + cutoff = int(git("show", "-s", "--format=%ct", rev).strip() or 0) - args.window_days * 86400 + added_by_file = {} added = removed = 0 - paths = [] - for line in git("diff", "--numstat", "-M", f"{base}..{args.head}").splitlines(): + for line in git( + "log", "--numstat", "-M", "--format=", f"--since={args.window_days} days ago", rev + ).splitlines(): cells = line.split("\t") if len(cells) != 3 or cells[0] == "-": # binary continue - if cells[2].startswith(CHURN_EXCLUDE_PREFIXES): + path = cells[2] + if path.startswith(CHURN_EXCLUDE_PREFIXES) or path in CHURN_EXCLUDE_FILES: continue added += int(cells[0]) removed += int(cells[1]) - paths.append(cells[2]) - - ages = [] - for path in paths: - line_times = blame_line_times(base, path) - if not line_times: - continue # added by this PR, so nothing of it is being reworked - for lineno in removed_line_numbers(base, args.head, path): - when = line_times.get(lineno) - if when is not None: - ages.append(base_time - when) - - # No ages means nothing was measured, which is not the same as "nothing was - # reworked": a median of 0.0 days is the strongest churn reading there is, and - # printing it for an empty measurement says the opposite of the truth. - if not ages: - if removed: - print( - f"could not date any of the {removed} removed lines", file=sys.stderr - ) - return 1 - rework, rework_pct, median_age = 0, "n/a", "n/a" - else: - rework = sum(1 for age in ages if age < window) - rework_pct = f"{rework / len(ages) * 100:.1f}" - median_age = f"{statistics.median(ages) / 86400:.1f}" - - # Commits reachable from the base, so this is the file's history BEFORE the PR -- - # context for the diff above, not part of it. - # - # Counted only if the commit DELETED from the file. Growth is not churn, and the - # distinction survives excluding tests: of 102 production files touched in a recent - # window, 59 count differently under the two rules and 4 are append-only with - # multiple touches (string_id.hpp, context.hpp, region.hpp, index/writer.cpp). - heat = [] - for path in paths: - rewrites = 0 - for line in git( - "log", "--numstat", "--format=%H", - f"--since={args.window_days} days ago", base, "--", path - ).splitlines(): - cells = line.split("\t") - if len(cells) == 3 and cells[1] not in ("-", "0"): - rewrites += 1 - if rewrites > 1: - heat.append((rewrites, path)) - heat.sort(key=lambda e: (-e[0], e[1])) - hot = "; ".join(f"{p} ({n})" for n, p in heat[:CHURN_HOT_FILES]) or "none" + added_by_file[path] = added_by_file.get(path, 0) + int(cells[0]) + + if not added: + print(f"no production changes in the last {args.window_days} days", file=sys.stderr) + return 1 + + # A line written in the window and still present at rev survived; the rest of what + # was written in the window has since been deleted or rewritten. + surviving = 0 + churn_by_file = [] + for path, wrote in added_by_file.items(): + lived = sum( + 1 for when in blame_line_times(rev, path).values() + if when is not None and when >= cutoff + ) + surviving += min(lived, wrote) + gone = wrote - min(lived, wrote) + if gone: + churn_by_file.append((gone, path)) + + churned = added - surviving + churn_pct = churned / added * 100 + + churn_by_file.sort(key=lambda e: (-e[0], e[1])) + hot = "; ".join( + f"{p} ({n})" for n, p in churn_by_file[:CHURN_HOT_FILES] + ) or "none" print("\t".join(CHURN_COLUMNS)) print("\t".join(str(v) for v in ( args.window_days, + len(added_by_file), added, removed, - rework, - rework_pct, - median_age, + surviving, + churned, + f"{churn_pct:.1f}", hot, ))) return 0 @@ -592,39 +589,32 @@ def coverage_section(cur, base): def churn_section(cur): window = cur["window_days"] - removed = num(cur["removed"]) - rework_pct = num(cur["rework_pct"]) - # A ratio over a handful of lines says nothing; report the count and withhold - # the percentage rather than let a 2-line diff read as "100% rework". - share = f"{rework_pct:g}%" if rework_pct is not None and removed >= 20 else "—" - median = cur["median_age_days"] lines = [ - f"### Code churn (rework window: {window}d)", + f"### Code churn (whole codebase, last {window}d)", "", - "| Added | Removed | Reworked | Rework | Median age of removed |", + "| Files | Lines written | Still present | Churned | Churn rate |", "|---|---|---|---|---|", "| {} | {} | {} | {} | {} |".format( - annotate(num(cur["added"]), None, "abs"), - annotate(removed, None, "abs"), - "{} lines".format(cur["rework_lines"]) if removed else "—", - share, - f"{median} d" if num(median) is not None else "—", + cur["files"], + cur["added"], + cur["surviving"], + cur["churned"], + f"{num(cur['churn_pct']):g}%", ), "", - "Rework is the share of the lines *this PR* removes that were themselves " - f"written within the last {window} days — code being rewritten, as distinct " - "from code being added. " - "The median age says which: near 0 means this change is undoing work from the " - "same few days. Test code is excluded: it is rewritten whenever the code it " - "covers is, so counting it measures the same change twice.", + f"Of the lines written across the codebase in the last {window} days, how many " + "are already gone — work that was written and then discarded or rewritten " + "inside the same window. This is the state of the tree including this PR, not " + "a measure of the PR itself. Tests, examples, vendored code and generated " + f"files are excluded. {cur['removed']} lines were deleted in the window in " + "total, most of them older than it.", ] if cur["hot_files"] != "none": lines += [ "", - f"
Same files, rewritten in the {window} days " - "before this PR", + "
Where the churn is", "", - "| File | Commits deleting from it |", + "| File | Lines written then discarded |", "|---|---|", ] for entry in cur["hot_files"].split("; "): @@ -707,8 +697,7 @@ def main() -> int: coverage.set_defaults(func=cmd_coverage) churn = sub.add_parser("churn") - churn.add_argument("base") - churn.add_argument("head") + churn.add_argument("rev", nargs="?", default="HEAD") churn.add_argument("--window-days", type=int, default=30) churn.set_defaults(func=cmd_churn) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f34f99a4..90da8f37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,9 +289,8 @@ jobs: fetch-depth: 0 # churn blames removed lines; a shallow clone has no history - name: Code churn env: - BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} - run: .github/scripts/metrics churn "$BASE" "$HEAD" > churn-metrics.tsv + run: .github/scripts/metrics churn "$HEAD" > churn-metrics.tsv - name: Download perf metrics uses: actions/download-artifact@v4 with: From 6d0e89bb606a6546d82a384091091640677ef159 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:31:13 +0800 Subject: [PATCH 7/7] Count only code we write: drop CI plumbing and prose from churn .github/ is how the project is built and checked, not the thing being built, and its churn was inflating the rate by about three points: 13.1% with it, 9.8% without. Five of the top ten entries were CI scripts, three of them files that no longer exist because they were consolidated into this one -- real work, rewritten, but not where a build system's defects come from. Excluding it left TESTING.md ranked tenth, which is the same category error one step further out: prose is not code. Markdown accounts for 483 of the lines written in the window. Excluded by suffix, since docs live both under docs/ and at the root, along with docs/ itself. The top ten is now entirely src/, led by cmd_build.cpp at 249 lines written then discarded -- the identity and join work -- followed by layout.cpp, new_delete.cpp and context.cpp. window files written churned rate 7d 49 2525 398 15.8% 14d 115 7335 756 10.3% 30d 119 7502 770 10.3% 90d 119 7886 844 10.7% Still stable across windows and still highest over the shortest, so the narrower denominator has not made the number fragile. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA --- .github/scripts/metrics | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/scripts/metrics b/.github/scripts/metrics index 306e02cd..051a6f82 100755 --- a/.github/scripts/metrics +++ b/.github/scripts/metrics @@ -28,11 +28,12 @@ SIZE_COLUMNS = [ "file_kb", ] -# Churn is only worth measuring where our defects come from. Tests are rewritten -# whenever the code they cover is, so counting them measures the same change twice; -# third_party/ is vendored, its history is imports; examples/ is build recipes for -# other people's source. -CHURN_EXCLUDE_PREFIXES = ("test/", "third_party/", "examples/") +# Churn is only worth measuring where our defects come from: tests echo the change +# they cover, third_party/ is vendored, examples/ builds other people's source, +# .github/ is CI plumbing, and prose is not code. +CHURN_EXCLUDE_PREFIXES = (".github/", "test/", "third_party/", "examples/", "docs/") + +CHURN_EXCLUDE_SUFFIXES = (".md",) # Generated and committed: `make bootstrap` regenerates these from the Tupfiles, so # their churn is a mechanical echo, at ~10x the volume, of a change already counted @@ -403,7 +404,9 @@ def cmd_churn(args): if len(cells) != 3 or cells[0] == "-": # binary continue path = cells[2] - if path.startswith(CHURN_EXCLUDE_PREFIXES) or path in CHURN_EXCLUDE_FILES: + if (path.startswith(CHURN_EXCLUDE_PREFIXES) + or path.endswith(CHURN_EXCLUDE_SUFFIXES) + or path in CHURN_EXCLUDE_FILES): continue added += int(cells[0]) removed += int(cells[1]) @@ -605,9 +608,10 @@ def churn_section(cur): f"Of the lines written across the codebase in the last {window} days, how many " "are already gone — work that was written and then discarded or rewritten " "inside the same window. This is the state of the tree including this PR, not " - "a measure of the PR itself. Tests, examples, vendored code and generated " - f"files are excluded. {cur['removed']} lines were deleted in the window in " - "total, most of them older than it.", + "a measure of the PR itself. Only code we write is counted: tests, examples, " + "vendored and generated files, CI plumbing and prose are excluded. " + f"{cur['removed']} lines were deleted in the window in total, most of them " + "older than it.", ] if cur["hot_files"] != "none": lines += [