Skip to content

Commit ca4effc

Browse files
mofrclaude
andauthored
Add --branch-coverage flag to respect partial branch coverage (#608)
diff-cover treated any executed line (hits > 0) as covered, ignoring Cobertura branch/condition-coverage data. A branch that was reached but only partially exercised therefore passed even --fail-under=100. Add an opt-in --branch-coverage flag that treats a partially covered branch (branch="true" with condition-coverage below 100%) as a violation. Default behaviour is unchanged. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b746a1b commit ca4effc

5 files changed

Lines changed: 147 additions & 17 deletions

File tree

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
Unreleased
2+
3+
* Add --branch-coverage flag to diff-cover to treat partially covered branches in Cobertura XML reports as uncovered
4+
5+
16
05/30/2026 v10.3.0
27

38
* Add --show-covered flag to highlight covered diff lines in HTML report [PR 600](https://github.com/Bachmann1234/diff_cover/pull/600) Thanks @duxiaocheng

diff_cover/diff_cover_tool.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252
EXPAND_COVERAGE_REPORT = (
5353
"Append missing lines in coverage reports based on the hits of the previous line."
5454
)
55+
BRANCH_COVERAGE_HELP = (
56+
"Treat partially covered branches as uncovered. Requires a coverage report "
57+
'with branch coverage data (Cobertura branch="true" condition-coverage).'
58+
)
5559
INCLUDE_UNTRACKED_HELP = "Include untracked files"
5660
CONFIG_FILE_HELP = "The configuration file to use"
5761
DIFF_FILE_HELP = "The diff file to use"
@@ -117,6 +121,13 @@ def parse_coverage_args(argv):
117121
help=EXPAND_COVERAGE_REPORT,
118122
)
119123

124+
parser.add_argument(
125+
"--branch-coverage",
126+
action="store_true",
127+
default=None,
128+
help=BRANCH_COVERAGE_HELP,
129+
)
130+
120131
parser.add_argument(
121132
"--external-css-file",
122133
metavar="FILENAME",
@@ -215,6 +226,7 @@ def parse_coverage_args(argv):
215226
"diff_range_notation": "...",
216227
"quiet": False,
217228
"expand_coverage_report": False,
229+
"branch_coverage": False,
218230
"total_percent_float": False,
219231
}
220232

@@ -237,6 +249,7 @@ def generate_coverage_report(
237249
show_uncovered=False,
238250
show_covered=False,
239251
expand_coverage_report=False,
252+
branch_coverage=False,
240253
total_percent_float=False,
241254
):
242255
"""
@@ -265,7 +278,9 @@ def generate_coverage_report(
265278
if xml_roots and lcov_roots:
266279
raise ValueError("Mixing LCov and XML reports is not supported yet")
267280
if xml_roots:
268-
coverage = XmlCoverageReporter(xml_roots, src_roots, expand_coverage_report)
281+
coverage = XmlCoverageReporter(
282+
xml_roots, src_roots, expand_coverage_report, branch_coverage
283+
)
269284
else:
270285
coverage = LcovCoverageReporter(lcov_roots, src_roots)
271286

@@ -417,6 +432,7 @@ def main(argv=None, directory=None):
417432
show_uncovered=arg_dict["show_uncovered"],
418433
show_covered=arg_dict["show_covered"],
419434
expand_coverage_report=arg_dict["expand_coverage_report"],
435+
branch_coverage=arg_dict["branch_coverage"],
420436
total_percent_float=arg_dict["total_percent_float"],
421437
)
422438

diff_cover/violationsreporters/violations_reporter.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ class XmlCoverageReporter(BaseViolationReporter):
2424
Query information from a Cobertura|Clover|JaCoCo XML coverage report.
2525
"""
2626

27-
def __init__(self, xml_roots, src_roots=None, expand_coverage_report=False):
27+
def __init__(
28+
self,
29+
xml_roots,
30+
src_roots=None,
31+
expand_coverage_report=False,
32+
branch_coverage=False,
33+
):
2834
"""
2935
Load the XML coverage report represented
3036
by the cElementTree with root element `xml_root`.
@@ -42,6 +48,11 @@ def __init__(self, xml_roots, src_roots=None, expand_coverage_report=False):
4248

4349
self._src_roots = src_roots or [""]
4450
self._expand_coverage_report = expand_coverage_report
51+
self._branch_coverage = branch_coverage
52+
53+
# Pulls the "(covered/total)" pair out of Cobertura's
54+
# condition-coverage attribute, e.g. "50% (1/2)".
55+
self._cobertura_condition_coverage_re = re.compile(r"\((\d+)/(\d+)\)")
4556

4657
def _get_xml_classes(self, xml_document):
4758
"""
@@ -239,23 +250,19 @@ def _cache_file(self, src_path):
239250
{_hits: last_hit_number, _number: line_number}
240251
)
241252

242-
# First case, need to define violations initially
243-
if violations is None:
244-
violations = {
245-
Violation(int(line.get(_number)), None)
246-
for line in line_nodes
247-
if int(line.get(_hits, 0)) == 0
248-
}
253+
new_violations = {
254+
Violation(int(line.get(_number)), None)
255+
for line in line_nodes
256+
if self._is_violation(line, _hits)
257+
}
249258

