libervia-backend/libervia/backend/plugins/plugin_forge_git.py
Goffi c297da7ec9 plugin (forge, forge_git): forge plugin first draft:
This plugin implements the base for the forge feature in Libervia.

The "forge" plugin introduces configuration to set repositories to expose with the forge
(per profile), and the default repository to show.
In the future, this may be done dynamically from frontends, so users can create/import
repositories themselves.

The "forge_git" plugin implements methods to handle Git repositories. In the future, other
types of repositories may be managed (e.g.; Mercurial), but for now the focus is on Git.
2026-02-18 17:49:36 +01:00

462 lines
15 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
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)