Repository: py-cov-action/python-coverage-comment-action
Pull request #257 (2023-09-10 17:17:16)

Remove diff cover
This PR removes diff-cover from the codebase:
- Computes the list of added lines from `git diff`
- Creates a DiffCoverage object from a Coverage object, restricting lines to the ones added
- Recomputes all derived metrics from the base ones

The nice thing is that the tests barely need to change.

We bump the docker image version number in the process, but I believe the old action will run on the new image, and vice versa (thanks to the `pip install .` that we left in `Dockerfile`) so I don't think we need to do anything specific for this to work.

github-actions[bot]:   [End-to-end public repo](https://github.com/mihcaojwe/python-coverage-comment-action-end-to-end-257-public)

ewjoachim: Ha of course.
The CI of e2e fail because:
> ERROR: failed to solve: ewjoachim/python-coverage-comment-action-base:v4: docker.io/ewjoachim/python-coverage-comment-action-base:v4: not found
ewjoachim: ```
subprocess.CalledProcessError: Command '('git', 'diff', '--unified=0', 'origin/main', '--', '.')' returned non-zero exit status 128.
fatal: bad revision 'origin/main'
```
Hmm... Is it a problem of a missing fetch ? But why didn't we have that with diff cover ? Time to explore...
ewjoachim: https://github.com/py-cov-action/python-coverage-comment-action/pull/257/files#diff-5327f4a45499e33cab8758f61e6b738fc200a07b00c58fc231f37a3142461ac7L215 That's the reason.
ewjoachim: Ow damn you already have a branch for doing that :( https://github.com/py-cov-action/python-coverage-comment-action/tree/replace-diff-cover

Sorry about the duplicated work !
kieferro: > Sorry about the duplicated work !

It's of course not ideal, but no big problem, I had neglected the branch a bit anyway, because we had more important things at the time and and then I didn't deal with it. But the fact that I had already thought about it makes the review process easier for me.

How do we want to proceed, do you want to get the CI passing first or should I review it now?

ewjoachim: I think the only thing needed is for me to push the missing git fetch. Let's say you're welcome to review once it's green :)
github-actions[bot]: ## Coverage report
The coverage rate went from `100%` to `100%` :arrow_right:
The branch rate is `100%`.

`100%` of new lines are covered.

<details>
<summary>Diff Coverage details (click to unfold)</summary>

### coverage_comment/main.py
`100%` of new lines are covered (`100%` of the complete file).

### coverage_comment/coverage.py
`100%` of new lines are covered (`100%` of the complete file).

</details>
<!-- This comment was produced by python-coverage-comment-action -->
ewjoachim: All green, rebased conflicts resolved, all yours :)

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7dc25dc..a65c67e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,7 +26,7 @@ jobs:
         uses: docker/build-push-action@v5
         with:
           # See Dockerfile.build for instructions on bumping this.
-          tags: ewjoachim/python-coverage-comment-action-base:v3
+          tags: ewjoachim/python-coverage-comment-action-base:v4
           push: true
           file: Dockerfile.build
 
diff --git a/Dockerfile b/Dockerfile
index 3dcf75a..5285683 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # See Dockerfile.build for instructions on bumping this.
-FROM ewjoachim/python-coverage-comment-action-base:v3
+FROM ewjoachim/python-coverage-comment-action-base:v4
 
 COPY coverage_comment ./coverage_comment
 RUN pip install .
diff --git a/Dockerfile.build b/Dockerfile.build
index e4ba676..91fbeac 100644
--- a/Dockerfile.build
+++ b/Dockerfile.build
@@ -1,6 +1,6 @@
 # If you change anything here, bump the version in:
 # - Dockerfile
-# - .github/workflows/docker.yml
+# - .github/workflows/release.yml
 
 FROM python:3.11-slim
 
diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py
index 47a8b65..87c4dca 100644
--- a/coverage_comment/coverage.py
+++ b/coverage_comment/coverage.py
@@ -3,7 +3,7 @@ import datetime
 import decimal
 import json
 import pathlib
-import tempfile
+from collections.abc import Iterable
 
 from coverage_comment import log, subprocess
 
@@ -45,6 +45,12 @@ class Coverage:
     files: dict[pathlib.Path, FileCoverage]
 
 
+# The format for Diff Coverage objects may seem a little weird, because it
+# was originally copied from diff-cover schema. In order to keep the
+# compatibility for existing custom template, we kept the same format.
+# Maybe in v4, we can change it to a simpler format.
+
+
 @dataclasses.dataclass
 class FileDiffCoverage:
     path: pathlib.Path
@@ -211,57 +217,90 @@ def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage:
     )
 
 
