Repository: Bachmann1234/diff_cover

Issue:
UnicodeDecodeError when an untracked binary file is present in the repository
**Problem description**
I have discovered a bug in the diff-cover library. When an untracked binary file is present in the repository, the library fails to read it and throws the following error:
```
  File "/Users/user/PycharmProjects/some-project/.direnv/python-3.12.3/lib/python3.12/site-packages/diff_cover/diff_reporter.py", line 172, in src_paths_changed
    num_lines = len(file_handle.readlines())
                    ^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen codecs>", line 322, in decode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb3 in position 0: invalid start byte
```

**Steps to Reproduce:**
1. Create a new repository or use an existing one.
2. Add a binary file to the repository that is not tracked by the version control system (untracked file).
3. Run diff-cover on this repository with `--include-untracked` flag

**Environment:**
diff-cover version: 6.5.1, 9.1.0
Python version: 3.12
Operating System: mac os [Sonomna 14.5]

**Additional Information:**
The problem is likely related to attempting to read all untracked.  It might be helpful to use the mechanism from 
```python
self._git_diff()
```
 that checks 
 ```python
 self._is_path_excluded(src_path) 
 ```
 and:
 ```python
 if (
    not self._supported_extensions
    or extension in self._supported_extensions
):
 ```

This will ensure that only supported file types are read, preventing the UnicodeDecodeError when encountering binary files.

Discussion:
MihailPereverza: I've implemented a function _get_file_lines that emulates the behavior of self._git_diff(), which returns an empty list for binary files. Additionally, the function leverages the basic path validation mechanisms used in self._git_diff() to ensure consistency.
MihailPereverza: Yes, it's ready. I have added the tests and ran the linters using the script `poetry run bash -x verify.sh`.
Bachmann1234: Alright, lets get this out there. Thanks for the PR!

Patch:
diff --git a/diff_cover/diff_reporter.py b/diff_cover/diff_reporter.py
index a9404d4..8f404b9 100644
--- a/diff_cover/diff_reporter.py
+++ b/diff_cover/diff_reporter.py
@@ -165,18 +165,31 @@ class GitDiffReporter(BaseDiffReporter):
 
         # Get the diff dictionary
         diff_dict = self._git_diff()
-
         # include untracked files
         if self._include_untracked:
             for path in self._git_diff_tool.untracked():
-                with open(path) as file_handle:
-                    num_lines = len(file_handle.readlines())
+                if not self._validate_path_to_diff(path):
+                    continue
+
+                num_lines = self._get_file_lines(path)
                 diff_dict[path] = list(range(1, num_lines + 1))
 
         # Return the changed file paths (dict keys)
         # in alphabetical order
         return sorted(diff_dict.keys(), key=lambda x: x.lower())
 
