diff --git a/libervia/backend/plugins/plugin_forge.py b/libervia/backend/plugins/plugin_forge.py
new file mode 100644
index 00000000..6c3428ae
--- /dev/null
+++ b/libervia/backend/plugins/plugin_forge.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+# Libervia plugin to handle forges.
+# 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 typing import Iterator
+
+from pydantic import BaseModel, Field, RootModel
+from twisted.internet import defer
+
+from libervia.backend import G
+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.tools.config import DirectoryPath, PluginSettings
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+ C.PI_NAME: "Forge",
+ C.PI_IMPORT_NAME: "FORGE",
+ C.PI_TYPE: C.PLUG_TYPE_MISC,
+ C.PI_DEPENDENCIES: [],
+ C.PI_MAIN: "Forge",
+ C.PI_HANDLER: "no",
+ C.PI_DESCRIPTION: _("""Forge for developping projects."""),
+}
+
+
+class Repository(BaseModel):
+ name: str
+ path: DirectoryPath
+
+
+class Repositories(RootModel):
+ root: list[Repository]
+
+ def __iter__(self) -> Iterator[Repository]: # type: ignore
+ return iter(self.root)
+
+ def __len__(self) -> int:
+ return len(self.root)
+
+
+class ProfileRepositories(RootModel):
+ root: dict[str, Repositories]
+
+ def __getitem__(self, item: str) -> Repositories:
+ return self.root[item]
+
+
+class DefaultRepository(BaseModel):
+ profile: str
+ repository: str
+
+
+class PluginForgeSettings(PluginSettings, plugin_info=PLUGIN_INFO):
+ repositories: ProfileRepositories = Field(
+ default_factory=lambda: ProfileRepositories({})
+ )
+ default: DefaultRepository | None = None
+
+
+class Forge:
+ def __init__(self, host):
+ log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.")
+ self.settings = G.config.get_settings(PluginForgeSettings)
+ self.host = host
+
+ host.bridge.add_method(
+ "forge_repositories_get",
+ ".plugin",
+ in_sign="sss",
+ out_sign="s",
+ method=self._repositories_get,
+ async_=True,
+ )
+ host.bridge.add_method(
+ "forge_default_repository_get",
+ ".plugin",
+ in_sign="",
+ out_sign="s",
+ method=self._default_repository_get,
+ async_=True,
+ )
+
+ def _repositories_get(
+ self, target_profile: str, data_s: str, profile: str
+ ) -> defer.Deferred[str]:
+ client = self.host.get_client(profile)
+ repositories = self.repositories_get(client, target_profile)
+ return defer.succeed(repositories.model_dump_json())
+
+ def repositories_get(
+ self, client: SatXMPPEntity, target_profile: str, data: dict | None = None
+ ) -> Repositories:
+ try:
+ return self.settings.repositories[target_profile]
+ except KeyError:
+ raise exceptions.NotFound(f"No repository found for {target_profile!r}.")
+
+ def _default_repository_get(self) -> defer.Deferred[str]:
+ default = self.settings.default
+ if default is None:
+ raise exceptions.NotFound("No default repository set.")
+ else:
+ return defer.succeed(default.model_dump_json())
diff --git a/libervia/backend/plugins/plugin_forge_git.py b/libervia/backend/plugins/plugin_forge_git.py
new file mode 100644
index 00000000..adc8d2c7
--- /dev/null
+++ b/libervia/backend/plugins/plugin_forge_git.py
@@ -0,0 +1,462 @@
+#!/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)