Source code for reuse.vcs

# SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. <https://fsfe.org>
# SPDX-FileCopyrightText: 2020 John Mulligan <jmulligan@redhat.com>
# SPDX-FileCopyrightText: 2023 Markus Haug <korrat@proton.me>
# SPDX-FileCopyrightText: 2024 Skyler Grey <sky@a.starrysky.fyi>
# SPDX-FileCopyrightText: 2025 Jonas Fierlings <fnoegip@gmail.com>
# SPDX-FileCopyrightText: © 2020 Liferay, Inc. <https://liferay.com>
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""This module deals with version control systems."""

from __future__ import annotations

import logging
import os
import shutil
from abc import ABC, abstractmethod
from collections.abc import Generator
from inspect import isclass
from pathlib import Path
from typing import TYPE_CHECKING

from ._util import execute_command, relative_from_root
from .types import StrPath

if TYPE_CHECKING:
    from .project import Project

_LOGGER = logging.getLogger(__name__)

GIT_EXE = shutil.which("git")
HG_EXE = shutil.which("hg")
JUJUTSU_EXE = shutil.which("jj")
PIJUL_EXE = shutil.which("pijul")


def _find_ancestor(
    directory: StrPath, ancestor: str, is_directory: bool = True
) -> Path | None:
    path = Path(directory).resolve()
    for parent in [path] + list(path.parents):
        if (parent / ancestor).is_dir() or (
            (parent / ancestor).exists() and not is_directory
        ):
            return parent / ancestor
    return None


[docs] class VCSStrategy(ABC): """Strategy pattern for version control systems.""" EXE: str | None = None def __init__(self, root: StrPath): self.root = Path(root)
[docs] @abstractmethod def is_ignored(self, path: Path) -> bool: """Is *path* ignored by the VCS?"""
[docs] @abstractmethod def is_submodule(self, path: StrPath) -> bool: """Is *path* a VCS submodule?"""
[docs] @classmethod @abstractmethod def in_repo(cls, directory: StrPath) -> bool: """Is *directory* inside of the VCS repository? Raises: NotADirectoryError: if directory is not a directory. """
[docs] @classmethod @abstractmethod def find_root(cls, cwd: StrPath | None = None) -> Path | None: """Try to find the root of the project from *cwd*. If none is found, return None. Raises: NotADirectoryError: if directory is not a directory. """
[docs] class VCSStrategyNone(VCSStrategy): """Strategy that is used when there is no VCS."""
[docs] def is_ignored(self, path: Path) -> bool: return False
[docs] def is_submodule(self, path: StrPath) -> bool: return False
[docs] @classmethod def in_repo(cls, directory: StrPath) -> bool: return False
[docs] @classmethod def find_root(cls, cwd: StrPath | None = None) -> Path | None: return None
[docs] class VCSStrategyGit(VCSStrategy): """Strategy that is used for Git.""" EXE = GIT_EXE def __init__(self, root: StrPath): super().__init__(root) 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() def _find_all_ignored_files(self) -> set[Path]: """Return a set of all files ignored by git. If a whole directory is ignored, don't return all files inside of it. """ command = [ str(self.EXE), "ls-files", "--exclude-standard", "--ignored", "--others", "--directory", # TODO: This flag is unexpected. I reported it as a bug in Git. # This flag---counter-intuitively---lists untracked directories # that contain ignored files. "--no-empty-directory", # Separate output with \0 instead of \n. "-z", ] result = execute_command(command, _LOGGER, cwd=self.root) all_files = result.stdout.decode("utf-8").split("\0") return {Path(file_) for file_ in all_files} def _find_submodules(self) -> set[Path]: command = [ str(self.EXE), "config", "-z", "--file", ".gitmodules", "--get-regexp", r"\.path$", ] result = execute_command(command, _LOGGER, cwd=self.root) # The final element may be an empty string. Filter it. submodule_entries = [ entry for entry in result.stdout.decode("utf-8").split("\0") if entry ] # Each entry looks a little like 'submodule.submodule.path\nmy_path'. return {Path(entry.splitlines()[1]) for entry in submodule_entries}
[docs] def is_ignored(self, path: Path) -> bool: path = relative_from_root(path, self.root) return path in self._all_ignored_files
[docs] def is_submodule(self, path: StrPath) -> bool: return any( relative_from_root(Path(path), self.root).resolve() == submodule_path.resolve() for submodule_path in self._submodules )
[docs] @classmethod def in_repo(cls, directory: StrPath) -> bool: if not Path(directory).is_dir(): raise NotADirectoryError() if _find_ancestor(directory, ".git", is_directory=False): command = [str(cls.EXE), "rev-parse", "--is-inside-work-tree"] result = execute_command(command, _LOGGER, cwd=directory) return not result.returncode return False
[docs] @classmethod def find_root(cls, cwd: StrPath | None = None) -> Path | None: if cwd is None: cwd = Path.cwd() if not Path(cwd).is_dir(): raise NotADirectoryError() command = [str(cls.EXE), "rev-parse", "--show-toplevel"] result = execute_command(command, _LOGGER, cwd=cwd) if not result.returncode: path = result.stdout.decode("utf-8")[:-1] return Path(os.path.relpath(path, cwd)) return None
[docs] class VCSStrategyHg(VCSStrategy): """Strategy that is used for Mercurial.""" EXE = HG_EXE def __init__(self, root: StrPath): super().__init__(root) if not self.EXE: raise FileNotFoundError("Could not find binary for Mercurial") self._all_ignored_files = self._find_all_ignored_files() def _find_all_ignored_files(self) -> set[Path]: """Return a set of all files ignored by mercurial. If a whole directory is ignored, don't return all files inside of it. """ command = [ str(self.EXE), "status", "--ignored", # terse is marked 'experimental' in the hg help but is documented # in the man page. It collapses the output of a dir containing only # ignored files to the ignored name like the git command does. # TODO: Re-enable this flag in the future. # "--terse=i", "--no-status", "--print0", ] result = execute_command(command, _LOGGER, cwd=self.root) all_files = result.stdout.decode("utf-8").split("\0") return {Path(file_) for file_ in all_files}
[docs] def is_ignored(self, path: Path) -> bool: path = relative_from_root(path, self.root) return path in self._all_ignored_files
[docs] def is_submodule(self, path: StrPath) -> bool: # TODO: Implement me. return False
[docs] @classmethod def in_repo(cls, directory: StrPath) -> bool: if not Path(directory).is_dir(): raise NotADirectoryError() if _find_ancestor(directory, ".hg"): command = [str(cls.EXE), "root"] result = execute_command(command, _LOGGER, cwd=directory) return not result.returncode return False
[docs] @classmethod def find_root(cls, cwd: StrPath | None = None) -> Path | None: if cwd is None: cwd = Path.cwd() if not Path(cwd).is_dir(): raise NotADirectoryError() command = [str(cls.EXE), "root"] result = execute_command(command, _LOGGER, cwd=cwd) if not result.returncode: path = result.stdout.decode("utf-8")[:-1] return Path(os.path.relpath(path, cwd)) return None
[docs] class VCSStrategyJujutsu(VCSStrategy): """Strategy that is used for Jujutsu.""" EXE = JUJUTSU_EXE def __init__(self, root: StrPath): super().__init__(root) if not self.EXE: raise FileNotFoundError("Could not find binary for Jujutsu") self._all_tracked_files = self._find_all_tracked_files() def _find_all_tracked_files(self) -> set[Path]: """ Return a set of all files tracked in the current jj revision """ version = self._version() # TODO: Remove the version check once most distributions ship jj 0.19.0 # or higher. if version is None or version >= (0, 19, 0): command = [str(self.EXE), "file", "list"] else: command = [str(self.EXE), "files"] result = execute_command(command, _LOGGER, cwd=self.root) all_files = result.stdout.decode("utf-8").split("\n") return {Path(file_) for file_ in all_files if file_} def _version(self) -> tuple[int, int, int] | None: """ Returns the (major, minor, patch) version of the jujutsu executable, or None if the version components cannot be determined. """ result = execute_command( [str(self.EXE), "--version"], _LOGGER, cwd=self.root ) lines = result.stdout.decode("utf-8").split("\n") # Output has the form `jj major.minor.patch[-hash]\n`. try: line = lines[0] version = line.split(" ")[-1] without_hash = version.split("-")[0] components = without_hash.split(".") return (int(components[0]), int(components[1]), int(components[2])) except (IndexError, ValueError) as e: _LOGGER.debug("unable to parse jj version: %s", e) return None
[docs] def is_ignored(self, path: Path) -> bool: path = relative_from_root(path, self.root) for tracked in self._all_tracked_files: if tracked.parts[: len(path.parts)] == path.parts: # We can't check only if the path is in our tracked files as we # must support directories as well as files # # We'll consider a directory "tracked" if there are any tracked # files inside it return False return True
[docs] def is_submodule(self, path: StrPath) -> bool: return False
[docs] @classmethod def in_repo(cls, directory: StrPath) -> bool: if not Path(directory).is_dir(): raise NotADirectoryError() if _find_ancestor(directory, ".jj"): command = [str(cls.EXE), "root"] result = execute_command(command, _LOGGER, cwd=directory) return not result.returncode return False
[docs] @classmethod def find_root(cls, cwd: StrPath | None = None) -> Path | None: if cwd is None: cwd = Path.cwd() if not Path(cwd).is_dir(): raise NotADirectoryError() command = [str(cls.EXE), "root"] result = execute_command(command, _LOGGER, cwd=cwd) if not result.returncode: path = result.stdout.decode("utf-8")[:-1] return Path(os.path.relpath(path, cwd)) return None
[docs] class VCSStrategyPijul(VCSStrategy): """Strategy that is used for Pijul.""" EXE = PIJUL_EXE def __init__(self, root: StrPath): super().__init__(root) 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.root) all_files = result.stdout.decode("utf-8").splitlines() return {Path(file_) for file_ in all_files}
[docs] def is_ignored(self, path: Path) -> bool: path = relative_from_root(path, self.root) return path not in self._all_tracked_files
[docs] def is_submodule(self, path: StrPath) -> bool: # not supported in pijul yet return False
[docs] @classmethod def in_repo(cls, directory: StrPath) -> bool: if not Path(directory).is_dir(): raise NotADirectoryError() if _find_ancestor(directory, ".pijul"): command = [str(cls.EXE), "diff", "--short"] result = execute_command(command, _LOGGER, cwd=directory) return not result.returncode return False
[docs] @classmethod def find_root(cls, cwd: StrPath | None = None) -> Path | None: 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() dot_pijul = _find_ancestor(path, ".pijul") if dot_pijul is not None: return dot_pijul.parent return None
[docs] def all_vcs_strategies() -> Generator[type[VCSStrategy]]: """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
[docs] def find_root(cwd: StrPath | None = None) -> Path | None: """Try to find the root of the project from *cwd*. If none is found, return None. Raises: NotADirectoryError: if directory is not a directory. """ for strategy in all_vcs_strategies(): if strategy.EXE: root = strategy.find_root(cwd=cwd) if root: return root return None