diff --git a/src/antsibull_fileutils/vcs.py b/src/antsibull_fileutils/vcs.py index adc9b1a..0b48ab8 100644 --- a/src/antsibull_fileutils/vcs.py +++ b/src/antsibull_fileutils/vcs.py @@ -13,11 +13,14 @@ import subprocess import typing as t +if t.TYPE_CHECKING: + from _typeshed import StrPath + def detect_vcs( - path: str, + path: StrPath, *, - git_bin_path: str = "git", + git_bin_path: StrPath = "git", log_debug: t.Callable[[str], None] | None = None, log_info: t.Callable[[str], None] | None = None, ) -> t.Literal["none", "git"]: @@ -40,7 +43,7 @@ def do_log_info(msg: str, *args: t.Any) -> None: do_log_debug("Trying to determine whether {!r} is a Git repository", path) try: result = subprocess.check_output( - [git_bin_path, "-C", path, "rev-parse", "--is-inside-work-tree"], + [str(git_bin_path), "-C", path, "rev-parse", "--is-inside-work-tree"], text=True, encoding="utf-8", ).strip() @@ -58,3 +61,42 @@ def do_log_info(msg: str, *args: t.Any) -> None: # Fallback: no VCS detected do_log_debug("Cannot identify VCS") return "none" + + +def list_git_files( + directory: StrPath, + *, + git_bin_path: StrPath = "git", + log_debug: t.Callable[[str], None] | None = None, +) -> list[bytes]: + """ + List all files not ignored by git in a directory and subdirectories. + + Raises ``ValueError`` in case of errors. + """ + + def do_log_debug(msg: str, *args) -> None: + if log_debug: + log_debug(msg, *args) + + do_log_debug("Identifying files not ignored by Git in {!r}", directory) + try: + result = subprocess.check_output( + [ + str(git_bin_path), + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--deduplicate", + ], + cwd=directory, + ).strip(b"\x00") + if result == b"": + return [] + return result.split(b"\x00") + except subprocess.CalledProcessError as exc: + raise ValueError("Error while running git") from exc + except FileNotFoundError as exc: + raise ValueError("Cannot find git executable") from exc diff --git a/tests/units/__init__.py b/tests/units/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/units/test_vcs.py b/tests/units/test_vcs.py index c9c6841..6446bad 100644 --- a/tests/units/test_vcs.py +++ b/tests/units/test_vcs.py @@ -1,10 +1,10 @@ # Author: Felix Fontein # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: 2020, Ansible Project +# SPDX-FileCopyrightText: 2024, Ansible Project """ -Test utils module. +Test vcs module. """ from __future__ import annotations @@ -14,7 +14,9 @@ import pytest -from antsibull_fileutils.vcs import detect_vcs +from antsibull_fileutils.vcs import detect_vcs, list_git_files + +from .utils import collect_log def test_detect_vcs(): @@ -22,25 +24,6 @@ def test_detect_vcs(): git_bin_path = "/path/to/git" git_command = [git_bin_path, "-C", path, "rev-parse", "--is-inside-work-tree"] - def collect_log(): - debug = [] - info = [] - - def log_debug(msg: str, *args) -> None: - debug.append((msg, args)) - - def log_info(msg: str, *args) -> None: - info.append((msg, args)) - - return ( - { - "log_debug": log_debug, - "log_info": log_info, - }, - debug, - info, - ) - with mock.patch( "subprocess.check_output", return_value="true\n", @@ -83,3 +66,70 @@ def log_info(msg: str, *args) -> None: ) as m: assert detect_vcs(path, git_bin_path=git_bin_path) == "none" m.assert_called_with(git_command, text=True, encoding="utf-8") + + +TEST_LIST_GIT_FILES = [ + (b"", [], False), + (b"", [], True), + (b"foo\nbar", [b"foo\nbar"], False), + (b"foo\x00", [b"foo"], True), + (b"foo\x00bar", [b"foo", b"bar"], True), + ( + b"link\x00foobar\x00dir/binary_file", + [b"link", b"foobar", b"dir/binary_file"], + False, + ), +] + + +@pytest.mark.parametrize("stdout, expected, with_logging", TEST_LIST_GIT_FILES) +def test_list_git_files(stdout: bytes, expected: list[str], with_logging: bool): + path = "/path/to/dir" + git_bin_path = "/path/to/git" + git_command = [ + git_bin_path, + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--deduplicate", + ] + + with mock.patch( + "subprocess.check_output", + return_value=stdout, + ) as m: + kwargs, debug, info = collect_log(with_debug=with_logging, with_info=False) + assert list_git_files(path, git_bin_path=git_bin_path, **kwargs) == expected + m.assert_called_with(git_command, cwd=path) + if with_logging: + assert debug == [("Identifying files not ignored by Git in {!r}", (path,))] + + +def test_list_git_files_fail(): + path = "/path/to/dir" + git_bin_path = "/path/to/git" + git_command = [ + git_bin_path, + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--deduplicate", + ] + + with mock.patch( + "subprocess.check_output", + side_effect=subprocess.CalledProcessError(128, path), + ) as m: + with pytest.raises(ValueError, match="^Error while running git$") as exc: + list_git_files(path, git_bin_path=git_bin_path) + + with mock.patch( + "subprocess.check_output", + side_effect=FileNotFoundError(), + ) as m: + with pytest.raises(ValueError, match="^Cannot find git executable$") as exc: + list_git_files(path, git_bin_path=git_bin_path) diff --git a/tests/units/utils.py b/tests/units/utils.py new file mode 100644 index 0000000..ae39588 --- /dev/null +++ b/tests/units/utils.py @@ -0,0 +1,32 @@ +# Author: Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2024, Ansible Project + +""" +Utilities +""" + +from __future__ import annotations + + +def collect_log(with_debug: bool = True, with_info: bool = True): + debug = [] + info = [] + + def log_debug(msg: str, *args) -> None: + debug.append((msg, args)) + + def log_info(msg: str, *args) -> None: + info.append((msg, args)) + + kwargs = {} + if with_debug: + kwargs["log_debug"] = log_debug + if with_info: + kwargs["log_info"] = log_info + return ( + kwargs, + debug, + info, + )