From c400cf5e8c4618d22237eb395db425184f35ecbf Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 4 Sep 2025 08:48:19 +0200 Subject: [PATCH 01/20] Support symlinks in GitFileSystem --- pycobertura/filesystem.py | 77 +++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 42624976..36a1efd4 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -83,54 +83,69 @@ def __init__(self, repo_folder, ref): self.prefix = self.repository.replace(self.repository_root, "").lstrip("/") def real_filename(self, filename): - prefix = "{}/".format(self.prefix) if self.prefix else "" - return "{ref}:{prefix}{filename}".format( - prefix=prefix, ref=self.ref, filename=filename - ) + """ + Constructs the Git path for a given filename. + This method should NOT resolve symlinks on the local disk. + """ + prefix = f"{self.prefix}/" if self.prefix else "" + return f"{self.ref}:{prefix}{filename}" def has_file(self, filename): - command = "git --no-pager show {}".format(self.real_filename(filename)) - return_code = subprocess.call( + """ + Check for a file's existence in the specified commit's tree. + """ + git_path = f"{self.prefix}/{filename}" if self.prefix else filename + command = ["git", "ls-tree", "-r", "--name-only", self.ref, "--", git_path] + return subprocess.call( command, - cwd=self.repository, - shell=True, + cwd=self.repository_root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - return not bool(return_code) + ) == 0 def _get_root_path(self, repository_folder): - command = "git rev-parse --show-toplevel" - command_tokens = shlex.split(command) + command = ["git", "rev-parse", "--show-toplevel"] try: - output = subprocess.check_output(command_tokens, cwd=repository_folder) - except (OSError, subprocess.CalledProcessError): - raise ValueError( - "The folder {} is not " - "a valid git repository.".format(repository_folder) - ) - + output = subprocess.check_output(command, cwd=repository_folder, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + raise ValueError(f"The folder {repository_folder} is not a valid git repository.") return output.decode("utf-8").rstrip() @contextmanager def open(self, filename): """ - Yield a file-like object for file `filename`. - - This function is a context manager. + Yield a file-like object for the given filename, following symlinks if necessary. """ - filename = self.real_filename(filename) - - command = "git --no-pager show {}".format(filename) - command_tokens = shlex.split(command) + git_path = f"{self.prefix}/{filename}" if self.prefix else filename + command = ["git", "cat-file", "--batch", "--follow-symlinks"] + input_data = f"{self.ref}:{git_path}\n".encode() try: - output = subprocess.check_output(command_tokens, cwd=self.repository) - except (OSError, subprocess.CalledProcessError): - raise self.FileNotFound(filename) + process = subprocess.Popen( + command, + cwd=self.repository_root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, error = process.communicate(input=input_data) + return_code = process.wait() + + if return_code != 0: + raise subprocess.CalledProcessError(return_code, command, output=output, stderr=error) + + # Parse the batch output to get content + lines = output.split(b'\n', 1) + # The first line contains object info. If the path doesn't exist, it might contain "missing" or "filtered". + first_line = lines[0].decode() + if "missing" in first_line or "filtered" in first_line: + raise self.FileNotFound(f"File not found in git: {git_path}@{self.ref}") + + content = lines[1] + yield io.StringIO(content.decode("utf-8")) - output = output.decode("utf-8").rstrip() - yield io.StringIO(output) + except (OSError, subprocess.CalledProcessError) as e: + raise self.FileNotFound(f"Could not open file in git: {git_path}@{self.ref}") from e def filesystem_factory(source, source_prefix=None, ref=None): From 4ac33dc8824d2638b5690ea0b1a0695bd7fc6247 Mon Sep 17 00:00:00 2001 From: JWM Date: Tue, 9 Sep 2025 18:00:11 +0200 Subject: [PATCH 02/20] Update and fix unittests --- pycobertura/filesystem.py | 42 +++++++++++++++++++-------------------- tests/test_filesystem.py | 24 +++++++++++++++------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 36a1efd4..4e3a0ece 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -3,7 +3,6 @@ import io import zipfile import subprocess -import shlex from contextlib import contextmanager @@ -95,19 +94,25 @@ def has_file(self, filename): Check for a file's existence in the specified commit's tree. """ git_path = f"{self.prefix}/{filename}" if self.prefix else filename - command = ["git", "ls-tree", "-r", "--name-only", self.ref, "--", git_path] - return subprocess.call( + command = ["git", "cat-file", "--batch-check", "--follow-symlinks", "-Z"] + input_data = f"{self.ref}:{git_path}".encode() + b'\x00' + + process = subprocess.Popen( command, cwd=self.repository_root, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) == 0 + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, error = process.communicate(input=input_data) + assert error == b'' + return not output.endswith(b'missing\x00') def _get_root_path(self, repository_folder): command = ["git", "rev-parse", "--show-toplevel"] try: - output = subprocess.check_output(command, cwd=repository_folder, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError: + output = subprocess.check_output(command, cwd=repository_folder) + except (OSError, subprocess.CalledProcessError): raise ValueError(f"The folder {repository_folder} is not a valid git repository.") return output.decode("utf-8").rstrip() @@ -117,8 +122,8 @@ def open(self, filename): Yield a file-like object for the given filename, following symlinks if necessary. """ git_path = f"{self.prefix}/{filename}" if self.prefix else filename - command = ["git", "cat-file", "--batch", "--follow-symlinks"] - input_data = f"{self.ref}:{git_path}\n".encode() + command = ["git", "cat-file", "--batch", "--follow-symlinks", "-Z"] + input_data = f"{self.ref}:{git_path}".encode() + b'\x00' try: process = subprocess.Popen( @@ -128,24 +133,17 @@ def open(self, filename): stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - output, error = process.communicate(input=input_data) + output, _ = process.communicate(input=input_data) return_code = process.wait() - if return_code != 0: - raise subprocess.CalledProcessError(return_code, command, output=output, stderr=error) - - # Parse the batch output to get content + if return_code != 0 or output.endswith(b'missing\x00'): + raise self.FileNotFound(self.real_filename(filename)) lines = output.split(b'\n', 1) - # The first line contains object info. If the path doesn't exist, it might contain "missing" or "filtered". - first_line = lines[0].decode() - if "missing" in first_line or "filtered" in first_line: - raise self.FileNotFound(f"File not found in git: {git_path}@{self.ref}") - content = lines[1] yield io.StringIO(content.decode("utf-8")) - except (OSError, subprocess.CalledProcessError) as e: - raise self.FileNotFound(f"Could not open file in git: {git_path}@{self.ref}") from e + except (OSError, subprocess.CalledProcessError): + raise self.FileNotFound(self.real_filename(filename)) def filesystem_factory(source, source_prefix=None, ref=None): diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py index 2e9f4db5..cf13514a 100644 --- a/tests/test_filesystem.py +++ b/tests/test_filesystem.py @@ -1,5 +1,6 @@ import subprocess from unittest.mock import patch, MagicMock +import pytest FIRST_PYCOBERTURA_COMMIT_SHA = "d1fe88da6b18340762b24bb1f89067a3439c4041" @@ -121,21 +122,30 @@ def test_filesystem_git(): import pycobertura.filesystem as fsm branch, folder, filename = "master", "tests/dummy", "test-file" + repo_root = "/tmp/repo" with patch.object(fsm, "subprocess") as subprocess_mock: - subprocess_mock.check_output = MagicMock(return_value=b"") + # Mock for _get_root_path + subprocess_mock.check_output.return_value = repo_root.encode('utf-8') + + # Mock for open + mock_process = MagicMock() + mock_process.communicate.return_value = ('some_hash blob 12\n'.encode(), b'') + mock_process.wait.return_value = 0 + subprocess_mock.Popen.return_value = mock_process fs = fsm.GitFileSystem(folder, branch) with fs.open(filename) as f: assert hasattr(f, 'read') - expected_git_filename = "master:tests/dummy/test-file" + expected_git_filename = f"{branch}:{folder}/{filename}" git_filename = fs.real_filename(filename) assert git_filename == expected_git_filename - expected_command = ["git", "--no-pager", "show", git_filename] - subprocess_mock.check_output.assert_called_with(expected_command, cwd=folder) + expected_command = ["git", "cat-file", "--batch", "--follow-symlinks", "-Z"] + subprocess_mock.Popen.assert_called_with(expected_command, cwd=repo_root, stdin=subprocess_mock.PIPE, + stdout=subprocess_mock.PIPE, stderr=subprocess_mock.PIPE) def test_filesystem_git_integration(): @@ -222,11 +232,11 @@ def test_filesystem_git_integration__not_found(): dummy_source_file = "CHANGES.md" - try: + with pytest.raises(GitFileSystem.FileNotFound) as excinfo: with fs.open(dummy_source_file) as f: pass - except GitFileSystem.FileNotFound as fnf: - assert fnf.path == fs.real_filename(dummy_source_file) + + assert excinfo.value.path == fs.real_filename(dummy_source_file) def test_filesystem_git_has_file_integration__not_found(): From fc5d66225de4c535a16e11b6a8390dcf8785e118 Mon Sep 17 00:00:00 2001 From: JWM Date: Wed, 10 Sep 2025 13:48:47 +0200 Subject: [PATCH 03/20] Delete assertion for error --- pycobertura/filesystem.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 4e3a0ece..33a4b3d0 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -105,7 +105,6 @@ def has_file(self, filename): stderr=subprocess.PIPE, ) output, error = process.communicate(input=input_data) - assert error == b'' return not output.endswith(b'missing\x00') def _get_root_path(self, repository_folder): From a8c78e09f88b19d15491305e7cac82c1b179f40c Mon Sep 17 00:00:00 2001 From: JWM Date: Wed, 10 Sep 2025 15:01:09 +0200 Subject: [PATCH 04/20] Use real_filename in has_file and open of GitFileSystem --- pycobertura/filesystem.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 33a4b3d0..22e724be 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -93,9 +93,8 @@ def has_file(self, filename): """ Check for a file's existence in the specified commit's tree. """ - git_path = f"{self.prefix}/{filename}" if self.prefix else filename command = ["git", "cat-file", "--batch-check", "--follow-symlinks", "-Z"] - input_data = f"{self.ref}:{git_path}".encode() + b'\x00' + input_data = self.real_filename(filename).encode() + b'\x00' process = subprocess.Popen( command, @@ -120,9 +119,9 @@ def open(self, filename): """ Yield a file-like object for the given filename, following symlinks if necessary. """ - git_path = f"{self.prefix}/{filename}" if self.prefix else filename command = ["git", "cat-file", "--batch", "--follow-symlinks", "-Z"] - input_data = f"{self.ref}:{git_path}".encode() + b'\x00' + real_filename = self.real_filename(filename) + input_data = real_filename.encode() + b'\x00' try: process = subprocess.Popen( @@ -136,13 +135,13 @@ def open(self, filename): return_code = process.wait() if return_code != 0 or output.endswith(b'missing\x00'): - raise self.FileNotFound(self.real_filename(filename)) + raise self.FileNotFound(real_filename) lines = output.split(b'\n', 1) content = lines[1] yield io.StringIO(content.decode("utf-8")) except (OSError, subprocess.CalledProcessError): - raise self.FileNotFound(self.real_filename(filename)) + raise self.FileNotFound(real_filename) def filesystem_factory(source, source_prefix=None, ref=None): From 33640e448aff30a9bea51d4472eb07bf8cb00b99 Mon Sep 17 00:00:00 2001 From: JWM Date: Wed, 10 Sep 2025 17:57:34 +0200 Subject: [PATCH 05/20] Add some prints for debugging --- pycobertura/filesystem.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 22e724be..e3b3659d 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -104,6 +104,8 @@ def has_file(self, filename): stderr=subprocess.PIPE, ) output, error = process.communicate(input=input_data) + print(output) + print(error) return not output.endswith(b'missing\x00') def _get_root_path(self, repository_folder): @@ -131,7 +133,9 @@ def open(self, filename): stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - output, _ = process.communicate(input=input_data) + output, error = process.communicate(input=input_data) + print(output) + print(error) return_code = process.wait() if return_code != 0 or output.endswith(b'missing\x00'): From b5af534aa476be3cded0146117acae3afe6fabc8 Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 11 Sep 2025 09:10:26 +0200 Subject: [PATCH 06/20] Remove -Z argument of cat-file command to support other versions as well --- pycobertura/filesystem.py | 13 +++++++------ tests/test_filesystem.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index e3b3659d..e8f39525 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -93,8 +93,9 @@ def has_file(self, filename): """ Check for a file's existence in the specified commit's tree. """ - command = ["git", "cat-file", "--batch-check", "--follow-symlinks", "-Z"] - input_data = self.real_filename(filename).encode() + b'\x00' + command = ["git", "cat-file", "--batch-check", "--follow-symlinks"] + real_filename = self.real_filename(filename) + input_data = f"{real_filename}\n".encode() process = subprocess.Popen( command, @@ -106,7 +107,7 @@ def has_file(self, filename): output, error = process.communicate(input=input_data) print(output) print(error) - return not output.endswith(b'missing\x00') + return not output.endswith(b'missing\n') def _get_root_path(self, repository_folder): command = ["git", "rev-parse", "--show-toplevel"] @@ -121,9 +122,9 @@ def open(self, filename): """ Yield a file-like object for the given filename, following symlinks if necessary. """ - command = ["git", "cat-file", "--batch", "--follow-symlinks", "-Z"] + command = ["git", "cat-file", "--batch", "--follow-symlinks"] real_filename = self.real_filename(filename) - input_data = real_filename.encode() + b'\x00' + input_data = f"{real_filename}\n".encode() try: process = subprocess.Popen( @@ -138,7 +139,7 @@ def open(self, filename): print(error) return_code = process.wait() - if return_code != 0 or output.endswith(b'missing\x00'): + if return_code != 0 or output.endswith(b'missing\n'): raise self.FileNotFound(real_filename) lines = output.split(b'\n', 1) content = lines[1] diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py index cf13514a..7ff49e00 100644 --- a/tests/test_filesystem.py +++ b/tests/test_filesystem.py @@ -143,7 +143,7 @@ def test_filesystem_git(): git_filename = fs.real_filename(filename) assert git_filename == expected_git_filename - expected_command = ["git", "cat-file", "--batch", "--follow-symlinks", "-Z"] + expected_command = ["git", "cat-file", "--batch", "--follow-symlinks"] subprocess_mock.Popen.assert_called_with(expected_command, cwd=repo_root, stdin=subprocess_mock.PIPE, stdout=subprocess_mock.PIPE, stderr=subprocess_mock.PIPE) From 7db7a9f9752d21e2b4d4b3f8e194b08192ba2f14 Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 11 Sep 2025 13:12:28 +0200 Subject: [PATCH 07/20] Delete prints --- pycobertura/filesystem.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index e8f39525..074b8de9 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -104,9 +104,7 @@ def has_file(self, filename): stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - output, error = process.communicate(input=input_data) - print(output) - print(error) + output, _ = process.communicate(input=input_data) return not output.endswith(b'missing\n') def _get_root_path(self, repository_folder): @@ -134,9 +132,7 @@ def open(self, filename): stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - output, error = process.communicate(input=input_data) - print(output) - print(error) + output, _ = process.communicate(input=input_data) return_code = process.wait() if return_code != 0 or output.endswith(b'missing\n'): From acaa262e3ca44cf6d5f5f6b620342499bd87e236 Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 11 Sep 2025 13:35:30 +0200 Subject: [PATCH 08/20] Make yellow more visible on a white background --- pycobertura/templates/html-delta.jinja2 | 2 +- tests/test_reporters.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pycobertura/templates/html-delta.jinja2 b/pycobertura/templates/html-delta.jinja2 index 3ec62172..ab46610a 100644 --- a/pycobertura/templates/html-delta.jinja2 +++ b/pycobertura/templates/html-delta.jinja2 @@ -7,7 +7,7 @@ {% include 'skeleton.css' %} .red {color: red} .green {color: green} -.yellow {color: yellow} +.yellow {color: #FFD700} .hit {background-color: #E6FFEC} .miss {background-color: #FFEBE9} .partial {background-color: #FFFECD} diff --git a/tests/test_reporters.py b/tests/test_reporters.py index ce5a0e6a..fce69d52 100644 --- a/tests/test_reporters.py +++ b/tests/test_reporters.py @@ -528,7 +528,7 @@ def test_html_report_delta(): html_output = report_delta.generate() assert '.red {color: red}' in html_output assert '.green {color: green}' in html_output - assert '.yellow {color: yellow}' in html_output + assert '.yellow {color: #FFD700}' in html_output assert "normalize.css" in html_output assert "Skeleton V2.0" in html_output From e16f373ea001bfe7b68ef04be916d17eb4a45246 Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 11 Sep 2025 17:18:40 +0200 Subject: [PATCH 09/20] Add Changes --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 187bb9fc..a88abaaa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## Unreleased +* Improve `GitFileSystem` to support symbolic links by using `git cat-file + --batch --follow-symlinks`. +* Use a slightly darker yellow to highlight partially covered lines. + * Add support for Python 3.12 and 3.13. ## 4.0.0 (2025-04-13) From 6cc05099e48f7b6d6b8efb170b8eadbe1f229fe2 Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 11 Sep 2025 17:54:21 +0200 Subject: [PATCH 10/20] Delete duplicate test --- tests/test_filesystem.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py index 7ff49e00..af4ab535 100644 --- a/tests/test_filesystem.py +++ b/tests/test_filesystem.py @@ -194,22 +194,6 @@ def test_filesystem_git__git_not_found(): assert folder in str(e) -def test_filesystem_git_integration(): - from pycobertura.filesystem import GitFileSystem - - fs = GitFileSystem(".", FIRST_PYCOBERTURA_COMMIT_SHA) - - # Files included in pycobertura's first commit. - source_files = [ - "README.md", - ".gitignore", - ] - - for source_file in source_files: - with fs.open(source_file) as f: - assert hasattr(f, "read") - - def test_filesystem_git_has_file_integration(): from pycobertura.filesystem import GitFileSystem From 34477d86b2489978f6885c5700f26dc8feaa6da8 Mon Sep 17 00:00:00 2001 From: JWM <62558419+JokeWaumans@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:58:28 +0200 Subject: [PATCH 11/20] Add if statement to fix IndexError Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pycobertura/filesystem.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 074b8de9..2c7d6288 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -138,6 +138,8 @@ def open(self, filename): if return_code != 0 or output.endswith(b'missing\n'): raise self.FileNotFound(real_filename) lines = output.split(b'\n', 1) + if len(lines) < 2: + raise self.FileNotFound(real_filename) content = lines[1] yield io.StringIO(content.decode("utf-8")) From 7e699ae9018ca32381756f7ef6b1ccae75f1fd2c Mon Sep 17 00:00:00 2001 From: JWM <62558419+JokeWaumans@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:26:11 +0200 Subject: [PATCH 12/20] Clarify change Co-authored-by: Jasper Craeghs <28319872+JasperCraeghs@users.noreply.github.com> --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index a88abaaa..a80fc5f8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,8 @@ * Improve `GitFileSystem` to support symbolic links by using `git cat-file --batch --follow-symlinks`. -* Use a slightly darker yellow to highlight partially covered lines. +* Use a slightly darker yellow in the HTML report for the numbers of + partially covered lines, for readability. * Add support for Python 3.12 and 3.13. From d4c327361e65cef3d27007f9c452bb368feaca4a Mon Sep 17 00:00:00 2001 From: JWM Date: Mon, 15 Sep 2025 08:58:27 +0200 Subject: [PATCH 13/20] Use black style --- pycobertura/filesystem.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 2c7d6288..386e3e89 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -105,14 +105,16 @@ def has_file(self, filename): stderr=subprocess.PIPE, ) output, _ = process.communicate(input=input_data) - return not output.endswith(b'missing\n') + return not output.endswith(b"missing\n") def _get_root_path(self, repository_folder): command = ["git", "rev-parse", "--show-toplevel"] try: output = subprocess.check_output(command, cwd=repository_folder) except (OSError, subprocess.CalledProcessError): - raise ValueError(f"The folder {repository_folder} is not a valid git repository.") + raise ValueError( + f"The folder {repository_folder} is not a valid git repository." + ) return output.decode("utf-8").rstrip() @contextmanager @@ -135,9 +137,9 @@ def open(self, filename): output, _ = process.communicate(input=input_data) return_code = process.wait() - if return_code != 0 or output.endswith(b'missing\n'): + if return_code != 0 or output.endswith(b"missing\n"): raise self.FileNotFound(real_filename) - lines = output.split(b'\n', 1) + lines = output.split(b"\n", 1) if len(lines) < 2: raise self.FileNotFound(real_filename) content = lines[1] From 0b117d8dde7517f3f9047d6f9f12bd26c9a15747 Mon Sep 17 00:00:00 2001 From: JWM Date: Wed, 17 Sep 2025 17:40:31 +0200 Subject: [PATCH 14/20] Support files in submodules --- pycobertura/filesystem.py | 103 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 386e3e89..724b4859 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -80,6 +80,8 @@ def __init__(self, repo_folder, ref): # the report may have been collected in a subfolder of the repository # root. Each file path shall thus be completed by the prefix. self.prefix = self.repository.replace(self.repository_root, "").lstrip("/") + # Cache submodule paths and commit SHAs for the provided ref + self._submodules = self._discover_submodules() def real_filename(self, filename): """ @@ -93,6 +95,22 @@ def has_file(self, filename): """ Check for a file's existence in the specified commit's tree. """ + # If the file is within a submodule, query the submodule repository + submodule_ctx = self._resolve_submodule_ctx(filename) + if submodule_ctx is not None: + submodule_root, sub_commit, rel_path = submodule_ctx + command = ["git", "cat-file", "--batch-check", "--follow-symlinks"] + input_data = f"{sub_commit}:{rel_path}\n".encode() + process = subprocess.Popen( + command, + cwd=submodule_root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, _ = process.communicate(input=input_data) + return not output.endswith(b"missing\n") + command = ["git", "cat-file", "--batch-check", "--follow-symlinks"] real_filename = self.real_filename(filename) input_data = f"{real_filename}\n".encode() @@ -122,6 +140,34 @@ def open(self, filename): """ Yield a file-like object for the given filename, following symlinks if necessary. """ + # If the file is within a submodule, read from the submodule repository at the pinned commit + submodule_ctx = self._resolve_submodule_ctx(filename) + if submodule_ctx is not None: + submodule_root, sub_commit, rel_path = submodule_ctx + command = ["git", "cat-file", "--batch", "--follow-symlinks"] + input_data = f"{sub_commit}:{rel_path}\n".encode() + try: + process = subprocess.Popen( + command, + cwd=submodule_root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, _ = process.communicate(input=input_data) + return_code = process.wait() + + if return_code != 0 or output.endswith(b"missing\n"): + raise self.FileNotFound(f"{sub_commit}:{rel_path}") + lines = output.split(b"\n", 1) + if len(lines) < 2: + raise self.FileNotFound(f"{sub_commit}:{rel_path}") + content = lines[1] + yield io.StringIO(content.decode("utf-8")) + return + except (OSError, subprocess.CalledProcessError): + raise self.FileNotFound(f"{sub_commit}:{rel_path}") + command = ["git", "cat-file", "--batch", "--follow-symlinks"] real_filename = self.real_filename(filename) input_data = f"{real_filename}\n".encode() @@ -148,6 +194,63 @@ def open(self, filename): except (OSError, subprocess.CalledProcessError): raise self.FileNotFound(real_filename) + def _discover_submodules(self): + """ + Discover submodule paths and SHAs for the given ref by inspecting the tree. + Returns a mapping of submodule path -> commit SHA. + """ + try: + output = subprocess.check_output( + [ + "git", + "ls-tree", + "-r", + "--full-tree", + self.ref, + ], + cwd=self.repository_root, + ) + except (OSError, subprocess.CalledProcessError): + return {} + + submodules = {} + for line in output.decode("utf-8", errors="replace").splitlines(): + # Expected format: "160000 commit \t" + try: + meta, path = line.split("\t", 1) + except ValueError: + continue + parts = meta.split() + if len(parts) < 3: + continue + mode, obj_type, sha = parts[0], parts[1], parts[2] + if mode == "160000" and obj_type == "commit": + submodules[path] = sha + return submodules + + def _resolve_submodule_ctx(self, filename): + """ + If the path points into a submodule, return a tuple of + (submodule_root_abs_path, submodule_commit_sha, relative_path_inside_submodule). + Otherwise, return None. + """ + # Find the longest matching submodule path that prefixes the filename + matching = [p for p in self._submodules.keys() if filename == p or filename.startswith(p + "/")] + if not matching: + return None + # Use the longest (deepest) match in case of nested submodules + sub_path = max(matching, key=len) + sub_sha = self._submodules.get(sub_path) + if not sub_sha: + return None + + rel_path = filename.removeprefix(sub_path).lstrip("/") + submodule_root = os.path.join(self.repository_root, sub_path) + if not os.path.isdir(submodule_root): + # Submodule not checked out; we cannot read without local checkout of objects + return None + return (submodule_root, sub_sha, rel_path) + def filesystem_factory(source, source_prefix=None, ref=None): """ From d15ce8597e8e5facf0c8686523b5d9998000c004 Mon Sep 17 00:00:00 2001 From: JWM Date: Wed, 17 Sep 2025 17:42:07 +0200 Subject: [PATCH 15/20] Make functions for cat-file commands in order get rid of duplicated code --- pycobertura/filesystem.py | 124 +++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 70 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 724b4859..d9f3e10f 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -83,6 +83,53 @@ def __init__(self, repo_folder, ref): # Cache submodule paths and commit SHAs for the provided ref self._submodules = self._discover_submodules() + def _git_cat_file_check(self, repo_root, spec): + """ + Call `git cat-file --batch-check --follow-symlinks` and return existence as bool. + """ + args = ["git", "cat-file", "--follow-symlinks", "--batch-check"] + input_data = f"{spec}\n".encode() + try: + process = subprocess.Popen( + args, + cwd=repo_root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, _ = process.communicate(input=input_data) + return_code = process.wait() + except (OSError, subprocess.CalledProcessError): + return False + return return_code == 0 and not output.endswith(b"missing\n") + + def _git_cat_file_read(self, repo_root, spec): + """ + Call `git cat-file --batch --follow-symlinks` and return blob content as bytes. + Raises FileNotFound if the object is missing or on error. + """ + args = ["git", "cat-file", "--follow-symlinks", "--batch"] + input_data = f"{spec}\n".encode() + try: + process = subprocess.Popen( + args, + cwd=repo_root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, _ = process.communicate(input=input_data) + return_code = process.wait() + except (OSError, subprocess.CalledProcessError): + raise self.FileNotFound(spec) + + if return_code != 0 or output.endswith(b"missing\n"): + raise self.FileNotFound(spec) + lines = output.split(b"\n", 1) + if len(lines) < 2: + raise self.FileNotFound(spec) + return lines[1] + def real_filename(self, filename): """ Constructs the Git path for a given filename. @@ -99,31 +146,10 @@ def has_file(self, filename): submodule_ctx = self._resolve_submodule_ctx(filename) if submodule_ctx is not None: submodule_root, sub_commit, rel_path = submodule_ctx - command = ["git", "cat-file", "--batch-check", "--follow-symlinks"] - input_data = f"{sub_commit}:{rel_path}\n".encode() - process = subprocess.Popen( - command, - cwd=submodule_root, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - output, _ = process.communicate(input=input_data) - return not output.endswith(b"missing\n") + return self._git_cat_file_check(submodule_root, f"{sub_commit}:{rel_path}") - command = ["git", "cat-file", "--batch-check", "--follow-symlinks"] real_filename = self.real_filename(filename) - input_data = f"{real_filename}\n".encode() - - process = subprocess.Popen( - command, - cwd=self.repository_root, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - output, _ = process.communicate(input=input_data) - return not output.endswith(b"missing\n") + return self._git_cat_file_check(self.repository_root, real_filename) def _get_root_path(self, repository_folder): command = ["git", "rev-parse", "--show-toplevel"] @@ -144,55 +170,13 @@ def open(self, filename): submodule_ctx = self._resolve_submodule_ctx(filename) if submodule_ctx is not None: submodule_root, sub_commit, rel_path = submodule_ctx - command = ["git", "cat-file", "--batch", "--follow-symlinks"] - input_data = f"{sub_commit}:{rel_path}\n".encode() - try: - process = subprocess.Popen( - command, - cwd=submodule_root, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - output, _ = process.communicate(input=input_data) - return_code = process.wait() - - if return_code != 0 or output.endswith(b"missing\n"): - raise self.FileNotFound(f"{sub_commit}:{rel_path}") - lines = output.split(b"\n", 1) - if len(lines) < 2: - raise self.FileNotFound(f"{sub_commit}:{rel_path}") - content = lines[1] - yield io.StringIO(content.decode("utf-8")) - return - except (OSError, subprocess.CalledProcessError): - raise self.FileNotFound(f"{sub_commit}:{rel_path}") - - command = ["git", "cat-file", "--batch", "--follow-symlinks"] - real_filename = self.real_filename(filename) - input_data = f"{real_filename}\n".encode() - - try: - process = subprocess.Popen( - command, - cwd=self.repository_root, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - output, _ = process.communicate(input=input_data) - return_code = process.wait() - - if return_code != 0 or output.endswith(b"missing\n"): - raise self.FileNotFound(real_filename) - lines = output.split(b"\n", 1) - if len(lines) < 2: - raise self.FileNotFound(real_filename) - content = lines[1] + content = self._git_cat_file_read(submodule_root, f"{sub_commit}:{rel_path}") yield io.StringIO(content.decode("utf-8")) + return - except (OSError, subprocess.CalledProcessError): - raise self.FileNotFound(real_filename) + real_filename = self.real_filename(filename) + content = self._git_cat_file_read(self.repository_root, real_filename) + yield io.StringIO(content.decode("utf-8")) def _discover_submodules(self): """ From 6322a032fd3c08ab7c790510b0273ff045895db2 Mon Sep 17 00:00:00 2001 From: JWM Date: Wed, 17 Sep 2025 17:50:18 +0200 Subject: [PATCH 16/20] Change order to fix tests --- pycobertura/filesystem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index d9f3e10f..2f535a63 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -87,7 +87,7 @@ def _git_cat_file_check(self, repo_root, spec): """ Call `git cat-file --batch-check --follow-symlinks` and return existence as bool. """ - args = ["git", "cat-file", "--follow-symlinks", "--batch-check"] + args = ["git", "cat-file", "--batch-check", "--follow-symlinks"] input_data = f"{spec}\n".encode() try: process = subprocess.Popen( @@ -108,7 +108,7 @@ def _git_cat_file_read(self, repo_root, spec): Call `git cat-file --batch --follow-symlinks` and return blob content as bytes. Raises FileNotFound if the object is missing or on error. """ - args = ["git", "cat-file", "--follow-symlinks", "--batch"] + args = ["git", "cat-file", "--batch", "--follow-symlinks"] input_data = f"{spec}\n".encode() try: process = subprocess.Popen( From 8ef2cc3b63b6a5e3cb0dac61743472ed0841a963 Mon Sep 17 00:00:00 2001 From: JWM Date: Wed, 17 Sep 2025 17:50:32 +0200 Subject: [PATCH 17/20] Fix black style --- pycobertura/filesystem.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 2f535a63..19871dc5 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -170,7 +170,9 @@ def open(self, filename): submodule_ctx = self._resolve_submodule_ctx(filename) if submodule_ctx is not None: submodule_root, sub_commit, rel_path = submodule_ctx - content = self._git_cat_file_read(submodule_root, f"{sub_commit}:{rel_path}") + content = self._git_cat_file_read( + submodule_root, f"{sub_commit}:{rel_path}" + ) yield io.StringIO(content.decode("utf-8")) return @@ -219,7 +221,11 @@ def _resolve_submodule_ctx(self, filename): Otherwise, return None. """ # Find the longest matching submodule path that prefixes the filename - matching = [p for p in self._submodules.keys() if filename == p or filename.startswith(p + "/")] + matching = [ + p + for p in self._submodules.keys() + if filename == p or filename.startswith(p + "/") + ] if not matching: return None # Use the longest (deepest) match in case of nested submodules From 3f2eefaa8c01b60167aef2eab62f768f0cbd375c Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 18 Sep 2025 09:49:25 +0200 Subject: [PATCH 18/20] Fix lines too long --- pycobertura/filesystem.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index 19871dc5..dd70117a 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -85,7 +85,8 @@ def __init__(self, repo_folder, ref): def _git_cat_file_check(self, repo_root, spec): """ - Call `git cat-file --batch-check --follow-symlinks` and return existence as bool. + Call `git cat-file --batch-check --follow-symlinks` + and return existence as bool. """ args = ["git", "cat-file", "--batch-check", "--follow-symlinks"] input_data = f"{spec}\n".encode() @@ -164,9 +165,11 @@ def _get_root_path(self, repository_folder): @contextmanager def open(self, filename): """ - Yield a file-like object for the given filename, following symlinks if necessary. + Yield a file-like object for the given filename, + following symlinks if necessary. """ - # If the file is within a submodule, read from the submodule repository at the pinned commit + # If the file is within a submodule, + # read from the submodule repository at the pinned commit submodule_ctx = self._resolve_submodule_ctx(filename) if submodule_ctx is not None: submodule_root, sub_commit, rel_path = submodule_ctx @@ -237,7 +240,8 @@ def _resolve_submodule_ctx(self, filename): rel_path = filename.removeprefix(sub_path).lstrip("/") submodule_root = os.path.join(self.repository_root, sub_path) if not os.path.isdir(submodule_root): - # Submodule not checked out; we cannot read without local checkout of objects + # Submodule not checked out; + # we cannot read without local checkout of objects return None return (submodule_root, sub_sha, rel_path) From 4ea5775778e45d39041c61602125cb7aa2898774 Mon Sep 17 00:00:00 2001 From: JWM Date: Thu, 18 Sep 2025 13:21:11 +0200 Subject: [PATCH 19/20] Update changes --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index a80fc5f8..40cdfc69 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,7 @@ * Improve `GitFileSystem` to support symbolic links by using `git cat-file --batch --follow-symlinks`. +* Improve `GitFileSystem` to support source files located in submodules. * Use a slightly darker yellow in the HTML report for the numbers of partially covered lines, for readability. From d69d7d86a511efe40afa6b8998bd18163dce2a5a Mon Sep 17 00:00:00 2001 From: JWM <62558419+JokeWaumans@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:49:33 +0200 Subject: [PATCH 20/20] Update docstring with suggestion Co-authored-by: Alexandre Conrad-Dormoy --- pycobertura/filesystem.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pycobertura/filesystem.py b/pycobertura/filesystem.py index dd70117a..2ea8b22a 100644 --- a/pycobertura/filesystem.py +++ b/pycobertura/filesystem.py @@ -167,6 +167,8 @@ def open(self, filename): """ Yield a file-like object for the given filename, following symlinks if necessary. + + This function is a context manager. """ # If the file is within a submodule, # read from the submodule repository at the pinned commit