diff-cover
diff-cover is a command-line quality gate for code changed in a Git branch. It combines `git diff` with a Cobertura, Clover, JaCoCo XML, or LCOV coverage report and calculates coverage only for added or modified lines. Its companion `diff-quality` command applies supported linters to the same changed-line view. The practical aim is to demand tests and clean lint results for new work without first fixing every historical problem in a repository.
diff-cover is a focused and effective ratchet for improving legacy repositories one change at a time. Do not mistake patch coverage for overall test health, and budget time to make Git refs and report paths deterministic in CI.
Use it if
- You are introducing a coverage requirement to a codebase whose overall percentage is too low for an immediate global gate
- Your CI already creates Cobertura, Clover, JaCoCo XML, or LCOV reports and checks out enough Git history to compare branches
- You need a nonzero exit status when changed-line coverage drops below a review policy such as 80 percent
- You want HTML, JSON, or Markdown evidence that a pull request's exact changed lines are covered
- Your source is not in Git: the README lists Git as a requirement, so Mercurial and other version-control systems need a different integration
- Your coverage producer emits only a proprietary or console format: diff-cover accepts Cobertura, Clover, JaCoCo XML, and LCOV, not arbitrary test output
- You need a trustworthy whole-project coverage policy: this tool intentionally considers only the diff, so untouched untested code stays invisible
- Your reports and checkout use different source paths: the troubleshooting guide says path mismatches cause the misleading 'No lines with coverage information in this diff' result
- You expect every changed physical line in a multi-line statement to count: the README documents that coverage reports often record only statement lines, and its expansion workaround is XML-only and assumption-based
Setup reality
Installing `diff-cover` is the easy part: version 10.5.0 needs Python 3.10 or newer and pulls Jinja2, Pygments, chardet, and pluggy. It does not run your tests or create coverage data. CI must first generate a supported Cobertura, Clover, JaCoCo XML, or LCOV report, then invoke `diff-cover` from a Git working tree. By default it compares with `origin/main`, which fails or measures the wrong change set when the remote, base branch, shallow checkout, or pull-request merge strategy differs. Fetch the intended base ref and pass `--compare-branch` explicitly. Coverage file paths must line up with paths in `git diff`; run report generation and diff-cover from the same repository root, or normalize the producer's source paths. JaCoCo projects often need `--src-roots` because package-relative class paths do not identify the source tree by themselves. A threshold matters only when `--fail-under` is present; without it, a poor report is still informational. Multiple reports are OR-combined, meaning a line is covered if any input report covers it. TOML configuration is not part of the base install: use `pip install 'diff-cover[toml]'`, name the file with a `.toml` extension, and remember that mandatory positional inputs still stay on the command line. For pull-request services that provide a patch but little Git history, create a diff file and use `--diff-file`. Decide deliberately between the default three-dot comparison and `--diff-range-notation=..`, because they answer different Git questions.
Patterns
Generate Python coverage and check the diffcheck-python-diff-coverage
pytest --cov=src --cov-report=xml:coverage.xml
diff-cover coverage.xml --compare-branch=origin/mainRun both commands from the same repository root so source paths in coverage.xml match paths produced by Git.
Fail CI below a changed-line thresholdenforce-coverage-threshold
diff-cover coverage.xml \
--compare-branch=origin/main \
--fail-under=90 \
--show-uncoveredWithout --fail-under, the command reports the percentage but does not turn a low result into a failing policy check.
Create a Markdown pull-request artifactwrite-markdown-report
diff-cover coverage.xml \
--compare-branch=origin/main \
--format markdown:diff-coverage.mdThe file is created locally. Posting it to a pull request still needs CI-provider permissions and a separate upload or comment step.
Produce HTML and JSON in one runwrite-multiple-formats
diff-cover coverage.xml \
--format html:artifacts/diff-cover.html,json:artifacts/diff-cover.jsonCreate the artifacts directory before running the command; the formats are comma-separated within one --format value.
Combine results from multiple test suitescombine-coverage-reports
diff-cover unit-coverage.xml integration-coverage.xml \
--compare-branch=origin/main \
--fail-under=85Combination uses OR semantics: a changed line counts as covered when any supplied report records it as covered.
Analyze an explicit patch filecheck-saved-diff
git diff origin/main...HEAD > pull-request.diff
diff-cover coverage.xml --diff-file=pull-request.diff --fail-under=90Use this when CI supplies a patch or lacks the base ref. Do not also rely on --compare-branch to define a different change set.
Map a JaCoCo report to Java sourcesset-java-source-roots
diff-cover target/site/jacoco/jacoco.xml \
--src-roots src/main/java generated/src/main/java \
--compare-branch=origin/mainThe README lists src/main/java as the default. Add every actual source root when report class paths do not match repository paths.
Exclude files that should not affect the gateexclude-generated-files
diff-cover coverage.xml \
--exclude 'src/generated/*' '*/migrations/*' \
--fail-under=90Exclusion uses fnmatch and paths are relative to the Git root. Quote patterns so the shell does not expand them first.
Control staged, unstaged, and untracked inputsinclude-working-tree-changes
diff-cover coverage.xml \
--ignore-staged \
--include-untrackedStaged and unstaged files are included by default, while untracked files are excluded unless --include-untracked is passed.
Expand XML coverage across multi-line statementshandle-multiline-statements
diff-cover coverage.xml \
--expand-coverage-report \
--show-uncoveredThis workaround is XML-only and copies the preceding reported line's hit count, so review whether that assumption fits your coverage producer.
Keep optional settings in TOMLconfigure-with-toml
# Install the optional parser first:
pip install 'diff-cover[toml]'
# pyproject-diff-cover.toml
[tool.diff_cover]
compare_branch = "origin/main"
fail_under = 90
show_uncovered = true
# The report remains positional:
diff-cover coverage.xml --config-file pyproject-diff-cover.tomlOnly .toml files are parsed, and command-line values override config values. Mandatory report inputs cannot live in the file.
Run Ruff only against changed linesgate-ruff-violations
diff-quality \
--violations=ruff.check \
--compare-branch=origin/main \
--fail-under=100The selected quality tool must already be installed. diff-quality reports 'Quality tool not installed' rather than installing Ruff for you.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| coverage | PyPI | You need Python coverage collection, combination, and whole-project thresholds rather than changed-line analysis |
| pytest-cov | PyPI | Your pytest suite needs to generate the XML input that diff-cover consumes |
| codecov-cli | PyPI | You want hosted pull-request annotations, historical trends, and server-side patch coverage |
| pycobertura | PyPI | You want local Cobertura report inspection and comparison without tying the policy to Git diffs |