Reporting Coverage Changes on Pull Requests
A repository-wide coverage figure on a pull request is almost useless: it barely moves for a small change, and when it does move the cause is usually something other than the change. What reviewers actually need is coverage of the diff — of the lines this change added — because that is the only part the author can do anything about. This guide covers computing that number, showing uncovered new lines where the reviewer is already looking, and gating on new code in a way that does not punish someone for touching a file that was untested before they arrived. It sits under defining coverage thresholds.
Root Cause Analysis
Total coverage is insensitive to individual changes by construction. A hundred new lines in a ten-thousand-line codebase moves the total by at most one point, and that movement is swamped by unrelated noise — a deleted file, a regenerated client, a different set of tests running because of affected-file selection. Reviewers learn quickly that the number is not informative and stop reading it.
Gating on the total makes this worse. The gate fires on a change that did not cause the drop, so the author’s only options are to write tests for code they did not touch or to ask for an override — both of which teach people that the gate is an obstacle rather than a signal. That is how coverage gates end up disabled.
Diff coverage inverts both problems. It is highly sensitive to the change, because the denominator is the change. It is fair, because the author controls exactly the lines being measured. And it is actionable at review time, because the uncovered lines can be shown inline rather than as a percentage someone has to interpret.
Reproducible Setup
Diff coverage needs two inputs: an LCOV report from the run, and the set of lines the branch added.
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['lcov', 'json-summary', 'text'],
reportsDirectory: './coverage',
},
},
});
# .github/workflows/pr.yml — full history so the merge base is available
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: npm ci
- run: npx vitest run --coverage
Implementation
Step 1 — Extract the added lines from the diff. Only added and modified lines count; deletions and context do not.
// scripts/diff-coverage/added-lines.ts
import { execSync } from 'node:child_process';
export function addedLines(base: string): Map<string, Set<number>> {
const diff = execSync(`git diff --unified=0 ${base}...HEAD -- '*.ts' '*.tsx'`, { encoding: 'utf8' });
const result = new Map<string, Set<number>>();
let file = '';
for (const line of diff.split('\n')) {
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
if (fileMatch) { file = fileMatch[1]; result.set(file, new Set()); continue; }
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
if (hunk && file) {
const start = Number(hunk[1]);
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
for (let i = 0; i < count; i++) result.get(file)!.add(start + i);
}
}
return result;
}
Step 2 — Intersect them with the coverage report. LCOV records a hit count per line, which is all the intersection needs.
// scripts/diff-coverage/report.ts
import { readFileSync } from 'node:fs';
import { addedLines } from './added-lines';
function lcovHits(path: string): Map<string, Map<number, number>> {
const out = new Map<string, Map<number, number>>();
let file = '';
for (const line of readFileSync(path, 'utf8').split('\n')) {
if (line.startsWith('SF:')) { file = line.slice(3).replace(process.cwd() + '/', ''); out.set(file, new Map()); }
else if (line.startsWith('DA:') && file) {
const [n, hits] = line.slice(3).split(',').map(Number);
out.get(file)!.set(n, hits);
}
}
return out;
}
const added = addedLines(process.env.BASE_REF ?? 'origin/main');
const hits = lcovHits('coverage/lcov.info');
let total = 0, covered = 0;
const uncovered: Array<{ file: string; line: number }> = [];
for (const [file, lines] of added) {
const fileHits = hits.get(file);
if (!fileHits) continue; // not measured (excluded or not source)
for (const line of lines) {
if (!fileHits.has(line)) continue; // not an executable line
total++;
if (fileHits.get(line)! > 0) covered++;
else uncovered.push({ file, line });
}
}
console.log(`diff coverage: ${total ? ((covered / total) * 100).toFixed(1) : '100.0'}% (${covered}/${total})`);
Step 3 — Annotate the uncovered lines in the diff. A percentage tells the author there is work; an annotation tells them where.
// scripts/diff-coverage/annotate.ts
for (const u of uncovered.slice(0, 25)) {
process.stdout.write(
`::warning file=${u.file},line=${u.line}::This line was added by this change and is not covered by any test\n`,
);
}
Step 4 — Gate on new code only, with a threshold that leaves room for judgement. Eighty per cent of new lines is a common bar; it permits an untested error branch without permitting an untested feature.
// scripts/diff-coverage/gate.ts
const MIN_DIFF_COVERAGE = 80;
const MIN_LINES_TO_ENFORCE = 20; // tiny changes are exempt from the ratio
const pct = total ? (covered / total) * 100 : 100;
if (total >= MIN_LINES_TO_ENFORCE && pct < MIN_DIFF_COVERAGE) {
console.error(`diff coverage ${pct.toFixed(1)}% is below the ${MIN_DIFF_COVERAGE}% bar`);
process.exit(1);
}
Step 5 — Report the total as context, never as the gate. It belongs in the summary so a genuine drop is visible, but it must not be the thing that blocks, or you are back to punishing authors for the repository’s history.
const summary = JSON.parse(readFileSync('coverage/coverage-summary.json', 'utf8'));
appendFileSync(process.env.GITHUB_STEP_SUMMARY!, [
`## Coverage`,
`- diff: **${pct.toFixed(1)}%** (${covered}/${total} new lines)`,
`- repository total: ${summary.total.lines.pct.toFixed(1)}% (context only)`,
].join('\n') + '\n');
Verification
Verify the merge base is correct, because a shallow clone silently produces a diff against the wrong commit and therefore a meaningless number.
git merge-base --is-ancestor origin/main HEAD && echo "base is reachable"
git rev-list --count origin/main..HEAD
# 4 ← four commits on the branch; if this is huge, fetch-depth is wrong
Then verify the intersection by hand on one file. Add a line you know is untested, run the report, and confirm it appears in the uncovered list at the right line number — off-by-one errors in hunk parsing are easy and produce annotations on innocent lines.
BASE_REF=origin/main npx tsx scripts/diff-coverage/report.ts
# diff coverage: 76.5% (52/68)
# uncovered: src/domain/pricing.ts:118 ← the line just added
Finally, verify the exemption for small changes behaves sensibly. A one-line fix with no executable added lines should report a hundred per cent and pass, not divide by zero or fail for having no tests.
git commit --allow-empty -m "docs only" && BASE_REF=origin/main npx tsx scripts/diff-coverage/gate.ts
# diff coverage: 100.0% (0/0) — under the enforcement minimum, skipped
Troubleshooting
Symptom: diff coverage reports zero lines on every change. Diagnosis: the paths in the LCOV report and the paths from git do not match — one is absolute, or one has a ./ prefix. Fix: normalise both to repository-relative before intersecting, and print one of each when debugging; the mismatch is obvious once you look at the strings.
Symptom: annotations land one line off. Diagnosis: the hunk header parser is treating @@ -10 +12 @@ — the form with no count — as a single line starting at the wrong offset. Fix: default the count to one when absent, as in Step 1, and test the parser against a diff containing single-line hunks.
Symptom: the gate fires on pure refactors. Diagnosis: moving code produces added lines that are covered by the tests for the original location only if those tests run — and affected-file selection may have skipped them. Fix: run the full suite when computing diff coverage, or accept the exemption and review manually; a refactor gated on coverage of moved lines is an unpleasant surprise.
Symptom: the number swings between runs on the same commit. Diagnosis: flaky tests changing which lines execute. Fix: this is a suite problem rather than a reporting one, and it is worth fixing for its own sake — see detecting flaky tests by repeating runs in CI.
FAQ
What is a reasonable bar for coverage of new lines?
Eighty per cent works for most teams: high enough to require tests for new behaviour, low enough to allow an untested defensive branch or logging line without an argument. Setting it at a hundred guarantees the gate is bypassed within a month, which leaves you worse off than having no gate.
Should the gate block or only warn?
Start as a warning with annotations, which changes behaviour more than people expect because the uncovered lines are visible in review. Move to blocking once the numbers are routinely above the bar — at that point the gate catches the exception rather than fighting the norm.
Does this replace repository-wide thresholds?
No; they answer different questions. Diff coverage keeps new code tested, while per-area thresholds protect areas where the standard is higher than the diff bar, as described in per-directory coverage thresholds in Vitest. Run both; only the first should be the pull request gate.
How does this work with sharded or affected-only runs?
Both need care, because a line can appear uncovered simply because the test that covers it did not run. Merge coverage from all shards before computing the diff figure, and when using affected-file selection, either include the full suite for the coverage job or treat unmeasured files as exempt rather than uncovered.
Related
- Back to Defining Coverage Thresholds
- Per-directory coverage thresholds in Vitest — the standing bars behind the diff gate.
- Annotating pull requests with test failures — the same annotation mechanism for failures.
- Excluding generated code from coverage reports — keeping generated lines out of the diff figure too.