+    @staticmethod
+    def _get_file_lines(path):
+        """
+        Return the number of lines in a file.
+        """
+
+        try:
+            with open(path, encoding="utf-8") as file_handle:
+                return len(file_handle.readlines())
+        except UnicodeDecodeError:
+            return 0
+
     def lines_changed(self, src_path):
         """
         See base class docstring.
@@ -224,23 +237,16 @@ class GitDiffReporter(BaseDiffReporter):
                 diff_dict = self._parse_diff_str(diff_str)
 
                 for src_path, (added_lines, deleted_lines) in diff_dict.items():
-                    if self._is_path_excluded(src_path):
+                    if not self._validate_path_to_diff(src_path):
                         continue
-                    # If no _supported_extensions provided, or extension present: process
-                    _, extension = os.path.splitext(src_path)
-                    extension = extension[1:].lower()
-                    # 'not self._supported_extensions' tests for both None and empty list []
-                    if (
-                        not self._supported_extensions
-                        or extension in self._supported_extensions
-                    ):
-                        # Remove any lines from the dict that have been deleted
-                        # Include any lines that have been added
-                        result_dict[src_path] = [
-                            line
-                            for line in result_dict.get(src_path, [])
-                            if line not in deleted_lines
-                        ] + added_lines
+
+                    # Remove any lines from the dict that have been deleted
+                    # Include any lines that have been added
+                    result_dict[src_path] = [
+                        line
+                        for line in result_dict.get(src_path, [])
+                        if line not in deleted_lines
+                    ] + added_lines
 
             # Eliminate repeats and order line numbers
             for src_path, lines in result_dict.items():
@@ -252,6 +258,29 @@ class GitDiffReporter(BaseDiffReporter):
         # Return the diff cache
         return self._diff_dict
 
+    def _validate_path_to_diff(self, src_path: str) -> bool:
+        """
+        Validate if a path should be included in the diff.
+
+        Returns True if the path should be included, otherwise False.
+
+        A path should be excluded if:
+        - If the path is excluded
+        - If the path has an extension that is not supported
+        """
+
+        if self._is_path_excluded(src_path):
+            return False
+
+        # If no _supported_extensions provided, or extension present: process
+        _, extension = os.path.splitext(src_path)
+        extension = extension[1:].lower()
+
+        if self._supported_extensions and extension not in self._supported_extensions:
+            return False
+
+        return True
+
     # Regular expressions used to parse the diff output
     SRC_FILE_RE = re.compile(r'^diff --git "?a/.*"? "?b/([^\n"]*)"?')
     MERGE_CONFLICT_RE = re.compile(r"^diff --cc ([^\n]*)")


Test patch:
diff --git a/tests/test_diff_reporter.py b/tests/test_diff_reporter.py
index 8a0eea5..0d61b4e 100644
--- a/tests/test_diff_reporter.py
+++ b/tests/test_diff_reporter.py
@@ -618,16 +618,63 @@ def test_include_untracked(mocker, git_diff):
         {"subdir/file1.py": line_numbers(3, 10) + line_numbers(34, 47)}
     )
     _set_git_diff_output(
-        reporter, git_diff, staged_diff=diff_output, untracked=["u1.py", " u2.py"]
+        reporter,
+        git_diff,
+        staged_diff=diff_output,
+        untracked=["u1.py", " u2.py", "binary1.bin"],
     )
 
-    open_mock = mocker.mock_open(read_data="1\n2\n3\n")
-    mocker.patch("diff_cover.diff_reporter.open", open_mock)
+    base_open_mock = mocker.mock_open(read_data="1\n2\n3\n")
+    raise_count = 0
+
+    def open_side_effect(*args, **kwargs):
+        if args[0] == "binary1.bin":
+            nonlocal raise_count
+            raise_count += 1
+
+            raise UnicodeDecodeError("utf-8", b"", 0, 1, "invalid start byte")
+        return base_open_mock(*args, **kwargs)
+
+    mocker.patch("diff_cover.diff_reporter.open", open_side_effect)
     changed = reporter.src_paths_changed()
 
-    assert sorted(changed) == [" u2.py", "subdir/file1.py", "u1.py"]
+    assert sorted(changed) == [" u2.py", "binary1.bin", "subdir/file1.py", "u1.py"]
     assert reporter.lines_changed("u1.py") == [1, 2, 3]
     assert reporter.lines_changed(" u2.py") == [1, 2, 3]
+    assert reporter.lines_changed("binary1.bin") == []
+
+    assert raise_count == 1
+
+
+@pytest.mark.parametrize(
+    "excluded, supported_extensions, path",
+    [
+        (["file.bin"], ["py"], "file.bin"),
+        ([], ["py"], "file.bin"),
+    ],
+)
+def test_include_untracked__not_valid_path__not_include_it(
+    git_diff, excluded, supported_extensions, path
+):
+    reporter = GitDiffReporter(
+        git_diff=git_diff,
+        include_untracked=True,
+        supported_extensions=supported_extensions,
+        exclude=excluded,
+    )
+    diff_output = git_diff_output(
+        {"subdir/file1.py": line_numbers(3, 10) + line_numbers(34, 47)}
+    )
+    _set_git_diff_output(
+        reporter,
+        git_diff,
+        staged_diff=diff_output,
+        untracked=[path],
+    )
+
+    changed = reporter.src_paths_changed()
+
+    assert sorted(changed) == ["subdir/file1.py"]
 
 
 def _set_git_diff_output(