250-
# If we already have a violations set,
251-
# take the intersection of the new
252-
# violations set and its old self
259+
# The first report defines the violations set. Each subsequent
260+
# report narrows it, since we only keep violations that show up
261+
# in every report.
262+
if violations is None:
263+
violations = new_violations
253264
else:
254-
violations = violations & {
255-
Violation(int(line.get(_number)), None)
256-
for line in line_nodes
257-
if int(line.get(_hits, 0)) == 0
258-
}
265+
violations = violations & new_violations
259266

260267
# Measured is the union of itself and the new measured
261268
measured = measured | {int(line.get(_number)) for line in line_nodes}
@@ -267,6 +274,29 @@ def _cache_file(self, src_path):
267274

268275
self._info_cache[src_path] = (violations, measured)
269276

277+
def _is_violation(self, line, hits_attr):
278+
"""
279+
Return whether a coverage `line` node counts as a violation.
280+
281+
A line is a violation if it was never executed, or -- when branch
282+
coverage is enabled -- if it is a partially covered branch.
283+
"""
284+
if int(line.get(hits_attr, 0)) == 0:
285+
return True
286+
return self._branch_coverage and self._is_partial_branch(line)
287+
288+
def _is_partial_branch(self, line):
289+
"""Return whether a Cobertura `line` node is a partially covered branch."""
290+
if line.get("branch") != "true":
291+
return False
292+
match = self._cobertura_condition_coverage_re.search(
293+
line.get("condition-coverage", "")
294+
)
295+
if not match:
296+
return False
297+
covered, total = int(match.group(1)), int(match.group(2))
298+
return covered < total
299+
270300
def violations(self, src_path):
271301
"""
272302
See base class comments.

tests/test_diff_cover_tool.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ def test_show_covered_flag():
4747
assert arg_dict["show_covered"] is True
4848

4949

50+
def test_branch_coverage_defaults_false():
51+
arg_dict = parse_coverage_args(["reports/coverage.xml"])
52+
assert arg_dict["branch_coverage"] is False
53+
54+
55+
def test_branch_coverage_flag():
56+
arg_dict = parse_coverage_args(["reports/coverage.xml", "--branch-coverage"])
57+
assert arg_dict["branch_coverage"] is True
58+
59+
5060
def test_parse_with_multiple_reports():
5161
argv = [
5262
"reports/coverage.xml",

tests/test_violations_reporter.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,75 @@ def test_expand_unreported_lines_without_measured(self):
333333

334334
assert set() == coverage.violations("file1.java")
335335

336+
def test_partial_branch_is_violation_when_branch_coverage_enabled(self):
337+
# A line that executed (hits=1) but only exercised 1 of its 2
338+
# branches. With branch coverage enabled this should count as a
339+
# violation, even though the statement itself ran.
340+
xml = etree.fromstring("""<?xml version="1.0"?>
341+
<coverage>
342+
<packages><classes>
343+
<class filename="file1.py">
344+
<lines>
345+
<line number="1" hits="1"/>
346+
<line number="2" hits="1" branch="true"
347+
condition-coverage="50% (1/2)"/>
348+
<line number="3" hits="1" branch="true"
349+
condition-coverage="100% (2/2)"/>
350+
</lines>
351+
</class>
352+
</classes></packages>
353+
</coverage>
354+
""")
355+
356+
coverage = XmlCoverageReporter([xml], branch_coverage=True)
357+
358+
# Line 2 is a partially covered branch -> violation.
359+
# Line 3 is a fully covered branch, line 1 a plain statement -> covered.
360+
assert coverage.violations("file1.py") == {Violation(2, None)}
361+
assert coverage.measured_lines("file1.py") == {1, 2, 3}
362+
363+
def test_partial_branch_ignored_by_default(self):
364+
# Without branch coverage enabled, an executed-but-partially-covered
365+
# branch stays covered, preserving the historical behaviour.
366+
xml = etree.fromstring("""<?xml version="1.0"?>
367+
<coverage>
368+
<packages><classes>
369+
<class filename="file1.py">
370+
<lines>
371+
<line number="1" hits="1"/>
372+
<line number="2" hits="1" branch="true"
373+
condition-coverage="50% (1/2)"/>
374+
</lines>
375+
</class>
376+
</classes></packages>
377+
</coverage>
378+
""")
379+
380+
coverage = XmlCoverageReporter([xml])
381+
382+
assert coverage.violations("file1.py") == set()
383+
assert coverage.measured_lines("file1.py") == {1, 2}
384+
385+
def test_uncovered_branch_line_is_single_violation(self):
386+
# A line that is both never executed (hits=0) and a partial branch is
387+
# reported once, not twice.
388+
xml = etree.fromstring("""<?xml version="1.0"?>
389+
<coverage>
390+
<packages><classes>
391+
<class filename="file1.py">
392+
<lines>
393+
<line number="1" hits="0" branch="true"
394+
condition-coverage="0% (0/2)"/>
395+
</lines>
396+
</class>
397+
</classes></packages>
398+
</coverage>
399+
""")
400+
401+
coverage = XmlCoverageReporter([xml], branch_coverage=True)
402+
403+
assert coverage.violations("file1.py") == {Violation(1, None)}
404+
336405
def _coverage_xml(self, file_paths, violations, measured, source_paths=None):
337406
"""
338407
Build an XML tree with source files specified by `file_paths`.

0 commit comments

Comments
 (0)