diff --git a/.github/workflows/license_list_up_to_date.yaml b/.github/workflows/license_list_up_to_date.yaml index 0b75ee89d..0c8add474 100644 --- a/.github/workflows/license_list_up_to_date.yaml +++ b/.github/workflows/license_list_up_to_date.yaml @@ -6,7 +6,7 @@ name: Verify that the license lists are up-to-date on: schedule: - - cron: "0 9 * * *" + - cron: "0 9 * * 1" jobs: license-list-up-to-date: @@ -16,7 +16,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: 3.x - name: Verify that the license lists are up-to-date diff --git a/.github/workflows/pijul.yaml b/.github/workflows/pijul.yaml new file mode 100644 index 000000000..23d59ae8e --- /dev/null +++ b/.github/workflows/pijul.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: © 2020 Liferay, Inc. +# SPDX-FileCopyrightText: 2023 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Test with Paijul + +# These tests are run exclusively on the main branch to reduce CPU time wasted +# on every single PR that very likely does not affect Pijul functionality. +on: + push: + branches: + - main + paths: + - "src/reuse/**.py" + - "tests/**.py" +jobs: + test-pijul: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.x + - name: Install dependencies + run: | + pip install poetry~=1.2.0 + poetry install --no-interaction --only main,test + # TODO: As soon as a binary is available for Ubuntu 22.04, use it instead + # of manually building it. + - name: Set up Pijul + run: | + sudo apt install make libsodium-dev libclang-dev pkg-config libssl-dev libxxhash-dev libzstd-dev clang + cargo install --locked pijul --version "1.0.0-beta.6" + pijul identity new --no-prompt --display-name 'Jane Doe' --email 'jdoe@example.com' 'jdoe' + - name: Run tests with pytest + run: | + poetry run pytest --cov=reuse diff --git a/AUTHORS.rst b/AUTHORS.rst index 36eb9d5a8..107ff7d21 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -72,6 +72,8 @@ Contributors - Robin Vobruba +- Markus Haug + Translators ----------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8c7c07f..38c3763f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ CLI command and its behaviour. There are no guarantees of stability for the - Julia (`.jl`) (#815) - Modern Fortran (`.f90`) (#836) - Display recommendations for steps to fix found issues during a lint. (#698) +- Add support for Pijul VCS. Pijul support is not added to the Docker image. + (#858) ### Changed diff --git a/README.md b/README.md index c1e58778e..ae550124f 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ For full functionality, the following pieces of software are recommended: - Git - Mercurial 4.3+ +- Pijul ### Installation via pip diff --git a/src/reuse/_util.py b/src/reuse/_util.py index b3ca8910a..676762b76 100644 --- a/src/reuse/_util.py +++ b/src/reuse/_util.py @@ -62,6 +62,7 @@ GIT_EXE = shutil.which("git") HG_EXE = shutil.which("hg") +PIJUL_EXE = shutil.which("pijul") REUSE_IGNORE_START = "REUSE-IgnoreStart" REUSE_IGNORE_END = "REUSE-IgnoreEnd" diff --git a/src/reuse/project.py b/src/reuse/project.py index bb0ccb4df..a0220376e 100644 --- a/src/reuse/project.py +++ b/src/reuse/project.py @@ -35,8 +35,6 @@ from ._util import ( _HEADER_BYTES, _LICENSEREF_PATTERN, - GIT_EXE, - HG_EXE, StrPath, _contains_snippet, _copyright_from_dep5, @@ -46,7 +44,7 @@ decoded_text_from_binary, extract_reuse_info, ) -from .vcs import VCSStrategy, VCSStrategyGit, VCSStrategyHg, VCSStrategyNone +from .vcs import VCSStrategy, VCSStrategyNone, all_vcs_strategies _LOGGER = logging.getLogger(__name__) @@ -448,10 +446,10 @@ def _detect_vcs_strategy(cls, root: StrPath) -> Type[VCSStrategy]: """For each supported VCS, check if the software is available and if the directory is a repository. If not, return :class:`VCSStrategyNone`. """ - if GIT_EXE and VCSStrategyGit.in_repo(root): - return VCSStrategyGit - if HG_EXE and VCSStrategyHg.in_repo(root): - return VCSStrategyHg + for strategy in all_vcs_strategies(): + if strategy.EXE and strategy.in_repo(root): + return strategy + _LOGGER.info( _( "project '{}' is not a VCS repository or required VCS" diff --git a/src/reuse/vcs.py b/src/reuse/vcs.py index 47400aa26..73c40c7ea 100644 --- a/src/reuse/vcs.py +++ b/src/reuse/vcs.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. # SPDX-FileCopyrightText: © 2020 Liferay, Inc. # SPDX-FileCopyrightText: 2020 John Mulligan +# SPDX-FileCopyrightText: 2023 Markus Haug # # SPDX-License-Identifier: GPL-3.0-or-later @@ -11,10 +12,11 @@ import logging import os from abc import ABC, abstractmethod +from inspect import isclass from pathlib import Path -from typing import TYPE_CHECKING, Optional, Set +from typing import TYPE_CHECKING, Generator, Optional, Set, Type -from ._util import GIT_EXE, HG_EXE, StrPath, execute_command +from ._util import GIT_EXE, HG_EXE, PIJUL_EXE, StrPath, execute_command if TYPE_CHECKING: from .project import Project @@ -25,6 +27,8 @@ class VCSStrategy(ABC): """Strategy pattern for version control systems.""" + EXE: str | None = None + @abstractmethod def __init__(self, project: Project): self.project = project @@ -82,9 +86,11 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: class VCSStrategyGit(VCSStrategy): """Strategy that is used for Git.""" + EXE = GIT_EXE + def __init__(self, project: Project): super().__init__(project) - if not GIT_EXE: + if not self.EXE: raise FileNotFoundError("Could not find binary for Git") self._all_ignored_files = self._find_all_ignored_files() self._submodules = self._find_submodules() @@ -94,7 +100,7 @@ def _find_all_ignored_files(self) -> Set[Path]: ignored, don't return all files inside of it. """ command = [ - str(GIT_EXE), + str(self.EXE), "ls-files", "--exclude-standard", "--ignored", @@ -113,7 +119,7 @@ def _find_all_ignored_files(self) -> Set[Path]: def _find_submodules(self) -> Set[Path]: command = [ - str(GIT_EXE), + str(self.EXE), "config", "-z", "--file", @@ -150,7 +156,7 @@ def in_repo(cls, directory: StrPath) -> bool: if not Path(directory).is_dir(): raise NotADirectoryError() - command = [str(GIT_EXE), "status"] + command = [str(cls.EXE), "status"] result = execute_command(command, _LOGGER, cwd=directory) return not result.returncode @@ -163,7 +169,7 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: if not Path(cwd).is_dir(): raise NotADirectoryError() - command = [str(GIT_EXE), "rev-parse", "--show-toplevel"] + command = [str(cls.EXE), "rev-parse", "--show-toplevel"] result = execute_command(command, _LOGGER, cwd=cwd) if not result.returncode: @@ -176,9 +182,11 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: class VCSStrategyHg(VCSStrategy): """Strategy that is used for Mercurial.""" + EXE = HG_EXE + def __init__(self, project: Project): super().__init__(project) - if not HG_EXE: + if not self.EXE: raise FileNotFoundError("Could not find binary for Mercurial") self._all_ignored_files = self._find_all_ignored_files() @@ -187,7 +195,7 @@ def _find_all_ignored_files(self) -> Set[Path]: is ignored, don't return all files inside of it. """ command = [ - str(HG_EXE), + str(self.EXE), "status", "--ignored", # terse is marked 'experimental' in the hg help but is documented @@ -218,7 +226,7 @@ def in_repo(cls, directory: StrPath) -> bool: if not Path(directory).is_dir(): raise NotADirectoryError() - command = [str(HG_EXE), "root"] + command = [str(cls.EXE), "root"] result = execute_command(command, _LOGGER, cwd=directory) return not result.returncode @@ -231,7 +239,7 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: if not Path(cwd).is_dir(): raise NotADirectoryError() - command = [str(HG_EXE), "root"] + command = [str(cls.EXE), "root"] result = execute_command(command, _LOGGER, cwd=cwd) if not result.returncode: @@ -241,6 +249,81 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: return None +class VCSStrategyPijul(VCSStrategy): + """Strategy that is used for Pijul.""" + + EXE = PIJUL_EXE + + def __init__(self, project: Project): + super().__init__(project) + if not self.EXE: + raise FileNotFoundError("Could not find binary for Pijul") + self._all_tracked_files = self._find_all_tracked_files() + + def _find_all_tracked_files(self) -> Set[Path]: + """Return a set of all files tracked by pijul.""" + command = [str(self.EXE), "list"] + result = execute_command(command, _LOGGER, cwd=self.project.root) + all_files = result.stdout.decode("utf-8").splitlines() + return {Path(file_) for file_ in all_files} + + def is_ignored(self, path: StrPath) -> bool: + path = self.project.relative_from_root(path) + return path not in self._all_tracked_files + + def is_submodule(self, path: StrPath) -> bool: + # not supported in pijul yet + return False + + @classmethod + def in_repo(cls, directory: StrPath) -> bool: + if directory is None: + directory = Path.cwd() + + if not Path(directory).is_dir(): + raise NotADirectoryError() + + command = [str(cls.EXE), "diff", "--short"] + result = execute_command(command, _LOGGER, cwd=directory) + + return not result.returncode + + @classmethod + def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: + if cwd is None: + cwd = Path.cwd() + + # TODO this duplicates pijul's logic. + # Maybe it should be replaced by calling pijul, + # but there is no matching subcommand yet. + path = Path(cwd).resolve() + + if not path.is_dir(): + raise NotADirectoryError() + + while True: + if (path / ".pijul").is_dir(): + return path + + parent = path.parent + if parent == path: + # We reached the filesystem root + return None + + path = parent + + +def all_vcs_strategies() -> Generator[Type[VCSStrategy], None, None]: + """Yield all VCSStrategy classes that aren't the abstract base class.""" + for value in globals().values(): + if ( + isclass(value) + and issubclass(value, VCSStrategy) + and value is not VCSStrategy + ): + yield value + + def find_root(cwd: Optional[StrPath] = None) -> Optional[Path]: """Try to find the root of the project from *cwd*. If none is found, return None. @@ -248,12 +331,9 @@ def find_root(cwd: Optional[StrPath] = None) -> Optional[Path]: Raises: NotADirectoryError: if directory is not a directory. """ - if GIT_EXE: - root = VCSStrategyGit.find_root(cwd=cwd) - if root: - return root - if HG_EXE: - root = VCSStrategyHg.find_root(cwd=cwd) - if root: - return root + for strategy in all_vcs_strategies(): + if strategy.EXE: + root = strategy.find_root(cwd=cwd) + if root: + return root return None diff --git a/tests/conftest.py b/tests/conftest.py index 3ab3b8efa..e77ea6180 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,7 +37,7 @@ except ImportError: sys.path.append(os.path.join(Path(__file__).parent.parent, "src")) finally: - from reuse._util import GIT_EXE, HG_EXE, setup_logging + from reuse._util import GIT_EXE, HG_EXE, PIJUL_EXE, setup_logging CWD = Path.cwd() @@ -84,6 +84,14 @@ def hg_exe() -> str: return str(HG_EXE) +@pytest.fixture() +def pijul_exe() -> str: + """Run the test with Pijul.""" + if not PIJUL_EXE: + pytest.skip("cannot run this test without pijul") + return str(PIJUL_EXE) + + @pytest.fixture(params=[True, False]) def multiprocessing(request, monkeypatch) -> Generator[bool, None, None]: """Run the test with or without multiprocessing.""" @@ -224,6 +232,31 @@ def hg_repository(fake_repository: Path, hg_exe: str) -> Path: return fake_repository +@pytest.fixture() +def pijul_repository(fake_repository: Path, pijul_exe: str) -> Path: + """Create a pijul repository with ignored files.""" + os.chdir(fake_repository) + _repo_contents( + fake_repository, + ignore_filename=".ignore", + ) + + subprocess.run([pijul_exe, "init", "."], check=True) + subprocess.run([pijul_exe, "add", "--recursive", "."], check=True) + subprocess.run( + [ + pijul_exe, + "record", + "--all", + "--message", + "initial", + ], + check=True, + ) + + return fake_repository + + @pytest.fixture(params=["submodule-add", "manual"]) def submodule_repository( git_repository: Path, git_exe: str, tmpdir_factory, request diff --git a/tests/test_main.py b/tests/test_main.py index 4b91d5822..0ee42dd15 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -25,7 +25,7 @@ from reuse import download from reuse._main import main -from reuse._util import GIT_EXE, HG_EXE +from reuse._util import GIT_EXE, HG_EXE, PIJUL_EXE from reuse.report import LINT_VERSION TESTS_DIRECTORY = Path(__file__).parent.resolve() @@ -40,7 +40,7 @@ def optional_git_exe( ) -> Generator[Optional[str], None, None]: """Run the test with or without git.""" exe = GIT_EXE if request.param else "" - monkeypatch.setattr("reuse.project.GIT_EXE", exe) + monkeypatch.setattr("reuse.vcs.GIT_EXE", exe) monkeypatch.setattr("reuse._util.GIT_EXE", exe) yield exe @@ -51,11 +51,22 @@ def optional_hg_exe( ) -> Generator[Optional[str], None, None]: """Run the test with or without mercurial.""" exe = HG_EXE if request.param else "" - monkeypatch.setattr("reuse.project.HG_EXE", exe) + monkeypatch.setattr("reuse.vcs.HG_EXE", exe) monkeypatch.setattr("reuse._util.HG_EXE", exe) yield exe +@pytest.fixture(params=[True, False]) +def optional_pijul_exe( + request, monkeypatch +) -> Generator[Optional[str], None, None]: + """Run the test with or without Pijul.""" + exe = PIJUL_EXE if request.param else "" + monkeypatch.setattr("reuse.vcs.PIJUL_EXE", exe) + monkeypatch.setattr("reuse._util.PIJUL_EXE", exe) + yield exe + + @pytest.fixture() def mock_put_license_in_file(monkeypatch): """Create a mocked version of put_license_in_file.""" diff --git a/tests/test_project.py b/tests/test_project.py index aaaafb07f..d856c6079 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -212,6 +212,40 @@ def test_all_files_hg_ignored_contains_newline(hg_repository): assert Path("hello\nworld.pyc").absolute() not in project.all_files() +def test_all_files_pijul_ignored(pijul_repository): + """Given a pijul repository where some files are ignored, do not yield + those files. + """ + project = Project.from_directory(pijul_repository) + assert Path("build/hello.py").absolute() not in project.all_files() + + +def test_all_files_pijul_ignored_different_cwd(pijul_repository): + """Given a pijul repository where some files are ignored, do not yield + those files. + + Be in a different CWD during the above. + """ + os.chdir(pijul_repository / "LICENSES") + project = Project.from_directory(pijul_repository) + assert Path("build/hello.py").absolute() not in project.all_files() + + +def test_all_files_pijul_ignored_contains_space(pijul_repository): + """File names that contain spaces are also ignored.""" + (pijul_repository / "I contain spaces.pyc").touch() + project = Project.from_directory(pijul_repository) + assert Path("I contain spaces.pyc").absolute() not in project.all_files() + + +@posix +def test_all_files_pijul_ignored_contains_newline(pijul_repository): + """File names that contain newlines are also ignored.""" + (pijul_repository / "hello\nworld.pyc").touch() + project = Project.from_directory(pijul_repository) + assert Path("hello\nworld.pyc").absolute() not in project.all_files() + + def test_reuse_info_of_file_does_not_exist(fake_repository): """Raise FileNotFoundError when asking for the REUSE info of a file that does not exist. diff --git a/tests/test_vcs.py b/tests/test_vcs.py index a522abc98..97bebb6c6 100644 --- a/tests/test_vcs.py +++ b/tests/test_vcs.py @@ -31,3 +31,13 @@ def test_find_root_in_hg_repo(hg_repository): result = vcs.find_root() assert Path(result).absolute().resolve() == hg_repository + + +def test_find_root_in_pijul_repo(pijul_repository): + """When using reuse from a child directory in a Pijul repo, always find + the root directory. + """ + os.chdir("src") + result = vcs.find_root() + + assert Path(result).absolute().resolve() == pijul_repository