I Wondered Whether to Sort My Diffs by Machine, So I Ran It on 106 Commits. It Mattered for 8, and for One 1,776-File Change
I wrote a small script that orders a git diff by reading priority and ran it on 106 recent commits. It earned its place in 8 of them and one bulk change. Rules, full code, and the silent misclassification traps.
Once a pile of agent-made changes builds up, review stalls at the front door. git diff --stat lists files in path order, and nothing tells you which one to open first.
So I wrote a small script that reorders a diff by reading priority. Config and logic come first; prose and generated files go to the back.
Right after finishing it, I couldn't decide whether to use it every time. It looked useful, but most of my diffs are small, and I suspected the cases where reordering helps were fewer than I assumed.
When I'm unsure, I count instead of guessing. I ran the script against the recent history of a website repository I maintain as an indie developer. The short version: it mattered for 8 of 106 commits, plus one change that rewrote 1,776 files at once.
Fix the rules before looking at any numbers
If you tune the rules after seeing results, you end up classifying to fit the outcome. So I fixed the buckets and their order first, and did not move them afterwards.
Read order
Bucket
What goes in
Why it comes first
1
secrets-infra
.env files, .dev.vars, wrangler config, CI workflows, YAML
Few lines, but they touch secrets and exposure directly
2
deps
package.json, requirements, pyproject
A new dependency deserves a look before any prose
3
logic
ts, tsx, js, mjs, py and the like
The part that changes behavior
4
tests
tests directories, spec and test files
Read after the logic, to compare against intent
5
style / data / content
css, json and csv, Markdown
Breakage here is usually easy to notice
Last
skip / binary
Lockfiles, minified files, images and fonts
Nothing to gain from reading them by eye
I made one judgment call here. Read in order of damage if wrong, not in order of line count.
Line count looks fair, but a three-line config change can be the biggest accident. That ordering is fixed, along with the pitfalls below.
The full script
It uses only the standard library. It sorts git diff --numstat output into buckets and prints them in reading order.
#!/usr/bin/env python3"""Reorder a git diff into reading order. Usage: diff_triage.py <rev-range> [repo]"""import re, subprocess, sysfrom collections import defaultdict# First matching bucket wins. tests sits before logic so a .py test is not swallowed by the extension rule.RULES = [ ("secrets-infra", re.compile(r"(^|/)(\.env[^/]*|\.dev\.vars|wrangler\.(toml|jsonc?)|\.github/workflows/[^/]+|Dockerfile|[^/]*\.ya?ml)$")), ("deps", re.compile(r"(^|/)(package\.json|requirements[^/]*\.txt|pyproject\.toml|go\.mod)$")), ("tests", re.compile(r"(^|/)(tests?|__tests__)/|\.(test|spec)\.[a-z]+$")), ("logic", re.compile(r"\.(tsx?|jsx?|mjs|py|go|rs|swift|kt)$")), ("style", re.compile(r"\.(css|scss)$")), ("content", re.compile(r"\.(mdx?|txt)$")), ("data", re.compile(r"\.(json|csv|tsv)$")),]SKIP = re.compile(r"(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|.*\.min\.(js|css)|.*\.snap)$")ORDER = ["secrets-infra", "deps", "logic", "tests", "style", "data", "content", "other"]def run(cmd, repo): return subprocess.run(cmd, cwd=repo, capture_output=True, text=True, check=True).stdoutdef bucket(path): if SKIP.search(path): return "skip" for name, rx in RULES: if rx.search(path): return name return "other"def triage(rev, repo="."): rows = [] for line in run(["git", "-c", "core.quotepath=false", "diff", "--numstat", "-M", rev], repo).splitlines(): add, dele, path = line.split("\t", 2) path = path.strip('"') # git wraps names with spaces or symbols in quotes if "=>" in path: # rename looks like 'a/{old => new}.ts'; keep the new side path = re.sub(r"\{.*? => (.*?)\}", r"\1", path).replace("//", "/") if add == "-": # binary files have no line counts rows.append(("binary", path, 0, 0)); continue rows.append((bucket(path), path, int(add), int(dele))) return rowsdef render(rows): by = defaultdict(list) for b, p, a, d in rows: by[b].append((p, a, d)) out = [] for b in ORDER + ["binary", "skip"]: files = sorted(by.get(b, []), key=lambda x: -(x[1] + x[2])) if not files: continue total = sum(a + d for _, a, d in files) out.append(f"[{b}] {len(files)} files / {total} lines") if b in ("skip", "content") and len(files) > 3: out.append(" (names only, first 3) " + ", ".join(p for p, _, _ in files[:3])) else: out += [f" {a:>5}+ {d:>5}- {p}" for p, a, d in files[:8]] return "\n".join(out)if __name__ == "__main__": print(render(triage(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else ".")))
The rules match the table above, and the order of RULES is the priority. Prose and generated files are folded down to three names, while logic and config show every file sorted by line count.
Call it with a revision range, for example python3 diff_triage.py HEAD~1..HEAD ..
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can run a classifier over your own repository history, count how often a diff actually needs reordering, and decide whether the tool deserves daily use
✦You will be able to decide a reading order and fold the noisy buckets before a handful of logic lines get buried under 1,700 content files
✦You can avoid the silent misclassifications a path classifier makes, such as rule order and quoted paths, before they skew your numbers
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I used the last 400 commits (August 8 to October 21, 2026) and kept those touching src, scripts, .github, or config files. Merge commits are excluded.
The oldest commit in my shallow history has no parent, so it couldn't be read and was skipped. That left 106 non-empty diffs.
# measure.py: classify each commit and find where the first logic file lands in line-count orderimport subprocess, sys, statisticssys.path.insert(0, sys.argv[2]); import diff_triage as trepo = sys.argv[1]revs = subprocess.run( ["git", "log", "-400", "--no-merges", "--pretty=%h", "--", "src", "scripts", ".github", "wrangler.toml", "package.json"], cwd=repo, capture_output=True, text=True).stdout.split()only_logic = mixed = first_not_logic = 0ranks, sizes, total = [], [], 0for r in revs: try: rows = t.triage(f"{r}^..{r}", repo) except Exception: continue # parentless commit: skip it (report the count separately) rows = [x for x in rows if x[0] not in ("skip", "binary")] if not rows: continue total += 1 kinds = {x[0] for x in rows} if "logic" not in kinds: continue if kinds == {"logic"}: only_logic += 1 continue mixed += 1 ranked = sorted(rows, key=lambda x: -(x[2] + x[3])) ranks.append(min(i + 1 for i, x in enumerate(ranked) if x[0] == "logic")) sizes.append(len(rows)) first_not_logic += ranked[0][0] != "logic"print(total, only_logic, mixed, first_not_logic, statistics.median(ranks), statistics.median(sizes), max(sizes))
Here is what came back.
Metric
Count
What it tells me
Diffs counted
106
Lockfile-only and image-only diffs excluded
Logic-only diffs
85
Line-count order is enough; no need to reorder
Logic mixed with other buckets
18
The only pool where reordering can matter
Of those, largest file is not logic
8
Line-count order puts something other than the core change first
Rank of first logic file in mixed diffs
median 1, max 4
Usually first or second, so nothing was close to being missed
Files per mixed diff
median 5.5, max 1,776
One diff is an order of magnitude apart from the rest
What surprised me: across the 18 mixed diffs, logic never fell below fourth place. In small diffs you just open a few files, so a poor ordering costs almost nothing.
The value showed up somewhere other than small diffs.
The one 1,776-file change was a different animal
The largest diff was a commit that rewrote front matter across Markdown bodies in bulk: 1,767 content files and 2,338 lines, against 9 logic files and 59 lines.
git diff --stat sorts by path, so those 9 files under src/app vanish among 1,767 content entries. To find the logic you would have to read through the whole column of body files.
Run the script on the same diff and the picture changes.
Nine small logic changes are visible at a glance, and the prose collapses into a quantity, "1,767 files, 2,338 lines." That single screen is the value for me.
If something had gone wrong in that change, what I'd have missed was not the prose but those 59 lines of logic. The bigger the diff, the fewer lines matter, and they are the ones that get buried. That is the principle I took from these numbers.
Not for daily use; a gate that fires only when it helps
The history says the tool pays off for some mixed diffs and for huge ones. So I added a function that decides whether a diff is worth triaging.
def needs_triage(rows, max_files=30): """True only for diffs where reordering is likely to help. Small diffs are fine with git diff --stat.""" live = [r for r in rows if r[0] not in ("skip", "binary")] kinds = {r[0] for r in live} if len(live) >= max_files: return True if kinds & {"secrets-infra", "deps"} and "logic" in kinds: # config and code together return True return "logic" in kinds and len(kinds) >= 2 and sorted(live, key=lambda r: -(r[2] + r[3]))[0][0] != "logic"
The three conditions are 30 or more files, config or dependencies mixed with code, and a mixed diff whose largest file is not logic.
On the same 106 diffs this fired for 10. For the other 96, git diff --stat is enough.
For a tool with limited use, I prefer it to appear only when a condition matches rather than run every time. So I did not wire it into my pre-commit checks; it stays something I call by hand.
Two quiet misclassifications I hit along the way
This affects how far you can trust the numbers, so I'll say it up front. My first tally showed 19 mixed diffs and 9 where the largest file was not logic. The 18 and 8 in the table are what remained after fixing two mistakes.
The first was quoted paths. By default git wraps file names containing Japanese characters or spaces in quotes. A path ending in HomeClient.tsx" matched no rule and fell into other. The fix is to pass core.quotepath=false and strip stray quotes as well.
The second was rule order. I had put logic first, so tests/test_a.py was caught by the .py extension and landed in logic. A tiny throwaway repository exposed it. Tests belong before logic.
# Throwaway repo: one pass covering rename, tests, lockfile, and a name with Japanese and a spaceT=$(mktemp -d); cd "$T"; git init -qgit config user.email a@b.c; git config user.name tmkdir -p src content tests; echo 'a=1' > src/app.py; echo '# t' > content/a.mdecho x > package-lock.json; echo 'def test(): pass' > tests/test_a.pygit add -A; git commit -qm basegit mv content/a.md content/b.md; echo 'a=2' >> src/app.py; echo x2 >> package-lock.jsonecho 't2' >> tests/test_a.py; echo '# 日本語' > "content/日本 語.md"git add -A; git commit -qm changepython3 diff_triage.py HEAD^..HEAD .
The expected output has only src/app.py under logic, tests/test_a.py under tests, two files under content, and package-lock.json under skip. If any file is in the wrong bucket, suspect the rule order first.
A classifier doesn't throw when it is wrong; it just drops the file into another bucket. Running it once on a throwaway repository before showing anyone the numbers catches this early.
The decision, and what I'll try next
Here is the decision, in my own words.
Situation
What I do
Logic only, or under 30 files with few buckets
Read git diff --stat as is
Config or dependencies mixed with code
Run the script and read config first
30 or more files, or lots of prose mixed in
Run the script and fold by bucket
Lockfiles or images only
Don't read them; just check the count
If you try this yourself, run measure.py over your own history first. If mixed diffs are under about 10 percent, skipping daily use is the natural call. Mine were 17 percent, yet I narrowed it to the 10 diffs that matched the gate.
Next I plan to grow the secrets-infra list to match the real config file names in my own repositories. More rules mean more chances to misclassify, so I'll verify each addition on a throwaway repository as I go.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.