Skip to content

Patch coverage

Patch coverage is the fraction of changed executable lines the test run covered. A line counts iff it is changed by the PR: in an added/modified hunk on the new side, diffed against merge-base(base, head), and the coverage tool considers it executable. The merge-base survives base-branch rebases and force-pushes.

            covered changed-executable lines
patch % = ───────────────────────────────────── × 100
              all changed-executable lines

Classification rules

Case Behaviour Configurable
Brand-new file every executable line counts none
Modified hunk only the changed executable lines count none
Pre-existing uncovered line in a changed file excluded: never penalised none
Blank line / comment / brace excluded (not in the coverage report) none
Changed file the report never mentions (a doc, an untested new file) contributes nothing (diff-cover behaviour) none
Renamed / copied file matched by head path; changed lines line-matched none
Deleted file dropped none
Nothing coverable changed (docs-only PR) vacuous pass (100%) none
Broken / empty test run tool error (exit 2), not 0% none
Missing merge-base / shallow clone fails loudly: needs fetch-depth: 0 none

The denominator is deliberately changed-and-executable: coverage tools only report executable lines, so blank lines and comments fall out naturally, and a genuinely untested new file the suite never imported isn't in the report, it contributes nothing rather than tanking the score. This matches diff-cover.

Why a broken run is not 0%

If the test command exits non-zero or emits no parseable coverage, reporting that as "0% patch coverage" would be actively misleading: it conflates no signal with bad signal. Brimyr treats it as a tool error (build red, exit 2) instead, the same philosophy as a broken security scanner being a tool error, not a finding.

Path matching

Coverage-report paths and git diff paths rarely match byte-for-byte: coverage tools emit absolute paths, <source>-rooted paths, or monorepo-prefixed paths, while the diff is repo-relative. Matching therefore falls back:

  1. exact normalized-path match;
  2. a coverage path that ends with the diff path (absolute coverage path);
  3. a diff path that ends with a (shorter) coverage path.

Pass strip_prefix (action) / --strip-prefix (CLI) to peel a known root (a monorepo subdir, a Cobertura <source>) before matching.

The threshold

threshold (default 80) is the patch-coverage percentage below which the gate blocks. --no-gate (or baseline mode) makes the run report-only: coverage is still computed and shipped to SonarQube, nothing blocks.

The sample-size floor

min_lines (default 20) is the number of changed executable lines below which the threshold is not applied. Below that the percentage is too coarse to act on: one uncovered line out of three is 67%, which fails an 80% gate while telling you nothing useful about the change.

SonarQube applies exactly this rule at exactly this number: "the conditions on coverage are ignored until the number of new lines to cover is at least 20", so matching it stops a Brimyr verdict and a SonarQube verdict from disagreeing on small pull requests.

It is a hole, deliberately

A 19-line change with no tests at all passes. That is the same shape of hole as the one that makes a project-level coverage gate unusable, just much smaller. The difference is that Brimyr says so:

⚪ Only 3 changed executable line(s), below the 20-line minimum, so the 80.0%
threshold was not applied (patch coverage was 33.3%).

SonarQube applies the same exemption silently, which is how a team ends up believing small PRs are gated when they are not. Set min_lines: '0' to gate every diff.

A diff with no coverable lines at all is still a separate case, a vacuous pass, the same as a docs-only change, and is reported as such rather than as "too small".

Local gate, Sonar trend

The gate is computed locally from the coverage file, it never depends on SonarQube. The sonar-scanner run is a separate, non-blocking step that feeds Sonar the same coverage for the long-run trend.

Total coverage, reported next to it

Brimyr also reports an overall coverage figure alongside the patch number. It is reported and never gated on: the same split Chargate uses, where the build blocks on net-new findings but the full SARIF still ships.

| Patch coverage                             | **100.0%** |
| Covered / changed executable lines         | 2 / 2      |
| Total coverage (measured files)            | 7.3%       |
| Covered / executable lines across 2 file(s)| 3 / 41     |

The gate is unchanged: gate_result, the exit code and failed all still derive from patch coverage alone. A codebase at 7% total does not fail a well-tested PR.

What the number actually means

"Total coverage (measured files)", not "project coverage". A coverage report only mentions files the test run loaded, so a module no test imports is absent from the report and therefore absent from this denominator, which means adding a brand-new untested file can raise it. Say "coverage of what we measured".

It also will not match SonarQube's figure, which applies its own sonar.coverage.exclusions and source scoping. That is fine and expected: this number is the at-a-glance trend inside a PR comment, and SonarQube owns the authoritative long-run total. If you only want one number, use Sonar's.

Nothing measured is not 100%

When a run measures no executable lines, total coverage is reported as absent, not as 100%. Patch coverage's vacuous 100% exists because a docs-only PR genuinely has nothing to cover; a total of 100% over zero lines would just be a lie that looks like good news. The total_coverage action output is an empty string in that case, never 0.00.

It obeys exclude

The same globs that drop generated code from the patch denominator drop it here. They have to: a comment showing a patch figure that ignored the EF migrations next to a total that didn't would disagree by twenty points and read as a bug in the tool.

Baseline mode

In baseline (report-only) mode the patch rows are omitted entirely rather than printed as a vacuous 100.0% (0 / 0), because baseline computes patch coverage against an empty diff. Baseline plus total coverage is the useful combination for a repo with no PR CI to attach a gate to: run it on pushes to the default branch, block nothing, and start producing a trend.