From 7ac3622db5eb4279dc97892b5b089db1c3bbc895 Mon Sep 17 00:00:00 2001 From: Korrat Date: Tue, 31 Oct 2023 14:44:12 +0100 Subject: [PATCH 1/6] Add support for the Pijul VCS Pijul is a new VCS based on tracking commutative patches. This commit adds support for Pijul to reuse-tool by implementing VCSStrategyPijul. --- src/reuse/_util.py | 1 + src/reuse/project.py | 13 ++++++++- src/reuse/vcs.py | 69 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/reuse/_util.py b/src/reuse/_util.py index b3ca8910..676762b7 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 bb0ccb4d..3f65bb5e 100644 --- a/src/reuse/project.py +++ b/src/reuse/project.py @@ -37,6 +37,7 @@ _LICENSEREF_PATTERN, GIT_EXE, HG_EXE, + PIJUL_EXE, StrPath, _contains_snippet, _copyright_from_dep5, @@ -46,7 +47,14 @@ decoded_text_from_binary, extract_reuse_info, ) -from .vcs import VCSStrategy, VCSStrategyGit, VCSStrategyHg, VCSStrategyNone +from .vcs import ( + VCSStrategy, + VCSStrategyGit, + VCSStrategyHg, + VCSStrategyNone, + VCSStrategyPijul, + find_root, +) _LOGGER = logging.getLogger(__name__) @@ -452,6 +460,9 @@ def _detect_vcs_strategy(cls, root: StrPath) -> Type[VCSStrategy]: return VCSStrategyGit if HG_EXE and VCSStrategyHg.in_repo(root): return VCSStrategyHg + if PIJUL_EXE and VCSStrategyPijul.in_repo(self._root): + return VCSStrategyPijul + _LOGGER.info( _( "project '{}' is not a VCS repository or required VCS" diff --git a/src/reuse/vcs.py b/src/reuse/vcs.py index 47400aa2..a5ed0f04 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 @@ -14,7 +15,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Optional, Set -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 @@ -241,6 +242,68 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: return None +class VCSStrategyPijul(VCSStrategy): + """Strategy that is used for Pijul.""" + + def __init__(self, project: Project): + super().__init__(project) + if not PIJUL_EXE: + raise FileNotFoundError("Could not find binary for Mercurial") + 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(PIJUL_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(PIJUL_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 find_root(cwd: Optional[StrPath] = None) -> Optional[Path]: """Try to find the root of the project from *cwd*. If none is found, return None. @@ -256,4 +319,8 @@ def find_root(cwd: Optional[StrPath] = None) -> Optional[Path]: root = VCSStrategyHg.find_root(cwd=cwd) if root: return root + if PIJUL_EXE: + root = VCSStrategyPijul.find_root(cwd=cwd) + if root: + return root return None From 4d5973196eacb9e13459c4245f28db703f48c5f5 Mon Sep 17 00:00:00 2001 From: Carmen Bianca BAKKER Date: Tue, 31 Oct 2023 15:29:26 +0100 Subject: [PATCH 2/6] Add better plumbing for adding more VCSStrategies Signed-off-by: Carmen Bianca BAKKER --- src/reuse/project.py | 21 +++----------- src/reuse/vcs.py | 65 ++++++++++++++++++++++++++------------------ tests/conftest.py | 10 ++++++- tests/test_main.py | 17 ++++++++++-- 4 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/reuse/project.py b/src/reuse/project.py index 3f65bb5e..a0220376 100644 --- a/src/reuse/project.py +++ b/src/reuse/project.py @@ -35,9 +35,6 @@ from ._util import ( _HEADER_BYTES, _LICENSEREF_PATTERN, - GIT_EXE, - HG_EXE, - PIJUL_EXE, StrPath, _contains_snippet, _copyright_from_dep5, @@ -47,14 +44,7 @@ decoded_text_from_binary, extract_reuse_info, ) -from .vcs import ( - VCSStrategy, - VCSStrategyGit, - VCSStrategyHg, - VCSStrategyNone, - VCSStrategyPijul, - find_root, -) +from .vcs import VCSStrategy, VCSStrategyNone, all_vcs_strategies _LOGGER = logging.getLogger(__name__) @@ -456,12 +446,9 @@ 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 - if PIJUL_EXE and VCSStrategyPijul.in_repo(self._root): - return VCSStrategyPijul + for strategy in all_vcs_strategies(): + if strategy.EXE and strategy.in_repo(root): + return strategy _LOGGER.info( _( diff --git a/src/reuse/vcs.py b/src/reuse/vcs.py index a5ed0f04..73c40c7e 100644 --- a/src/reuse/vcs.py +++ b/src/reuse/vcs.py @@ -12,8 +12,9 @@ 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, PIJUL_EXE, StrPath, execute_command @@ -26,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 @@ -83,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() @@ -95,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", @@ -114,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", @@ -151,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 @@ -164,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: @@ -177,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() @@ -188,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 @@ -219,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 @@ -232,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: @@ -245,15 +252,17 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: class VCSStrategyPijul(VCSStrategy): """Strategy that is used for Pijul.""" + EXE = PIJUL_EXE + def __init__(self, project: Project): super().__init__(project) - if not PIJUL_EXE: - raise FileNotFoundError("Could not find binary for Mercurial") + 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(PIJUL_EXE), "list"] + 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} @@ -274,7 +283,7 @@ def in_repo(cls, directory: StrPath) -> bool: if not Path(directory).is_dir(): raise NotADirectoryError() - command = [str(PIJUL_EXE), "diff", "--short"] + command = [str(cls.EXE), "diff", "--short"] result = execute_command(command, _LOGGER, cwd=directory) return not result.returncode @@ -304,6 +313,17 @@ def find_root(cls, cwd: Optional[StrPath] = None) -> Optional[Path]: 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. @@ -311,16 +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 - if PIJUL_EXE: - root = VCSStrategyPijul.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 3ab3b8ef..77c6d25e 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.""" diff --git a/tests/test_main.py b/tests/test_main.py index 4b91d582..0ee42dd1 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.""" From 572d920695a937959b490cdcc5a78a1002633271 Mon Sep 17 00:00:00 2001 From: Korrat Date: Tue, 31 Oct 2023 17:43:13 +0100 Subject: [PATCH 3/6] Add tests for pijul --- tests/conftest.py | 25 +++++++++++++++++++++++++ tests/test_project.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_vcs.py | 10 ++++++++++ 3 files changed, 69 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 77c6d25e..e77ea618 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -232,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_project.py b/tests/test_project.py index aaaafb07..d856c607 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 a522abc9..97bebb6c 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 From 49279fcaa10a041931f79a0b378268f529228ba6 Mon Sep 17 00:00:00 2001 From: Korrat Date: Tue, 31 Oct 2023 17:45:18 +0100 Subject: [PATCH 4/6] Add changelog and author entries --- AUTHORS.rst | 2 ++ CHANGELOG.md | 1 + 2 files changed, 3 insertions(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 36eb9d5a..107ff7d2 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 1a8c7c07..0e81a1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ 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 (#858) ### Changed From ba0298bf663a9b875f3ee216aaabd348919065e1 Mon Sep 17 00:00:00 2001 From: Korrat Date: Tue, 31 Oct 2023 17:49:37 +0100 Subject: [PATCH 5/6] Install pijul for tests during CI --- .../workflows/license_list_up_to_date.yaml | 4 +- .github/workflows/pijul.yaml | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pijul.yaml diff --git a/.github/workflows/license_list_up_to_date.yaml b/.github/workflows/license_list_up_to_date.yaml index 0b75ee89..0c8add47 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 00000000..23d59ae8 --- /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 From 77b828fa4f198f1730416e90d7fa7f1acb212a2f Mon Sep 17 00:00:00 2001 From: Carmen Bianca BAKKER Date: Thu, 9 Nov 2023 10:59:36 +0100 Subject: [PATCH 6/6] Update README Signed-off-by: Carmen Bianca BAKKER --- CHANGELOG.md | 3 ++- README.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e81a1c8..38c3763f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +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 (#858) +- 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 c1e58778..ae550124 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