[{"data":1,"prerenderedAt":880},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fenforcing-diff-coverage-on-pull-requests\u002F":3},{"id":4,"title":5,"body":6,"description":843,"extension":844,"meta":845,"navigation":875,"path":876,"seo":877,"stem":878,"__hash__":879},"content\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fenforcing-diff-coverage-on-pull-requests\u002Findex.md","Enforcing Diff Coverage on Pull Requests",{"type":7,"value":8,"toc":832},"minimark",[9,13,18,53,57,60,101,116,119,238,245,375,379,382,386,389,406,413,422,437,542,546,549,560,563,599,602,605,609,651,655,658,671,680,686,761,765,771,777,783,789,793,822,828],[10,11,12],"p",{},"A total coverage percentage answers a question nobody asked. On a mature codebase it moves by tenths, so it cannot say whether the change under review is tested — and because it is a single global number, the only way to satisfy it is to write tests somewhere, not necessarily where the change is. Diff coverage replaces it with the question a reviewer actually needs answered: of the lines this pull request adds or modifies, how many does the suite execute?",[14,15,17],"h2",{"id":16},"prerequisites","Prerequisites",[19,20,21,38,45],"ul",{},[22,23,24,28,29,33,34,37],"li",{},[25,26,27],"strong",{},"pytest-cov 4.1+"," producing ",[30,31,32],"code",{},"coverage.xml",", and ",[25,35,36],{},"diff-cover 8.0+",".",[22,39,40,41,44],{},"A CI checkout with enough git history to compute a merge base (",[30,42,43],{},"fetch-depth: 0"," on GitHub Actions).",[22,46,47,48,37],{},"A correct combined report, especially under parallel runs — see ",[49,50,52],"a",{"href":51},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fwhy-pytest-cov-reports-zero-under-xdist\u002F","why pytest-cov reports 0% under xdist",[14,54,56],{"id":55},"solution","Solution",[10,58,59],{},"The gate is two commands: produce the report, compare it to the diff.",[61,62,67],"pre",{"className":63,"code":64,"language":65,"meta":66,"style":66},"language-console shiki shiki-themes github-light github-dark","$ pytest -n auto --cov --cov-report=xml -q\n$ diff-cover coverage.xml \\\n    --compare-branch=origin\u002Fmain \\\n    --fail-under=100 \\\n    --html-report diff-coverage.html\n","console","",[30,68,69,77,83,89,95],{"__ignoreMap":66},[70,71,74],"span",{"class":72,"line":73},"line",1,[70,75,76],{},"$ pytest -n auto --cov --cov-report=xml -q\n",[70,78,80],{"class":72,"line":79},2,[70,81,82],{},"$ diff-cover coverage.xml \\\n",[70,84,86],{"class":72,"line":85},3,[70,87,88],{},"    --compare-branch=origin\u002Fmain \\\n",[70,90,92],{"class":72,"line":91},4,[70,93,94],{},"    --fail-under=100 \\\n",[70,96,98],{"class":72,"line":97},5,[70,99,100],{},"    --html-report diff-coverage.html\n",[10,102,103,106,107,110,111,115],{},[30,104,105],{},"diff-cover"," computes the diff between the working tree and the merge base with ",[30,108,109],{},"--compare-branch",", intersects the changed lines with the coverage data, and reports the percentage of ",[112,113,114],"em",{},"changed"," lines that were executed. A threshold of 100 is realistic here in a way it never is for total coverage, because the denominator is only the lines in this change.",[10,117,118],{},"In GitHub Actions the whole gate is one job, with the checkout depth being the detail that most often breaks it:",[61,120,124],{"className":121,"code":122,"language":123,"meta":66,"style":66},"language-yaml shiki shiki-themes github-light github-dark","- uses: actions\u002Fcheckout@v4\n  with:\n    fetch-depth: 0                      # merge base must exist locally\n- run: pytest -n auto --cov --cov-report=xml -q\n- run: |\n    diff-cover coverage.xml \\\n      --compare-branch=origin\u002F${{ github.base_ref }} \\\n      --fail-under=100 \\\n      --markdown-report diff-coverage.md\n- if: always()\n  run: cat diff-coverage.md >> $GITHUB_STEP_SUMMARY\n","yaml",[30,125,126,143,151,166,178,190,196,202,208,214,227],{"__ignoreMap":66},[70,127,128,132,136,139],{"class":72,"line":73},[70,129,131],{"class":130},"sVt8B","- ",[70,133,135],{"class":134},"s9eBZ","uses",[70,137,138],{"class":130},": ",[70,140,142],{"class":141},"sZZnC","actions\u002Fcheckout@v4\n",[70,144,145,148],{"class":72,"line":79},[70,146,147],{"class":134},"  with",[70,149,150],{"class":130},":\n",[70,152,153,156,158,162],{"class":72,"line":85},[70,154,155],{"class":134},"    fetch-depth",[70,157,138],{"class":130},[70,159,161],{"class":160},"sj4cs","0",[70,163,165],{"class":164},"sJ8bj","                      # merge base must exist locally\n",[70,167,168,170,173,175],{"class":72,"line":91},[70,169,131],{"class":130},[70,171,172],{"class":134},"run",[70,174,138],{"class":130},[70,176,177],{"class":141},"pytest -n auto --cov --cov-report=xml -q\n",[70,179,180,182,184,186],{"class":72,"line":97},[70,181,131],{"class":130},[70,183,172],{"class":134},[70,185,138],{"class":130},[70,187,189],{"class":188},"szBVR","|\n",[70,191,193],{"class":72,"line":192},6,[70,194,195],{"class":141},"    diff-cover coverage.xml \\\n",[70,197,199],{"class":72,"line":198},7,[70,200,201],{"class":141},"      --compare-branch=origin\u002F${{ github.base_ref }} \\\n",[70,203,205],{"class":72,"line":204},8,[70,206,207],{"class":141},"      --fail-under=100 \\\n",[70,209,211],{"class":72,"line":210},9,[70,212,213],{"class":141},"      --markdown-report diff-coverage.md\n",[70,215,217,219,222,224],{"class":72,"line":216},10,[70,218,131],{"class":130},[70,220,221],{"class":134},"if",[70,223,138],{"class":130},[70,225,226],{"class":141},"always()\n",[70,228,230,233,235],{"class":72,"line":229},11,[70,231,232],{"class":134},"  run",[70,234,138],{"class":130},[70,236,237],{"class":141},"cat diff-coverage.md >> $GITHUB_STEP_SUMMARY\n",[10,239,240,241,244],{},"The last step matters more than it looks. A gate that fails with a log line nobody reads produces frustration; the same gate that prints the uncovered lines into the pull request summary produces a fix. ",[30,242,243],{},"--markdown-report"," writes exactly that: a per-file list of line numbers the change touched and the tests did not.",[246,247,250,371],"figure",{"className":248},[249],"diagram",[251,252,259,260,259,264,259,268,259,286,259,293,259,302,259,311,259,316,259,321,259,327,259,330,259,334,259,337,259,341,259,344,259,348,259,351,259,355,259,358,259,362,259,365],"svg",{"viewBox":253,"role":254,"ariaLabelledBy":255,"xmlns":258},"0 0 760 172","img",[256,257],"diffcovflow-t","diffcovflow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[261,262,263],"title",{"id":256},"How a diff-coverage gate is computed",[265,266,267],"desc",{"id":257},"A left-to-right pipeline: the suite produces a coverage XML report, git computes the changed lines against the merge base, the two are intersected to find changed-and-uncovered lines, and the gate fails when that set is not empty.",[269,270,271,272,259],"defs",{},"\n    ",[273,274,281],"marker",{"id":275,"viewBox":276,"refX":277,"refY":278,"markerWidth":279,"markerHeight":279,"orient":280},"diffcovflow-a","0 0 10 10","9","5","7","auto-start-reverse",[282,283],"path",{"d":284,"fill":285},"M0 0 L10 5 L0 10 z","#81b29a",[287,288],"rect",{"x":161,"y":161,"width":289,"height":290,"rx":291,"fill":292},"760","172","14","#fffdf8",[294,295,263],"text",{"x":296,"y":297,"textAnchor":298,"fontSize":299,"fontWeight":300,"fill":301},"380","30","middle","15","700","#3d405b",[287,303],{"x":304,"y":305,"width":306,"height":307,"rx":308,"fill":309,"stroke":285,"strokeWidth":310},"26","62","142","76","12","#f4f1de","1.8",[294,312,32],{"x":313,"y":314,"textAnchor":298,"fontSize":315,"fontWeight":300,"fill":301},"97","96","12.5",[294,317,320],{"x":313,"y":318,"textAnchor":298,"fontSize":319,"fill":301},"113","11","from the full run",[72,322],{"x1":323,"y1":324,"x2":325,"y2":324,"stroke":285,"strokeWidth":310,"markerEnd":326},"176","100","208","url(#diffcovflow-a)",[287,328],{"x":329,"y":305,"width":306,"height":307,"rx":308,"fill":292,"stroke":285,"strokeWidth":310},"214",[294,331,333],{"x":332,"y":314,"textAnchor":298,"fontSize":315,"fontWeight":300,"fill":301},"286","changed lines",[294,335,336],{"x":332,"y":318,"textAnchor":298,"fontSize":319,"fill":301},"diff vs merge base",[72,338],{"x1":339,"y1":324,"x2":340,"y2":324,"stroke":285,"strokeWidth":310,"markerEnd":326},"364","396",[287,342],{"x":343,"y":305,"width":306,"height":307,"rx":308,"fill":309,"stroke":285,"strokeWidth":310},"403",[294,345,347],{"x":346,"y":314,"textAnchor":298,"fontSize":315,"fontWeight":300,"fill":301},"474","intersect",[294,349,350],{"x":346,"y":318,"textAnchor":298,"fontSize":319,"fill":301},"changed AND uncovered",[72,352],{"x1":353,"y1":324,"x2":354,"y2":324,"stroke":285,"strokeWidth":310,"markerEnd":326},"552","584",[287,356],{"x":357,"y":305,"width":306,"height":307,"rx":308,"fill":292,"stroke":285,"strokeWidth":310},"592",[294,359,361],{"x":360,"y":314,"textAnchor":298,"fontSize":315,"fontWeight":300,"fill":301},"663","gate",[294,363,364],{"x":360,"y":318,"textAnchor":298,"fontSize":319,"fill":301},"fail if not empty",[294,366,370],{"x":296,"y":367,"textAnchor":298,"fontSize":368,"fontStyle":369,"fill":301},"164","11.5","italic","A shallow checkout has no merge base, which makes the diff empty and the gate vacuously green.",[372,373,374],"figcaption",{},"Only the intersection matters: untouched legacy code cannot fail the gate, and new code cannot pass it untested.",[14,376,378],{"id":377},"why-this-works","Why this works",[10,380,381],{},"Coverage XML records, for every file, which lines executed. Git records, for every file, which lines the change added or modified. Diff coverage is the intersection of those two sets, expressed as a percentage of the second. Because the denominator is scoped to the change, the metric is stable regardless of the size or age of the codebase, and it attributes the result to the person who can act on it. It also degrades gracefully: a change that touches no Python lines yields no denominator and passes trivially, which is the correct behaviour for a documentation-only commit.",[14,383,385],{"id":384},"choosing-the-comparison-point","Choosing the comparison point",[10,387,388],{},"The compare branch is the setting that decides whether the report is trustworthy.",[10,390,391,392,395,396,399,400,402,403,405],{},"Comparing against the ",[25,393,394],{},"merge base"," — the commit where the branch diverged — reports exactly the author's changes. Comparing against the ",[25,397,398],{},"tip of main"," reports the author's changes plus everything merged since the branch was cut, so an unrelated colleague's uncovered line fails your build, and rebasing changes the result. ",[30,401,105],{}," uses the merge base when given a branch name and the checkout has the history to compute it, which is why ",[30,404,43],{}," is not optional.",[10,407,408,409,412],{},"For a merge queue or a rebase-heavy workflow, compare against the ",[112,410,411],{},"target"," branch rather than a fixed name:",[61,414,416],{"className":63,"code":415,"language":65,"meta":66,"style":66},"$ diff-cover coverage.xml --compare-branch=\"origin\u002F${TARGET_BRANCH:-main}\" --fail-under=100\n",[30,417,418],{"__ignoreMap":66},[70,419,420],{"class":72,"line":73},[70,421,415],{},[10,423,424,425,428,429,432,433,436],{},"Two further options tune what counts. ",[30,426,427],{},"--diff-range-notation '...'"," selects the three-dot range explicitly, which is the merge-base semantics spelled out. And ",[30,430,431],{},"--ignore-staged"," \u002F ",[30,434,435],{},"--ignore-unstaged"," keep local runs meaningful when the working tree is dirty, so an engineer can run the same gate before pushing.",[246,438,440,539],{"className":439},[249],[251,441,259,446,259,449,259,452,259,455,259,457,259,466,259,470,259,474,259,478,259,482,259,486,259,489,259,493,259,496,259,500,259,503,259,507,259,509,259,513,259,516,259,519,259,522,259,526,259,529,259,532,259,536],{"viewBox":442,"role":254,"ariaLabelledBy":443,"xmlns":258},"0 0 760 270",[444,445],"cmpbranch-t","cmpbranch-d",[261,447,448],{"id":444},"What each comparison point measures",[265,450,451],{"id":445},"A table comparing three git comparison points for diff coverage - the merge base, the tip of the target branch, and the previous commit - describing what each includes and the failure mode of each.",[287,453],{"x":161,"y":161,"width":289,"height":454,"rx":291,"fill":292},"270",[294,456,448],{"x":296,"y":297,"textAnchor":298,"fontSize":299,"fontWeight":300,"fill":301},[287,458],{"x":459,"y":460,"width":461,"height":462,"rx":463,"fill":464,"stroke":301,"strokeWidth":465},"24","52","712","40","10","#f2cc8f","1.5",[294,467,469],{"x":468,"y":307,"fontSize":308,"fontWeight":300,"fill":301},"38","Criterion",[294,471,473],{"x":472,"y":307,"textAnchor":298,"fontSize":308,"fontWeight":300,"fill":301},"390","Includes",[294,475,477],{"x":476,"y":307,"textAnchor":298,"fontSize":308,"fontWeight":300,"fill":301},"620","Failure mode",[287,479],{"x":459,"y":480,"width":461,"height":462,"fill":309,"stroke":301,"strokeWidth":481},"92","1",[294,483,485],{"x":468,"y":484,"fontSize":368,"fill":301},"116","merge base (three-dot)",[294,487,488],{"x":472,"y":484,"textAnchor":298,"fontSize":368,"fill":301},"only your changes",[294,490,492],{"x":476,"y":484,"textAnchor":298,"fontSize":368,"fill":491},"#2a5f49","none, this is correct",[287,494],{"x":459,"y":495,"width":461,"height":462,"fill":292,"stroke":301,"strokeWidth":481},"132",[294,497,499],{"x":468,"y":498,"fontSize":368,"fill":301},"156","tip of target branch",[294,501,502],{"x":472,"y":498,"textAnchor":298,"fontSize":368,"fill":301},"yours plus others merged",[294,504,506],{"x":476,"y":498,"textAnchor":298,"fontSize":368,"fill":505},"#8f3d22","blames the wrong author",[287,508],{"x":459,"y":290,"width":461,"height":462,"fill":309,"stroke":301,"strokeWidth":481},[294,510,512],{"x":468,"y":511,"fontSize":368,"fill":301},"196","previous commit",[294,514,515],{"x":472,"y":511,"textAnchor":298,"fontSize":368,"fill":301},"the last commit only",[294,517,518],{"x":476,"y":511,"textAnchor":298,"fontSize":368,"fill":505},"misses earlier branch work",[287,520],{"x":459,"y":521,"width":461,"height":462,"fill":292,"stroke":301,"strokeWidth":481},"212",[294,523,525],{"x":468,"y":524,"fontSize":368,"fill":301},"236","shallow checkout",[294,527,528],{"x":472,"y":524,"textAnchor":298,"fontSize":368,"fill":301},"nothing computable",[294,530,531],{"x":476,"y":524,"textAnchor":298,"fontSize":368,"fill":505},"passes vacuously",[72,533],{"x1":534,"y1":460,"x2":534,"y2":535,"stroke":301,"strokeWidth":481},"274","252",[72,537],{"x1":538,"y1":460,"x2":538,"y2":535,"stroke":301,"strokeWidth":481},"505",[372,540,541],{},"Only the first row answers the question the gate is supposed to ask, and the last row is the reason so many gates are silently green.",[14,543,545],{"id":544},"living-with-the-gate","Living with the gate",[10,547,548],{},"A gate that cannot be satisfied honestly will be disabled, so the policy needs two escape hatches and one review rule.",[10,550,551,552,555,556,559],{},"The first hatch is exclusion of code that genuinely cannot be tested in CI: platform-specific branches, defensive assertions, ",[30,553,554],{},"if TYPE_CHECKING"," blocks. These belong in ",[30,557,558],{},"exclude_also"," in the coverage configuration, where they apply consistently, rather than in per-line pragmas added under deadline pressure.",[10,561,562],{},"The second is an explicit override — a label on the pull request that skips the gate, paired with a required comment explaining why. Making the override visible and attributable is what keeps it rare; making it impossible is what gets the gate deleted.",[61,564,566],{"className":121,"code":565,"language":123,"meta":66,"style":66},"- name: Diff coverage\n  if: \"!contains(github.event.pull_request.labels.*.name, 'skip-diff-coverage')\"\n  run: diff-cover coverage.xml --compare-branch=origin\u002Fmain --fail-under=100\n",[30,567,568,580,590],{"__ignoreMap":66},[70,569,570,572,575,577],{"class":72,"line":73},[70,571,131],{"class":130},[70,573,574],{"class":134},"name",[70,576,138],{"class":130},[70,578,579],{"class":141},"Diff coverage\n",[70,581,582,585,587],{"class":72,"line":79},[70,583,584],{"class":134},"  if",[70,586,138],{"class":130},[70,588,589],{"class":141},"\"!contains(github.event.pull_request.labels.*.name, 'skip-diff-coverage')\"\n",[70,591,592,594,596],{"class":72,"line":85},[70,593,232],{"class":134},[70,595,138],{"class":130},[70,597,598],{"class":141},"diff-cover coverage.xml --compare-branch=origin\u002Fmain --fail-under=100\n",[10,600,601],{},"The review rule is that uncovered lines are a review topic, not just a build failure. When the report lands in the pull request summary, a reviewer can see that the uncovered lines are an error branch nobody exercised and ask for a test — which is the outcome the gate exists to produce. A gate whose output only exists in a CI log produces re-runs instead of tests.",[10,603,604],{},"Finally, expect the first week to be noisy. Enabling diff coverage on an existing repository surfaces every untested error path in the code people are currently touching, and that is the point; run it as a warning for a sprint, publish the report, then turn on the failure once the team has seen what it asks for.",[14,606,608],{"id":607},"edge-cases-and-failure-modes","Edge cases and failure modes",[19,610,611,617,627,633,639,645],{},[22,612,613,616],{},[25,614,615],{},"Shallow checkouts pass vacuously."," With no merge base the diff is empty, the gate reports 100% and proves nothing. Assert that the changed-line count is non-zero for code changes.",[22,618,619,622,623,626],{},[25,620,621],{},"Generated files inflate the denominator."," Exclude them in the coverage configuration and, if necessary, with ",[30,624,625],{},"--exclude"," on diff-cover; a regenerated protobuf module should not be a review topic.",[22,628,629,632],{},[25,630,631],{},"Moved code counts as changed."," A large refactor that relocates covered code still shows those lines as changed, and they remain covered — but a move that also renames a branch condition can legitimately fail. Read the report rather than assuming it is noise.",[22,634,635,638],{},[25,636,637],{},"The report reflects the run that produced the XML."," If the coverage job ran a subset of the suite, the gate measures that subset; keep the coverage run and the gate in the same job.",[22,640,641,644],{},[25,642,643],{},"Merge commits confuse the range."," Prefer squash or rebase merges, or pass an explicit three-dot range so the merge base is unambiguous.",[22,646,647,650],{},[25,648,649],{},"A parallel run with a broken combine reports every changed line as uncovered."," The gate then fails on correct code, which is the fastest way to discover the combine problem described in the sibling guide.",[14,652,654],{"id":653},"reporting-the-gate-so-it-changes-behaviour","Reporting the gate so it changes behaviour",[10,656,657],{},"The difference between a gate that improves a codebase and one that annoys people is entirely in how the result is surfaced. Three presentation choices do most of that work.",[10,659,660,663,664,666,667,670],{},[25,661,662],{},"Show the lines, not the percentage."," \"Diff coverage 82%\" tells an author nothing actionable; a list of four file-and-line references tells them exactly what to write. Both ",[30,665,243],{}," and ",[30,668,669],{},"--html-report"," produce that list, and the markdown form can be posted directly into a pull request comment or a job summary.",[10,672,673,676,677,679],{},[25,674,675],{},"Link the report to the code."," ",[30,678,105],{}," renders file paths and line numbers; a short post-processing step that turns them into repository links removes the last friction between reading the report and fixing it.",[10,681,682,685],{},[25,683,684],{},"Report on success too, quietly."," A gate that only speaks when it fails trains people to treat it as an obstacle. A single line — \"diff coverage: 100% of 47 changed lines\" — in the job summary makes the gate visible as a routine part of the review, and makes an unexpected zero-line result (the shallow-checkout failure mode) obvious rather than silent.",[246,687,689,758],{"className":688},[249],[251,690,259,695,259,698,259,701,259,704,259,706,259,710,259,714,259,718,259,723,259,726,259,728,259,732,259,735,259,738,259,740,259,743,259,746,259,749,259,751,259,755],{"viewBox":691,"role":254,"ariaLabelledBy":692,"xmlns":258},"0 0 760 320",[693,694],"diffreport-t","diffreport-d",[261,696,697],{"id":693},"What the report should contain",[265,699,700],{"id":694},"A stack of four elements a useful diff-coverage report contains: the changed-line count, the uncovered lines with file and line numbers, a link back to the source, and the pass or fail verdict, each with the reason it belongs there.",[287,702],{"x":161,"y":161,"width":289,"height":703,"rx":291,"fill":292},"320",[294,705,697],{"x":296,"y":297,"textAnchor":298,"fontSize":299,"fontWeight":300,"fill":301},[287,707],{"x":297,"y":708,"width":300,"height":460,"rx":463,"fill":309,"stroke":709,"strokeWidth":310},"56","#e07a5f",[287,711],{"x":297,"y":708,"width":712,"height":460,"rx":713,"fill":709},"8","4",[294,715,717],{"x":460,"y":716,"fontSize":315,"fontWeight":300,"fill":301},"86","changed line count",[294,719,722],{"x":720,"y":716,"textAnchor":721,"fontSize":319,"fill":301},"714","end","proves the diff was computed at all",[287,724],{"x":297,"y":725,"width":300,"height":460,"rx":463,"fill":292,"stroke":285,"strokeWidth":310},"120",[287,727],{"x":297,"y":725,"width":712,"height":460,"rx":713,"fill":285},[294,729,731],{"x":460,"y":730,"fontSize":315,"fontWeight":300,"fill":301},"150","uncovered file:line list",[294,733,734],{"x":720,"y":730,"textAnchor":721,"fontSize":319,"fill":301},"the only actionable part of the report",[287,736],{"x":297,"y":737,"width":300,"height":460,"rx":463,"fill":309,"stroke":464,"strokeWidth":310},"184",[287,739],{"x":297,"y":737,"width":712,"height":460,"rx":713,"fill":464},[294,741,742],{"x":460,"y":329,"fontSize":315,"fontWeight":300,"fill":301},"links back to the code",[294,744,745],{"x":720,"y":329,"textAnchor":721,"fontSize":319,"fill":301},"removes the friction before the fix",[287,747],{"x":297,"y":748,"width":300,"height":460,"rx":463,"fill":292,"stroke":301,"strokeWidth":310},"248",[287,750],{"x":297,"y":748,"width":712,"height":460,"rx":713,"fill":301},[294,752,754],{"x":460,"y":753,"fontSize":315,"fontWeight":300,"fill":301},"278","verdict and threshold",[294,756,757],{"x":720,"y":753,"textAnchor":721,"fontSize":319,"fill":301},"states the rule being applied",[372,759,760],{},"A report without the first row can be vacuously green; one without the second row cannot be acted on.",[14,762,764],{"id":763},"frequently-asked-questions","Frequently Asked Questions",[10,766,767,770],{},[25,768,769],{},"Why gate on diff coverage instead of total coverage?","\nBecause the total is dominated by code nobody is changing, so it moves too slowly to give feedback and invites tests written to raise a number. Diff coverage asks one answerable question of the author: are the lines you just wrote exercised?",[10,772,773,776],{},[25,774,775],{},"What compare branch should diff-cover use?","\nThe merge base with the target branch, not the tip. Comparing against the tip counts changes other people merged after the branch was cut, which makes the report unstable and blames the wrong author.",[10,778,779,782],{},[25,780,781],{},"How should a pure refactor be handled?","\nIt usually passes unchanged, because moved lines that were covered before are still covered. When a refactor genuinely adds uncovered branches, the gate is correct to fail — the new branches are new behaviour.",[10,784,785,788],{},[25,786,787],{},"Can diff coverage be required at 100%?","\nYes for most projects, provided the exclusion rules are honest. Reserve an explicit override label or a pragma comment for the rare legitimate case, and require a reason in the pull request.",[14,790,792],{"id":791},"related","Related",[19,794,795,802,808,815],{},[22,796,797,801],{},[49,798,800],{"href":799},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002F","Coverage measurement and enforcement"," — branch coverage and the ratchet this gate complements.",[22,803,804,807],{},[49,805,806],{"href":51},"Why pytest-cov reports 0% under xdist"," — the combine problem that makes a diff gate fail on correct code.",[22,809,810,814],{},[49,811,813],{"href":812},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpytest-markers-for-conditional-test-execution\u002F","Pytest markers for conditional test execution"," — keeping the coverage job's test selection honest.",[22,816,817,821],{},[49,818,820],{"href":819},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002F","Optimizing test discovery"," — keeping the gate fast enough to run on every pull request.",[10,823,824,825],{},"← Back to ",[49,826,827],{"href":799},"Coverage Measurement and Enforcement",[829,830,831],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":66,"searchDepth":79,"depth":79,"links":833},[834,835,836,837,838,839,840,841,842],{"id":16,"depth":79,"text":17},{"id":55,"depth":79,"text":56},{"id":377,"depth":79,"text":378},{"id":384,"depth":79,"text":385},{"id":544,"depth":79,"text":545},{"id":607,"depth":79,"text":608},{"id":653,"depth":79,"text":654},{"id":763,"depth":79,"text":764},{"id":791,"depth":79,"text":792},"Gate pull requests on diff coverage instead of a total percentage: diff-cover setup, choosing the compare branch, handling refactors, and reporting that reviewers read.","md",{"slug":846,"type":847,"breadcrumb":848,"datePublished":849,"dateModified":849,"faq":850,"howto":859},"enforcing-diff-coverage-on-pull-requests","article","Diff Coverage","2026-08-01",[851,853,855,857],{"q":769,"a":852},"Because the total is dominated by code nobody is changing, so it moves too slowly to give feedback and invites tests written to raise a number. Diff coverage asks one answerable question of the author: are the lines you just wrote exercised?",{"q":775,"a":854},"The merge base with the target branch, not the tip. Comparing against the tip counts changes other people merged after the branch was cut, which makes the report unstable and blames the wrong author.",{"q":781,"a":856},"It usually passes unchanged, because moved lines that were covered before are still covered. When a refactor genuinely adds uncovered branches, the gate is correct to fail — the new branches are new behaviour.",{"q":787,"a":858},"Yes for most projects, provided the exclusion rules are honest. Reserve an explicit override label or a pragma comment for the rare legitimate case, and require a reason in the pull request.",{"name":860,"description":861,"steps":862},"How to enforce diff coverage on pull requests","Produce a coverage report, compare it against the diff, and fail the build when new lines are untested.",[863,866,869,872],{"name":864,"text":865},"Produce an XML coverage report","Run the suite with pytest --cov --cov-report=xml so the data is in a machine-readable form.",{"name":867,"text":868},"Fetch enough history for a merge base","Ensure CI checks out with sufficient depth that the merge base with the target branch exists locally.",{"name":870,"text":871},"Run diff-cover against the merge base","Compare the report to the diff and fail below the threshold.",{"name":873,"text":874},"Publish the annotated report","Post the uncovered lines as a comment or artefact so the reviewer sees them without opening logs.",true,"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fenforcing-diff-coverage-on-pull-requests",{"title":5,"description":843},"advanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fenforcing-diff-coverage-on-pull-requests\u002Findex","Cpxsz-4DlYCF4JjqMJvpHJnUh2xsTAbv-3L3iDtyOtg",1785613404390]