-def get_diff_coverage_info(base_ref: str, coverage_path: pathlib.Path) -> DiffCoverage:
-    subprocess.run("git", "fetch", "--depth=1000", path=pathlib.Path.cwd())
-    subprocess.run("coverage", "xml", path=coverage_path)
-    with tempfile.NamedTemporaryFile("r") as f:
-        subprocess.run(
-            "diff-cover",
-            "coverage.xml",
-            f"--compare-branch=origin/{base_ref}",
-            f"--json-report={f.name}",
-            "--diff-range-notation=..",
-            "--quiet",
-            path=coverage_path,
+def get_diff_coverage_info(
+    added_lines: dict[pathlib.Path, list[int]], coverage: Coverage
+) -> DiffCoverage:
+    files = {}
+    total_num_lines = 0
+    total_num_violations = 0
+    num_changed_lines = 0
+
+    for path, added_lines_for_file in added_lines.items():
+        num_changed_lines += len(added_lines_for_file)
+
+        try:
+            file = coverage.files[path]
+        except KeyError:
+            continue
+
+        executed = set(file.executed_lines) & set(added_lines_for_file)
+        count_executed = len(executed)
+
+        missing = set(file.missing_lines) & set(added_lines_for_file)
+        count_missing = len(missing)
+        # Even partially covered lines are considered as covered, no line
+        # appears in both counts
+        count_total = count_executed + count_missing
+
+        total_num_lines += count_total
+        total_num_violations += count_missing
+
+        percent_covered = compute_coverage(
+            num_covered=count_executed, num_total=count_total
         )
-        diff_json = json.loads(pathlib.Path(f.name).read_text())
 
-    return extract_diff_info(diff_json)
+        files[path] = FileDiffCoverage(
+            path=path,
+            percent_covered=percent_covered,
+            violation_lines=sorted(missing),
+        )
+    final_percentage = compute_coverage(
+        num_covered=total_num_lines - total_num_violations,
+        num_total=total_num_lines,
+    )
+
+    return DiffCoverage(
+        total_num_lines=total_num_lines,
+        total_num_violations=total_num_violations,
+        total_percent_covered=final_percentage,
+        num_changed_lines=num_changed_lines,
+        files=files,
+    )
+
+
+def get_added_lines(
+    git: subprocess.Git, base_ref: str
+) -> dict[pathlib.Path, list[int]]:
+    # --unified=0 means we don't get any context lines for chunk, and we
+    # don't merge chunks. This means the headers that describe line number
+    # are always enough to derive what line numbers were added.
+    git.fetch("origin", base_ref, "--depth=1000")
+    diff = git.diff("--unified=0", "FETCH_HEAD", "--", ".")
+    return parse_diff_output(diff)
 
 
-def extract_diff_info(data) -> DiffCoverage:
+def parse_diff_output(diff: str) -> dict[pathlib.Path, list[int]]:
+    current_file: pathlib.Path | None = None
+    added_filename_prefix = "+++ b/"
+    result: dict[pathlib.Path, list[int]] = {}
+    for line in diff.splitlines():
+        if line.startswith(added_filename_prefix):
+            current_file = pathlib.Path(line.removeprefix(added_filename_prefix))
+            continue
+        if line.startswith("@@"):
+            assert current_file
+            lines = parse_line_number_diff_line(line)
+            result.setdefault(current_file, []).extend(lines)
+            continue
+
+    return result
+
+
+def parse_line_number_diff_line(line: str) -> Iterable[int]:
     """
-    {
-        "report_name": "XML",
-        "diff_name": "master...HEAD, staged and unstaged changes",
-        "src_stats": {
-            "codebase/code.py": {
-                "percent_covered": 80.0,
-                "violation_lines": [9],
-                "violations": [[9, null]],
-            }
-        },
-        "total_num_lines": 5,
-        "total_num_violations": 1,
-        "total_percent_covered": 80,
-        "num_changed_lines": 39,
-    }
+    Parse the "added" part of the line number diff text:
+        @@ -60,0 +61 @@ def compute_files(  -> [61]
+        @@ -60,0 +61,3 @@ def compute_files(  -> [61, 62, 63]
     """
-    return DiffCoverage(
-        total_num_lines=data["total_num_lines"],
-        total_num_violations=data["total_num_violations"],
-        total_percent_covered=compute_coverage(
-            data["total_num_lines"] - data["total_num_violations"],
-            data["total_num_lines"],
-        ),
-        num_changed_lines=data["num_changed_lines"],
-        files={
-            pathlib.Path(path): FileDiffCoverage(
-                path=pathlib.Path(path),
-                percent_covered=decimal.Decimal(str(file_data["percent_covered"]))
-                / decimal.Decimal("100"),
-                violation_lines=file_data["violation_lines"],
-            )
-            for path, file_data in data["src_stats"].items()
-        },
-    )
+    start, length = (int(i) for i in (line.split()[2][1:] + ",1").split(",")[:2])
+    return range(start, start + length)
diff --git a/coverage_comment/main.py b/coverage_comment/main.py
index a192895..ac3ca17 100644
--- a/coverage_comment/main.py
+++ b/coverage_comment/main.py
@@ -93,6 +93,7 @@ def action(
             config=config,
             gh=gh,
             repo_info=repo_info,
+            git=git,
         )
 
     else:
@@ -107,6 +108,7 @@ def process_pr(
     config: settings.Config,
     gh: github_client.GitHub,
     repo_info: github.RepositoryInfo,
+    git: subprocess.Git,
 ) -> int:
     log.info("Generating comment for PR")
 
@@ -115,10 +117,11 @@ def process_pr(
         coverage_path=config.COVERAGE_PATH,
     )
     base_ref = config.GITHUB_BASE_REF or repo_info.default_branch
+
+    added_lines = coverage_module.get_added_lines(git=git, base_ref=base_ref)
     diff_coverage = coverage_module.get_diff_coverage_info(
-        base_ref=base_ref, coverage_path=config.COVERAGE_PATH
+        coverage=coverage, added_lines=added_lines
     )
-
     # It only really makes sense to display a comparison with the previous
     # coverage if the PR target is the branch in which the coverage data is
     # stored, e.g. the default branch.
diff --git a/coverage_comment/subprocess.py b/coverage_comment/subprocess.py
index 1d2ba81..94eafec 100644
--- a/coverage_comment/subprocess.py
+++ b/coverage_comment/subprocess.py
@@ -48,7 +48,7 @@ class Git:
         # When setting the `env` argument to run, instead of inheriting env
         # vars from the current process, the whole environment of the
         # subprocess is whatever we pass. In other words, we can either
-        # conditionnaly pass an `env` parameter, but it's less readable,
+        # conditionally pass an `env` parameter, but it's less readable,
         # or we can always pass an `env` parameter, but in this case, we
         # need to always merge `os.environ` to it (and ensure our variables
         # have precedence)
diff --git a/poetry.lock b/poetry.lock
index c4a0615..10fe985 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -75,17 +75,6 @@ files = [
     {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
 ]
 
-[[package]]
-name = "chardet"
-version = "5.2.0"
-description = "Universal encoding detector for Python 3"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
-    {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
-]
-
 [[package]]
 name = "click"
 version = "8.1.7"
@@ -175,26 +164,6 @@ files = [
 [package.extras]
 toml = ["tomli"]
 
-[[package]]
-name = "diff-cover"
-version = "7.7.0"
-description = "Run coverage and linting reports on diffs"
-optional = false
-python-versions = ">=3.7.2,<4.0.0"
-files = [
-    {file = "diff_cover-7.7.0-py3-none-any.whl", hash = "sha256:bf86f32ec999f9a9e79bf24969f7127ea7b4e55c3ef3cd9300feb13188c89736"},
-    {file = "diff_cover-7.7.0.tar.gz", hash = "sha256:60614cf7e722cf7fb1bde497afac0b514294e1e26534449622dac4da296123fb"},
-]
-
-[package.dependencies]
-chardet = ">=3.0.0"
-Jinja2 = ">=2.7.1"
-pluggy = ">=0.13.1,<2"
-Pygments = ">=2.9.0,<3.0.0"
-
-[package.extras]
-toml = ["tomli (>=1.2.1)"]
-
 [[package]]
 name = "flake8"
 version = "6.1.0"
@@ -387,16 +356,6 @@ files = [
     {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
     {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
     {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
-    {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
     {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
     {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
     {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
@@ -570,20 +529,6 @@ files = [
     {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"},
 ]
 
-[[package]]
-name = "pygments"
-version = "2.16.1"
-description = "Pygments is a syntax highlighting package written in Python."
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"},
-    {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"},
-]
-
-[package.extras]
-plugins = ["importlib-metadata"]
-
 [[package]]
 name = "pytest"
 version = "7.4.2"
@@ -678,4 +623,4 @@ files = [
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "2b0320a2be9cf2c4e6194be199a89e96d92f5c3c930e42b003c583eb6f4b6a00"
+content-hash = "e2d7b0a1cd3ffd18dd10635b0bafb56547aabb41ca0d3a3e7ff816114261dd3b"
diff --git a/pyproject.toml b/pyproject.toml
index 8513903..2ce67dc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,6 @@ coverage_comment = 'coverage_comment.main:main'
 [tool.poetry.dependencies]
 python = "^3.11"
 coverage = { version = "*", extras = ["toml"] }
-diff-cover = "*"
 httpx = { version = "*", extras = ["http2"] }
 Jinja2 = "*"
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 4eee0c1..a8e4d94 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -113,25 +113,6 @@ def coverage_json():
     }
 
 
-@pytest.fixture
-def diff_coverage_json():
-    return {
-        "report_name": "XML",
-        "diff_name": "master...HEAD, staged and unstaged changes",
-        "src_stats": {
-            "codebase/code.py": {
-                "percent_covered": 80.0,
-                "violation_lines": [9],
-                "violations": [[9, None]],
-            }
-        },
-        "total_num_lines": 5,
-        "total_num_violations": 1,
-        "total_percent_covered": 80,
-        "num_changed_lines": 39,
-    }
-
-
 @pytest.fixture
 def coverage_obj():
     return coverage_module.Coverage(
@@ -203,7 +184,7 @@ def coverage_obj_no_branch():
                 info=coverage_module.CoverageInfo(
                     covered_lines=5,
                     num_statements=6,
-                    percent_covered=decimal.Decimal("0.75"),
+                    percent_covered=decimal.Decimal("0.8333"),
                     missing_lines=1,
                     excluded_lines=0,
                     num_branches=None,
@@ -216,6 +197,41 @@ def coverage_obj_no_branch():
     )
 
 
+@pytest.fixture
+def coverage_obj_more_files(coverage_obj_no_branch):
+    coverage_obj_no_branch.files[
+        pathlib.Path("codebase/other.py")
+    ] = coverage_module.FileCoverage(
+        path=pathlib.Path("codebase/other.py"),
+        executed_lines=[10, 11, 12],
+        missing_lines=[13],
+        excluded_lines=[],
+        info=coverage_module.CoverageInfo(
+            covered_lines=3,
+            num_statements=4,
+            percent_covered=decimal.Decimal("0.75"),
+            missing_lines=1,
+            excluded_lines=0,
+            num_branches=None,
+            num_partial_branches=None,
+            covered_branches=None,
+            missing_branches=None,
+        ),
+    )
+    return coverage_obj_no_branch
+
+
+@pytest.fixture
+def make_coverage_obj(coverage_obj_more_files):
+    def f(**kwargs):
+        obj = coverage_obj_more_files
+        for key, value in kwargs.items():
+            vars(obj.files[pathlib.Path(key)]).update(value)
+        return obj
+
+    return f
+
+
 @pytest.fixture
 def diff_coverage_obj():
     return coverage_module.DiffCoverage(
@@ -371,7 +387,7 @@ def git(is_failed):
     You get a git object. Register calls on it:
         git.register("git checkout master")(exit_code=1)
     or
-        session.register("git commit", env={"A": "B"})(stdout="Changed branch")
+        git.register("git commit", env={"A": "B"})(stdout="Changed branch")
 
     If the command was not received by the end of the test, it will raise.
     """
diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py
index 123d569..20811c8 100644
--- a/tests/integration/test_main.py
+++ b/tests/integration/test_main.py
@@ -53,6 +53,20 @@ def run_coverage(file_path, integration_dir):
     return _
 
 
+DIFF_STDOUT = """diff --git a/foo.py b/foo.py
+index 6c08c94..b65c612 100644
+--- a/foo.py
++++ b/foo.py
+@@ -6,0 +7,6 @@ if os.environ.get("B"):
++
++if os.environ.get("C"):
++    2
++
++if os.environ.get("D"):
++    3
+"""
+
+
 @pytest.fixture
 def commit(integration_dir):
     def _():
@@ -120,7 +134,13 @@ def test_action__invalid_event_name(session, push_config, in_integration_env, ge
 
 
 def test_action__pull_request__store_comment(
-    pull_request_config, session, in_integration_env, output_file, summary_file, capsys
+    pull_request_config,
+    session,
+    in_integration_env,
+    output_file,
+    summary_file,
+    capsys,
+    git,
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
@@ -150,13 +170,16 @@ def test_action__pull_request__store_comment(
         "POST", "/repos/py-cov-action/foobar/issues/2/comments", json=checker
     )(status_code=403)
 
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
+
     result = main.action(
         config=pull_request_config(
             GITHUB_OUTPUT=output_file, GITHUB_STEP_SUMMARY=summary_file
         ),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 0
 
@@ -186,7 +209,13 @@ def test_action__pull_request__store_comment(
 
 @pytest.mark.add_branches("foo")
 def test_action__pull_request__store_comment_not_targeting_default(
-    pull_request_config, session, in_integration_env, output_file, summary_file, capsys
+    pull_request_config,
+    session,
+    in_integration_env,
+    output_file,
+    summary_file,
+    capsys,
+    git,
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
@@ -217,6 +246,9 @@ def test_action__pull_request__store_comment_not_targeting_default(
         "POST", "/repos/py-cov-action/foobar/issues/2/comments", json=checker
     )(status_code=403)
 
+    git.register("git fetch origin foo --depth=1000")(stdout=DIFF_STDOUT)
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
+
     result = main.action(
         config=pull_request_config(
             GITHUB_OUTPUT=output_file,
@@ -225,7 +257,7 @@ def test_action__pull_request__store_comment_not_targeting_default(
         ),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 0
 
@@ -240,7 +272,7 @@ def test_action__pull_request__store_comment_not_targeting_default(
 
 
 def test_action__pull_request__post_comment(
-    pull_request_config, session, in_integration_env, output_file, summary_file
+    pull_request_config, session, in_integration_env, output_file, summary_file, git
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
@@ -258,6 +290,9 @@ def test_action__pull_request__post_comment(
     # Are there already comments
     session.register("GET", "/repos/py-cov-action/foobar/issues/2/comments")(json=[])
 
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
+
     comment = None
 
     def checker(payload):
@@ -282,7 +317,7 @@ def test_action__pull_request__post_comment(
         ),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 0
 
@@ -296,11 +331,13 @@ def test_action__pull_request__post_comment(
 
 
 def test_action__push__non_default_branch(
-    push_config, session, in_integration_env, output_file, summary_file
+    push_config, session, in_integration_env, output_file, summary_file, git
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
     )
+    git.register("git fetch origin main --depth=1000")(stdout=DIFF_STDOUT)
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
 
     payload = json.dumps({"coverage": 30.00})
     # There is an existing badge in this test, allowing to test the coverage evolution
@@ -350,7 +387,7 @@ def test_action__push__non_default_branch(
         ),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 0
 
@@ -364,11 +401,13 @@ def test_action__push__non_default_branch(
 
 
 def test_action__push__non_default_branch__no_pr(
-    push_config, session, in_integration_env, output_file, summary_file
+    push_config, session, in_integration_env, output_file, summary_file, git
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
     )
+    git.register("git fetch origin main --depth=1000")(stdout=DIFF_STDOUT)
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
 
     payload = json.dumps({"coverage": 30.00})
     # There is an existing badge in this test, allowing to test the coverage evolution
@@ -406,7 +445,7 @@ def test_action__push__non_default_branch__no_pr(
         ),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 0
 
@@ -418,7 +457,7 @@ def test_action__push__non_default_branch__no_pr(
 
 
 def test_action__pull_request__force_store_comment(
-    pull_request_config, session, in_integration_env, output_file
+    pull_request_config, session, in_integration_env, output_file, git
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
@@ -431,11 +470,14 @@ def test_action__pull_request__force_store_comment(
         "/repos/py-cov-action/foobar/contents/data.json",
     )(json={"content": base64.b64encode(payload.encode()).decode()})
 
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
+
     result = main.action(
         config=pull_request_config(FORCE_WORKFLOW_RUN=True, GITHUB_OUTPUT=output_file),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 0
 
@@ -447,7 +489,7 @@ def test_action__pull_request__force_store_comment(
 
 
 def test_action__pull_request__post_comment__no_marker(
-    pull_request_config, session, in_integration_env, get_logs
+    pull_request_config, session, in_integration_env, get_logs, git
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
@@ -459,18 +501,21 @@ def test_action__pull_request__post_comment__no_marker(
         "/repos/py-cov-action/foobar/contents/data.json",
     )(status_code=404)
 
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
+
     result = main.action(
         config=pull_request_config(COMMENT_TEMPLATE="""foo"""),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 1
     assert get_logs("ERROR", "Marker not found")
 
 
 def test_action__pull_request__annotations(
-    pull_request_config, session, in_integration_env, capsys
+    pull_request_config, session, in_integration_env, capsys, git
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
@@ -481,6 +526,9 @@ def test_action__pull_request__annotations(
         "/repos/py-cov-action/foobar/contents/data.json",
     )(status_code=404)
 
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
+
     # Who am I
     session.register("GET", "/user")(json={"login": "foo"})
     # Are there already comments
@@ -496,7 +544,7 @@ def test_action__pull_request__annotations(
         config=pull_request_config(ANNOTATE_MISSING_LINES=True),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     expected = """::group::Annotations of lines with missing coverage
 ::warning file=foo.py,line=12::This line has no coverage
@@ -508,7 +556,7 @@ def test_action__pull_request__annotations(
 
 
 def test_action__pull_request__post_comment__template_error(
-    pull_request_config, session, in_integration_env, get_logs
+    pull_request_config, session, in_integration_env, get_logs, git
 ):
     session.register("GET", "/repos/py-cov-action/foobar")(
         json={"default_branch": "main", "visibility": "public"}
@@ -520,11 +568,14 @@ def test_action__pull_request__post_comment__template_error(
         "/repos/py-cov-action/foobar/contents/data.json",
     )(status_code=404)
 
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=DIFF_STDOUT)
+
     result = main.action(
         config=pull_request_config(COMMENT_TEMPLATE="""{%"""),
         github_session=session,
         http_session=session,
-        git=None,
+        git=git,
     )
     assert result == 1
     assert get_logs("ERROR", "There was a rendering error")
diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py
index d52cc63..7d30c31 100644
--- a/tests/unit/test_coverage.py
+++ b/tests/unit/test_coverage.py
@@ -114,3 +114,165 @@ def test_generate_coverage_markdown(mocker):
     ]
 
     assert result == "foo"
+
+
+@pytest.mark.parametrize(
+    "added_lines, update_obj, expected",
+    [
+        # A first simple example. We added lines 1 and 3 to a file. Coverage
+        # info says that lines 1 and 2 were executed and line 3 was not.
+        # Diff coverage should report that the violation is line 3 and
+        # that the total coverage is 50%.
+        (
+            {pathlib.Path("codebase/code.py"): [1, 3]},
+            {"codebase/code.py": {"executed_lines": [1, 2], "missing_lines": [3]}},
+            coverage.DiffCoverage(
+                total_num_lines=2,
+                total_num_violations=1,
+                total_percent_covered=decimal.Decimal("0.5"),
+                num_changed_lines=2,
+                files={
+                    pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
+                        path=pathlib.Path("codebase/code.py"),
+                        percent_covered=decimal.Decimal("0.5"),
+                        violation_lines=[3],
+                    )
+                },
+            ),
+        ),
+        # A second simple example. This time, the only modified file (code2.py)
+        # is not the same as the files that received coverage info (code.py).
+        # Consequently, no line should be reported as a violation (we could
+        # imagine that the file code2.py only contains comments and is not
+        # covered, nor imported.)
+        (
+            {pathlib.Path("codebase/code2.py"): [1, 3]},
+            {"codebase/code.py": {"executed_lines": [1, 2], "missing_lines": [3]}},
+            coverage.DiffCoverage(
+                total_num_lines=0,
+                total_num_violations=0,
+                total_percent_covered=decimal.Decimal("1"),
+                num_changed_lines=2,
+                files={},
+            ),
+        ),
+        # A third simple example. This time, there's no intersection between
+        # the modified files and the files that received coverage info. We
+        # should not report any violation (and 100% coverage)
+        (
+            {pathlib.Path("codebase/code.py"): [4, 5, 6]},
+            {"codebase/code.py": {"executed_lines": [1, 2, 3], "missing_lines": [7]}},
+            coverage.DiffCoverage(
+                total_num_lines=0,
+                total_num_violations=0,
+                total_percent_covered=decimal.Decimal("1"),
+                num_changed_lines=3,
+                files={
+                    pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
+                        path=pathlib.Path("codebase/code.py"),
+                        percent_covered=decimal.Decimal("1"),
+                        violation_lines=[],
+                    )
+                },
+            ),
+        ),
+        # A more complex example with 2 distinct files. We want to check both
+        # that they are individually handled correctly and that the general
+        # stats are correct.
+        (
+            {
+                pathlib.Path("codebase/code.py"): [4, 5, 6],
+                pathlib.Path("codebase/other.py"): [10, 13],
+            },
+            {
+                "codebase/code.py": {
+                    "executed_lines": [1, 2, 3, 5, 6],
+                    "missing_lines": [7],
+                },
+                "codebase/other.py": {
+                    "executed_lines": [10, 11, 12],
+                    "missing_lines": [13],
+                },
+            },
+            coverage.DiffCoverage(
+                total_num_lines=4,  # 2 lines in code.py + 2 lines in other.py
+                total_num_violations=1,  # 1 line in other.py
+                total_percent_covered=decimal.Decimal("0.75"),  # 3/4 lines covered
+                num_changed_lines=5,  # 3 lines in code.py + 2 lines in other.py
+                files={
+                    pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
+                        path=pathlib.Path("codebase/code.py"),
+                        percent_covered=decimal.Decimal("1"),
+                        violation_lines=[],
+                    ),
+                    pathlib.Path("codebase/other.py"): coverage.FileDiffCoverage(
+                        path=pathlib.Path("codebase/other.py"),
+                        percent_covered=decimal.Decimal("0.5"),
+                        violation_lines=[13],
+                    ),
+                },
+            ),
+        ),
+    ],
+)
+def test_get_diff_coverage_info(make_coverage_obj, added_lines, update_obj, expected):
+    result = coverage.get_diff_coverage_info(
+        added_lines=added_lines, coverage=make_coverage_obj(**update_obj)
+    )
+    assert result == expected
+
+
+def test_get_added_lines(git):
+    diff = (
+        """+++ b/README.md\n@@ -1,2 +1,3 @@\n-# coverage-comment\n+coverage-comment\n"""
+    )
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=diff)
+    assert coverage.get_added_lines(git=git, base_ref="main") == {
+        pathlib.Path("README.md"): [1, 2, 3]
+    }
+
+
+@pytest.mark.parametrize(
+    "line_number_diff_line, expected",
+    [
+        ("@@ -1,2 +7,4 @@ foo()", [7, 8, 9, 10]),
+        ("@@ -1,2 +8 @@ foo()", [8]),
+    ],
+)
+def test_parse_line_number_diff_line(git, line_number_diff_line, expected):
+    result = list(coverage.parse_line_number_diff_line(line_number_diff_line))
+    assert result == expected
+
+
+def test_parse_diff_output(git):
+    diff = """diff --git a/README.md b/README.md
+index 1f1d9a4..e69de29 100644
+--- a/README.md
++++ b/README.md
+@@ -1 +1 @@
+-# coverage-comment
+-coverage-comment
+@@ -3,2 +3,4 @@
+-foo
+-bar
++foo1
++bar1
++foo2
++bar2
+--- a/foo.txt
++++ b/foo.txt
+@@ -0,0 +1 @@
++bar
+--- a/bar.txt
++++ b/bar.txt
+@@ -8 +7,0 @@
+-foo
+"""
+    git.register("git fetch origin main --depth=1000")()
+    git.register("git diff --unified=0 FETCH_HEAD -- .")(stdout=diff)
+    assert coverage.parse_diff_output(diff=diff) == {
+        pathlib.Path("README.md"): [1, 3, 4, 5, 6],
+        pathlib.Path("foo.txt"): [1],
+        pathlib.Path("bar.txt"): [],
+    }
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
index 0a8cc64..dc28ebd 100644
--- a/tests/unit/test_template.py
+++ b/tests/unit/test_template.py
@@ -223,7 +223,7 @@ The coverage rate is `75%`.
 <summary>Diff Coverage details (click to unfold)</summary>
 
 ### codebase/code.py
-`80%` of new lines are covered (`75%` of the complete file).
+`80%` of new lines are covered (`83.33%` of the complete file).
 Missing lines: `7`, `9`
 
 </details>