Skip to content

Why your coverage changed

Most surprises come from one of a handful of mechanics rather than from the code. This page is the short list, in the order they actually bite.

What the percentage counts

Coverage is statements, not lines and not files: every statement in every file of the merged report, counted once, covered over total. A file with 300 statements moves the number thirty times as far as one with 10, which is why a well-tested small package can land and the total barely twitches. A report with no statements at all reads 0%, not 100%.

Where coverage is shown line by line — diff coverage, the source view, a file's uncovered and newly uncovered ranges — one rule applies everywhere: a line is executable when a statement spans it, and covered when a statement on it ran. A line holding two blocks, one of which ran, is covered; an empty body (Go's func noop() {}) is not code a test can miss, just as it carries no weight in the total.

What it is compared against

The delta beside the percentage — and the Compared to line on the repo page — is not the previous commit. It is the latest gate-passing merged report on the same branch, ignoring the commit's own report so an earlier part is never its own baseline. A feature branch with no passing history of its own falls back to the default branch — as it stood when the upload arrived, so a page does not start comparing against what the default branch received later, a PR's own merge commit included.

When a commit's coverage comes in parts, the comparison is between the same parts. A baseline still missing one of the commit's parts — its other jobs still running, say — is passed over for an earlier one that has them all; and while the commit itself is still missing parts, the delta compares the parts it has against the same parts of the baseline. The gate's max drop rule compares the same way.

Gate-failing uploads are recorded but never become a baseline. That is deliberate: re-running CI cannot launder a failure into the new reference point, and a PR cannot walk coverage down one tolerated step at a time. The gate's max drop rule goes further and always measures against the default branch's latest passing report, whatever branch the upload is on.

Pull-request builds are never the default branch's own history. A PR opened from a fork's main uploads under the branch name main, but the badge, the dashboard, the repo page's trend and verdict for main, and every baseline keep reading the repository's own main builds. On a feature branch, the PR's builds are that branch's history: its trend plots them and its latest one is the branch's current coverage. They are still never a baseline — a branch is always compared against a build of its own.

The gate verdict's explanation follows the gate, not the delta: on a feature branch it reports the drop against the default branch, so the two can point different ways — the delta down against the branch's last build, the verdict passing because coverage still holds against the default branch. Uploads judged before gocov recorded that comparison leave the drop out of the explanation.

So a delta can move without your coverage moving — because the baseline it points at changed.

A part did not run

When a commit's coverage arrives in parts, the merged report is rebuilt from the latest upload of every part that exists. A job that did not run this time contributes nothing, and its statements simply are not in the merged report; the total drops even though no code changed. There is no carry-forward of a previous run's part.

This is the most common unexplained drop in a matrix build: a skipped job, a cancelled shard, a job that failed before its upload step. Check the upload page for the commit — it names the part it carried and how many parts the report was merged from.

A part is still in flight

Every upload recomputes the whole commit, so between jobs the merged report is genuinely incomplete: the total reads low and the gate can fail, then correct itself when the last part lands. The report heals; a reviewer who looked during the window did not see a bug. Sequence the gate check after all coverage jobs rather than racing them.

Diff coverage is empty while total coverage looks fine

Diff coverage matches the paths in your profile against the paths in the pull request's diff. With -path-prefix the match is exact; without one gocov tries a suffix heuristic in both directions, which is what lets JaCoCo's package-qualified paths (com/example/Foo.java) meet a repo path like src/main/java/com/example/Foo.java.

When neither matches — a profile recorded under a Go module path, a CI checkout directory baked into the paths, a monorepo subdirectory — total coverage is still correct and diff coverage comes out empty, because gocov cannot tell which recorded lines are the changed ones. Set -path-prefix to the prefix your profile carries.

There is nothing to diff against

Diff coverage needs the pull request's diff, which gocov fetches from the forge. On a workspace with no forge connection, on a push that is not part of a pull request, or on an upload whose PR id was not detected, there is no diff — diff coverage is skipped and the gate's diff rule is skipped with it. Total coverage and the gate's other rules are unaffected.