#!/usr/bin/env python3 # Libervia plugin to handle Git repositories # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from pathlib import Path from typing import Iterator, Literal, TYPE_CHECKING, cast from pydantic import BaseModel, ConfigDict, Field, RootModel from twisted.internet import defer from twisted.python.procutils import which from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger from libervia.backend.plugins.plugin_forge import Forge from libervia.backend.tools import utils from libervia.backend.tools.common import async_process if TYPE_CHECKING: from libervia.backend.core.main import LiberviaBackend log = getLogger(__name__) # Git formatting, cf.: https://git-scm.com/docs/git-log#_pretty_formats GIT_LOG_SEPARATOR = "%x00" GIT_LOG_FORMAT = GIT_LOG_SEPARATOR.join( [ # Full commit hash "%H", # Short commit hash "%h", # Author name "%an", # Author email "%ae", # Committer date in UNIX timestamp "%ct", # Subject (first line of commit message) "%s", # Body (rest of commit message) "%b", ] ) GIT_LOG_FORMAT += "%x1E" class CommitRequest(BaseModel): """Model to specify the commits to retrieve.""" model_config = ConfigDict(extra="forbid") after: str | None = Field( default=None, description="Get commits after this reference." ) before: str | None = Field( default=None, description="Get commits before this reference." ) limit: int = Field(default=20, description="Maximum number of commits to retrieve.") class Commit(BaseModel): """Information about a Git commit for history display""" id: str = Field(description="Commit hash (SHA)") short_id: str = Field(description="Short commit hash (7 characters)") author_name: str = Field(description="Name of the author") author_email: str = Field(description="Email of the author") commit_ts: int = Field(description="UNIX timestamp of the commit") message_title: str = Field(description="First line of the commit message") message: str = Field(description="Commit message") class Commits(RootModel): root: list[Commit] def __iter__(self) -> Iterator[Commit]: # type: ignore return iter(self.root) def __getitem__(self, item) -> Commit: return self.root[item] def __len__(self) -> int: return len(self.root) def append(self, item: Commit) -> None: self.root.append(item) def sort(self, key=None, reverse=False) -> None: self.root.sort(key=key, reverse=reverse) # type: ignore class File(BaseModel): """Metadata for a file or directory in a Git repository""" name: str type: Literal["file", "directory"] hash: str mode: int size: int | None = None class Files(RootModel): root: list[File] def __iter__(self) -> Iterator[File]: # type: ignore return iter(self.root) def __getitem__(self, item) -> str: return self.root[item] def __len__(self) -> int: return len(self.root) def append(self, item: File) -> None: self.root.append(item) def sort(self, key=None, reverse=False) -> None: self.root.sort(key=key, reverse=reverse) # type: ignore class InfoRequest(BaseModel): """Model to indicate which infos a requested in an info_get request.""" model_config = ConfigDict(extra="forbid") branches: bool = True last_commit: bool = False class InfoResult(BaseModel): branches: list[str] = Field(default_factory=list) last_commit: Commit | None = None PLUGIN_INFO = { C.PI_NAME: "Git Repository Handler", C.PI_IMPORT_NAME: "GIT_REPOS", C.PI_TYPE: C.PLUG_TYPE_MISC, C.PI_DEPENDENCIES: ["FORGE"], C.PI_MAIN: "GitHandler", C.PI_HANDLER: "no", C.PI_DESCRIPTION: _("""Handle Git Repositories"""), } class GitProtocol(async_process.CommandProtocol): """handle git commands""" name = "Git" command = None @classmethod def run(cls, path: Path, command: str, *args, **kwargs) -> defer.Deferred[bytes]: """Create a new MercurialRegisterProtocol and execute the given mercurial command. @param path: path to the repository @param command: git command to run @return: stdout of the command """ assert "path" not in kwargs kwargs["path"] = path d = super().run(command, *args, **kwargs) d.addErrback(utils.logError) return d class GitHandler: def __init__(self, host: "LiberviaBackend") -> None: log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.") try: GitProtocol.command = which("git")[0] except IndexError: raise exceptions.NotFound( _("Git executable not found, can't use Git handler.") ) self.host = host self._f = cast(Forge, host.plugins["FORGE"]) host.bridge.add_method( "git_files_get", ".plugin", in_sign="sssss", out_sign="s", method=self._files_get, async_=True, ) host.bridge.add_method( "git_infos_get", ".plugin", in_sign="ssss", out_sign="s", method=self._infos_get, async_=True, ) host.bridge.add_method( "git_file_content_get", ".plugin", in_sign="sssss", out_sign="s", method=self._git_file_content_get, async_=True, ) host.bridge.add_method( "git_history_get", ".plugin", in_sign="ssss", out_sign="s", method=self._git_history_get, async_=True, ) def get_path_from_repository(self, target_profile: str, repo: str) -> Path: try: profile_repositories = self._f.settings.repositories[target_profile] repo_data = next( r for r in profile_repositories if r.name.lower() == repo.lower() ) except (KeyError, StopIteration): raise exceptions.NotFound(f"Repository Unknown: {repo!r}") else: return repo_data.path def _files_get( self, target_profile: str, repository: str, branch: str, path: str, profile_key: str, ) -> defer.Deferred[str]: client = self.host.get_client(profile_key) repository_path = self.get_path_from_repository(target_profile, repository) d = defer.ensureDeferred( self.files_get( client, repository_path, branch, path, ) ) d.addCallback(lambda m: m.model_dump_json()) return d async def files_get( self, client: SatXMPPEntity, repository: Path, branch: str = "main", path: str = "", ) -> Files: """List files from a repository branch and path @param client: Client instance. @param repository: Repository to list files from. @param branch: Branch of the repository to use. @param path: Path to list. If not set, list root files. @return: files listing. """ output = await GitProtocol.run(repository, "ls-tree", "-l", f"{branch}:{path}") files = Files([]) for line in output.splitlines(): mode, type_, hash_, size, name = line.split(maxsplit=4) size_int = int(size) if size != b"-" else None file = File( name=name.decode(), type="file" if type_ == b"blob" else "directory", hash=hash_.decode(), mode=int(mode), size=size_int, ) files.append(file) files.sort(key=lambda f: (f.type != "directory", f.name.lower())) return files def _infos_get( self, target_profile: str, repository: str, info_request_s: str, profile_key: str ) -> defer.Deferred[str]: client = self.host.get_client(profile_key) repository_path = self.get_path_from_repository(target_profile, repository) info_request = InfoRequest.model_validate_json(info_request_s) d = defer.ensureDeferred(self.infos_get(client, repository_path, info_request)) d.addCallback(lambda info_result: info_result.model_dump_json()) d = cast(defer.Deferred[str], d) return d async def infos_get( self, client: SatXMPPEntity, repository: Path, info_request: InfoRequest ) -> InfoResult: """Get information about a Git repository. @param client: Client instance. @param repository: Path to the repository. @param info_request: Which information to retrieve. @return: Informations about the repository. """ branches = [] last_commit = None if info_request.branches: try: output = await GitProtocol.run(repository, "branch") output_str = output.decode() lines = output_str.splitlines() branches = [line.strip().lstrip("* ").strip() for line in lines] except Exception as e: log.warning(f"Could not retrieve branches for {repository}: {e}") if info_request.last_commit: try: output = await GitProtocol.run( repository, "log", "-1", f"--format={GIT_LOG_FORMAT}" ) commits = self._parse_git_log_output(output) if commits: last_commit = commits[0] except Exception as e: log.warning(f"Could not retrieve last commit data for {repository}: {e}") return InfoResult(branches=branches, last_commit=last_commit) def _git_file_content_get( self, target_profile: str, repository: str, branch: str, file_path: str, profile_key: str, ) -> defer.Deferred[str]: client = self.host.get_client(profile_key) repository_path = self.get_path_from_repository(target_profile, repository) d = defer.ensureDeferred( self.git_file_content_get(client, repository_path, branch, file_path) ) d.addCallback(lambda content: content) return d async def git_file_content_get( self, client: SatXMPPEntity, repository: Path, branch: str, file_path: str, ) -> str: """Get the content of a file from a Git repository. @param client: Client instance. @param repository: Path to the repository. @param branch: Branch of the repository to use. @param file_path: Path to the file within the repository. @return: Content of the file as a string. @raise exceptions.NotFound: If the file does not exist. """ output = await GitProtocol.run(repository, "show", f"{branch}:{file_path}") return output.decode() def _git_history_get( self, target_profile: str, repository: str, commit_request_s: str, profile_key: str, ) -> defer.Deferred[str]: client = self.host.get_client(profile_key) repository_path = self.get_path_from_repository(target_profile, repository) commit_request = CommitRequest.model_validate_json(commit_request_s) d = defer.ensureDeferred( self.git_history_get(client, repository_path, commit_request) ) d.addCallback(lambda commits: commits.model_dump_json()) d = cast(defer.Deferred[str], d) return d @staticmethod def _parse_git_log_output(output: bytes) -> Commits: """Parse git log output into Commit objects. @param output: Raw output from git log command. @return: Commits object containing parsed commit data. """ output_str = output.decode().strip() if not output_str: return Commits([]) commits = Commits([]) commit_blocks = output_str.split("\x1e") for block in commit_blocks: commit_data = block.strip().split("\x00") if len(commit_data) != 7: log.warning(f"Error while parsing commit, ignoring:\n{block!r}") continue else: commit = Commit( id=commit_data[0], short_id=commit_data[1], author_name=commit_data[2], author_email=commit_data[3], commit_ts=int(commit_data[4]), message_title=commit_data[5], message=commit_data[6], ) commits.append(commit) return commits async def git_history_get( self, client: SatXMPPEntity, repository: Path, commit_request: CommitRequest ) -> Commits: """Get the commit history of a repository. @param client: Client instance. @param repository: Path to the repository. @param commit_request: Requested commit and limit. @return: List of commits in reverse chronological order. """ extra = [] match (commit_request.after, commit_request.before): case (after, before) if after is not None and before is not None: extra.append(f"{after}..{before}^") case (_, before) if before is not None: extra.append(f"{before}^") case (after, _) if after is not None: # Git can't retrieve immediate children, so we first need to get commits # IDs until the limit. commit_ids = ( (await GitProtocol.run(repository, "rev-list", f"{after}..")) .decode() .split() ) if not commit_ids: return Commits([]) elif len(commit_ids) >= commit_request.limit: upper_limit = commit_ids[-commit_request.limit] else: upper_limit = commit_ids[0] extra.append(f"{after}..{upper_limit}") output = await GitProtocol.run( repository, "log", f"-{commit_request.limit}", f"--format={GIT_LOG_FORMAT}", *extra, ) return self._parse_git_log_output(output)