Every repo I’ve worked in has a coverage number, and the number lies. Ours says 84%. It’s said 84% for about a year. It is, in the most literal sense, true — and it tells me almost nothing about the change sitting in front of me.
A project-wide coverage percentage is the HEAD / → 200 of testing. Someone opens a PR, adds a hundred lines with no tests, and the global number drifts from 84.1% to 83.6%. Nobody blocks a PR over half a percent — you’d look insane doing it. So the untested code walks right in, the number stays roughly flat, and six months later “84% covered” is hiding a steadily growing pile of stuff nobody ever wrote a test for. The aggregate is exactly the wrong lens: it’s biggest where it matters least, and it barely flinches at the one thing you’d actually want to catch.
Same instinct as the other gates I keep building — heartbeat and deploy — pointed at a different lie.
The Idea
Stop grading the whole codebase. Grade the diff.
It’s called delta coverage, or patch coverage, and the move is simple: correlate the git diff with the coverage report and hold only the lines this PR changed to a threshold. The global number can sit at 84% forever — I don’t care. I care that the hundred lines you just wrote are tested. If they’re not, the PR doesn’t merge. Coverage debt can’t slip in behind an aggregate that barely moves, because the aggregate isn’t the gate anymore. Your diff is.
This isn’t a new idea — Codecov and friends have done patch coverage for years. I wanted a version that was dead simple, language-agnostic, and ran entirely inside my own CI.
What I Built
difftron is a small Go CLI: point it at a coverage report — LCOV, Cobertura, or Go’s native format — and it diffs HEAD against the base branch and grades only the changed lines. If your test runner emits one of those three, difftron doesn’t care what language wrote it.
Wrapped as a GitHub Action, wiring it up is this:
- uses: swantron/difftron@v1
with:
coverage: coverage/lcov.info
threshold: '80'
Sticky PR comment, per-file breakdown, exact uncovered line numbers, fails the check under threshold — or just comments, if you set fail-on-error: false while you’re easing it in. That’s the whole thing. $0, runs in your CI, and nothing — not your source, not your coverage — leaves the runner.
Dogfooding the fleet
I dropped it onto six of my own repos in an afternoon — Go tools, a couple of vitest apps, two that needed c8 to wrap node --test into an lcov file. Same four lines of yaml every time. Watching a Go repo, a vitest repo, and a node:test monorepo all light up the identical sticky comment was the moment it felt real.
On tronswan, I planted covered() (tested) next to uncovered() (not) in one file and opened a throwaway PR. difftron graded only the 15 changed lines — 1 covered, 14 not — and skipped the test file for having no coverage signal of its own. The project-wide number barely moved. The diff didn’t.

The honest bug: v1 failed a docs-only PR. Edit a README, difftron finds no coverage data for markdown, reads that as 0%, fails the PR. Instant uninstall. Fix was philosophical as much as technical — no signal isn’t the same as zero, so files with none get skipped, not failed, and the comment says which. Touch only docs, gate passes clean, tells you why.
The caveats, because there are always caveats
- A gate, not an SLO. “These lines are tested” is a different, smaller claim than “this codebase is well tested” — on purpose.
- Report-only while it earns trust.
fail-on-error: falseon every repo above until changed-line coverage catches up, then flip it. - Garbage in, honestly-labeled garbage out. No coverage file for something, no opinion about it — difftron says so instead of guessing.
- Codecov, Coveralls, Sonar already do patch coverage, with dashboards and org policy difftron doesn’t have. What they can’t match: nothing ever leaves your runner. No upload, no token, no third party reading your code. For a lot of teams that’s the whole pitch.
Try It
Marketplace: Difftron Delta Coverage Gate
Source: github.com/swantron/difftron
A few lines of yaml, $0, runs where your tests already run — and your next untested PR doesn’t get to merge quietly. Check it out ^

















