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.
462 lines
15 KiB
Python
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)
|