diff --git a/libervia/backend/plugins/plugin_forge_git.py b/libervia/backend/plugins/plugin_forge_git.py
index adc8d2c7..a132e34f 100644
--- a/libervia/backend/plugins/plugin_forge_git.py
+++ b/libervia/backend/plugins/plugin_forge_git.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usl/bin/env python3
# Libervia plugin to handle Git repositories
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
@@ -16,10 +16,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+import re
+from enum import StrEnum, auto
from pathlib import Path
-from typing import Iterator, Literal, TYPE_CHECKING, cast
+from typing import Iterator, TYPE_CHECKING, Literal, cast
-from pydantic import BaseModel, ConfigDict, Field, RootModel
+from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
from twisted.internet import defer
from twisted.python.procutils import which
@@ -58,6 +60,8 @@ GIT_LOG_FORMAT = GIT_LOG_SEPARATOR.join(
]
)
GIT_LOG_FORMAT += "%x1E"
+# Parse hunk header: "@@ -old_start,old_lines +new_start,new_lines @@"
+HUNK_MATCH_RE = re.compile(r"@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@")
class CommitRequest(BaseModel):
@@ -134,6 +138,147 @@ class Files(RootModel):
self.root.sort(key=key, reverse=reverse) # type: ignore
+class FileStatus(StrEnum):
+ """Status of a file in a diff"""
+
+ ADDED = auto()
+ DELETED = auto()
+ MODIFIED = auto()
+ RENAMED = auto()
+ COPIED = auto()
+ TYPE_CHANGED = auto()
+ UNTRACKED = auto()
+
+
+class DiffHunk(BaseModel):
+ """A single hunk in a diff"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ old_start: int = Field(description="Starting line number in old file")
+ old_lines: int = Field(description="Number of lines in old file")
+ new_start: int = Field(description="Starting line number in new file")
+ new_lines: int = Field(description="Number of lines in new file")
+ lines: list[str] = Field(description="Lines in the hunk (with +/- prefix)")
+
+
+class DiffFile(BaseModel):
+ """A file changed in a commit"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ old_path: str = Field(description="Path of the file before changes")
+ new_path: str = Field(description="Path of the file after changes")
+ old_mode: str = Field(description="File mode before changes (e.g., 100644)")
+ new_mode: str = Field(description="File mode after changes (e.g., 100644)")
+ old_hash: str | None = Field(
+ default=None, description="Git hash before changes (None for new files)"
+ )
+ new_hash: str | None = Field(
+ default=None, description="Git hash after changes (None for deleted files)"
+ )
+ status: FileStatus = Field(description="File status")
+ hunks: list[DiffHunk] = Field(default_factory=list, description="Diff hunks")
+
+
+class DiffFiles(RootModel):
+ root: list[DiffFile]
+
+ def __iter__(self) -> Iterator[DiffFile]: # type: ignore
+ return iter(self.root)
+
+ def __getitem__(self, item) -> DiffFile:
+ return self.root[item]
+
+ def __len__(self) -> int:
+ return len(self.root)
+
+ def append(self, item: DiffFile) -> None:
+ self.root.append(item)
+
+
+class CommitDiff(BaseModel):
+ """Diff information for a single commit"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ commit: Commit = Field(description="Commit information")
+ files: DiffFiles = Field(description="Files changed in this commit")
+
+
+class CommitDiffs(RootModel):
+ root: list[CommitDiff]
+
+ def __iter__(self) -> Iterator[CommitDiff]: # type: ignore
+ return iter(self.root)
+
+ def __getitem__(self, item) -> CommitDiff:
+ return self.root[item]
+
+ def __len__(self) -> int:
+ return len(self.root)
+
+ def append(self, item: CommitDiff) -> None:
+ self.root.append(item)
+
+
+class CommitDiffRequest(BaseModel):
+ """Model to specify the commits to retrieve diffs for.
+
+ Supports different use cases:
+ - Single commit: provide commit_hash only
+ - Commit range: provide after and before
+ - Merge request: provide merge_request ref to get all commits in the MR
+ - Branch history: provide before only to get commits up to a branch
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ commit_hash: str | None = Field(
+ default=None, description="Single commit hash to get diff for."
+ )
+ merge_request: str | None = Field(
+ default=None,
+ description='Merge request ref (e.g., "mr/42") to get all commits.',
+ )
+ after: str | None = Field(
+ default=None,
+ description="Get commits after this reference (e.g., base branch for MR).",
+ )
+ before: str | None = Field(
+ default=None,
+ description="Get commits before this reference (e.g., MR branch or commit).",
+ )
+ limit: int = Field(default=50, description="Maximum number of commits to retrieve.")
+
+ @field_validator("commit_hash")
+ @classmethod
+ def validate_commit_hash_exclusive(cls, v: str | None, info):
+ """commit_hash cannot be used with other fields."""
+ if v is not None:
+ data = info.data
+ conflicting_fields = ["merge_request", "after", "before"]
+ if any(data.get(field) is not None for field in conflicting_fields):
+ raise ValueError(
+ "commit_hash cannot be used together with"
+ f" {', '.join(conflicting_fields)}"
+ )
+ return v
+
+ @field_validator("merge_request")
+ @classmethod
+ def validate_merge_request_exclusive(cls, v: str | None, info):
+ """merge_request is a standalone option."""
+ if v is not None:
+ data = info.data
+ conflicting_fields = ["after", "before"]
+ if any(data.get(field) is not None for field in conflicting_fields):
+ raise ValueError(
+ "merge_request cannot be used together with after/before range"
+ )
+ return v
+
+
class InfoRequest(BaseModel):
"""Model to indicate which infos a requested in an info_get request."""
@@ -224,6 +369,14 @@ class GitHandler:
method=self._git_history_get,
async_=True,
)
+ host.bridge.add_method(
+ "git_diff_get",
+ ".plugin",
+ in_sign="ssss",
+ out_sign="s",
+ method=self._git_diff_get,
+ async_=True,
+ )
def get_path_from_repository(self, target_profile: str, repo: str) -> Path:
try:
@@ -386,6 +539,31 @@ class GitHandler:
d = cast(defer.Deferred[str], d)
return d
+ @staticmethod
+ def _parse_commit_data(commit_bytes: bytes) -> Commit:
+ """Parse commit data from bytes into a Commit object.
+
+ @param commit_bytes: Raw commit data.
+ @return: Parsed Commit object.
+ @raise exceptions.DataError: If the data doesn't have exactly 7 fields.
+ """
+ commit_data = commit_bytes.decode().split("\x00")
+ if len(commit_data) != 7:
+ raise exceptions.DataError(
+ "Failed to parse commit information (expected 7 fields, got"
+ f" {len(commit_data)})"
+ )
+
+ return 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],
+ )
+
@staticmethod
def _parse_git_log_output(output: bytes) -> Commits:
"""Parse git log output into Commit objects.
@@ -401,21 +579,12 @@ class GitHandler:
commit_blocks = output_str.split("\x1e")
for block in commit_blocks:
- commit_data = block.strip().split("\x00")
- if len(commit_data) != 7:
+ try:
+ commit = GitHandler._parse_commit_data(block.strip().encode())
+ commits.append(commit)
+ except exceptions.DataError:
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
@@ -460,3 +629,264 @@ class GitHandler:
*extra,
)
return self._parse_git_log_output(output)
+
+ @staticmethod
+ def _parse_git_diff_output(output: bytes) -> tuple[Commit, DiffFiles]:
+ """Parse git show output into model instances.
+
+ @param output: Raw output from git show.
+ @return: Commit and DiffFiles model instances.
+ """
+ try:
+ commit_part, diff_part = output.split(b"\x1e", maxsplit=1)
+ except ValueError:
+ raise exceptions.InternalError(
+ "Failed to parse commit information: missing separator"
+ )
+
+ commit = GitHandler._parse_commit_data(commit_part)
+
+ # Parse diff files (skip the \n\n after the terminator)
+ diff_str = diff_part.decode().lstrip("\n")
+ diff_lines = diff_str.splitlines()
+ diff_files = DiffFiles([])
+ current_file: DiffFile | None = None
+ current_hunk: DiffHunk | None = None
+
+ for line in diff_lines:
+ if line.startswith("diff --git"):
+ # Save previous file if exists
+ if current_file is not None:
+ if current_hunk is not None:
+ current_file.hunks.append(current_hunk)
+ diff_files.append(current_file)
+
+ # Parse new file header
+ # Format: diff --git a/old_path b/new_path
+ parts = line.split()
+ if len(parts) >= 4:
+ old_path = parts[2].removeprefix("a/").removeprefix("b/")
+ new_path = parts[3].removeprefix("a/").removeprefix("b/")
+ else:
+ old_path = ""
+ new_path = ""
+
+ current_file = DiffFile(
+ old_path=old_path,
+ new_path=new_path,
+ old_mode="",
+ new_mode="",
+ old_hash=None,
+ new_hash=None,
+ status=FileStatus.MODIFIED,
+ hunks=[],
+ )
+ current_hunk = None
+
+ elif current_file is None:
+ # Skip lines before any diff --git header
+ continue
+
+ elif line.startswith("old mode "):
+ current_file.old_mode = line.split()[1]
+
+ elif line.startswith("new mode "):
+ current_file.new_mode = line.split()[1]
+
+ elif line.startswith("deleted file mode "):
+ current_file.old_mode = line.split()[1]
+ current_file.status = FileStatus.DELETED
+
+ elif line.startswith("new file mode "):
+ current_file.new_mode = line.split()[1]
+ current_file.status = FileStatus.ADDED
+
+ elif line.startswith("index "):
+ # Parse hashes from index line: "index .. "
+ idx_parts = line.split()[1:]
+ if idx_parts:
+ hashes = idx_parts[0].split("..")
+ if len(hashes) == 2:
+ current_file.old_hash = (
+ hashes[0] if hashes[0] != "0000000" else None
+ )
+ current_file.new_hash = (
+ hashes[1] if hashes[1] != "0000000" else None
+ )
+
+ elif line.startswith("similarity index"):
+ # Renamed or copied file
+ current_file.status = (
+ FileStatus.RENAMED if "rename" in line else FileStatus.COPIED
+ )
+
+ elif line.startswith("rename from"):
+ current_file.status = FileStatus.RENAMED
+ current_file.old_path = line.split()[2]
+
+ elif line.startswith("rename to"):
+ current_file.new_path = line.split()[2]
+
+ elif line.startswith("copy from"):
+ current_file.old_path = line.split()[2]
+
+ elif line.startswith("copy to"):
+ current_file.new_path = line.split()[2]
+
+ elif line.startswith("--- "):
+ # Update paths from unified diff header if not already set
+ path = line[4:].strip()
+ if not path.startswith("dev/null"):
+ if not (
+ current_file.old_path and current_file.status == FileStatus.ADDED
+ ):
+ if not current_file.old_path:
+ current_file.old_path = path.rstrip("\t...")
+
+ elif line.startswith("+++ "):
+ # Update paths from unified diff header if not already set
+ path = line[4:].strip()
+ if not path.startswith("dev/null"):
+ if not (
+ current_file.new_path
+ and current_file.status == FileStatus.DELETED
+ ):
+ if not current_file.new_path:
+ current_file.new_path = path.rstrip("\t...")
+
+ elif line.startswith("@@"):
+ # New hunk
+ if current_hunk is not None:
+ current_file.hunks.append(current_hunk)
+
+ hunk_header = line.strip()
+ hunk_match = HUNK_MATCH_RE.match(hunk_header)
+ if hunk_match:
+ old_start = int(hunk_match.group(1))
+ old_lines = int(hunk_match.group(2)) if hunk_match.group(2) else 1
+ new_start = int(hunk_match.group(3))
+ new_lines = int(hunk_match.group(4)) if hunk_match.group(4) else 1
+
+ current_hunk = DiffHunk(
+ old_start=old_start,
+ old_lines=old_lines,
+ new_start=new_start,
+ new_lines=new_lines,
+ lines=[],
+ )
+ else:
+ log.warning(f"Failed to parse hunk header: {line!r}")
+
+ elif current_hunk is not None:
+ # Diff line (must be last case to catch +, -, or space prefixed lines)
+ if line.startswith(("+", "-", " ")):
+ current_hunk.lines.append(line)
+ elif line == "":
+ # Empty line in diff
+ if current_hunk.lines and not current_hunk.lines[-1].startswith(
+ ("+", "-", " ")
+ ):
+ current_hunk.lines.append(" ")
+
+ # Save last file and hunk
+ if current_file is not None:
+ if current_hunk is not None:
+ current_file.hunks.append(current_hunk)
+ diff_files.append(current_file)
+
+ return commit, diff_files
+
+ def _git_diff_get(
+ self,
+ target_profile: str,
+ repository: str,
+ commit_diff_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_diff_request = CommitDiffRequest.model_validate_json(commit_diff_request_s)
+ d = defer.ensureDeferred(
+ self.git_diff_get(client, repository_path, commit_diff_request)
+ )
+ d.addCallback(lambda diffs: diffs.model_dump_json())
+ d = cast(defer.Deferred[str], d)
+ return d
+
+ async def git_diff_get(
+ self,
+ client: SatXMPPEntity,
+ repository: Path,
+ commit_diff_request: CommitDiffRequest,
+ ) -> CommitDiffs:
+ """Get the diff for commits in a repository.
+
+ @param client: Client instance.
+ @param repository: Path to the repository.
+ @param commit_diff_request: Requested commits and limit.
+ @return: List of commit diffs in reverse chronological order.
+ """
+ # Handle single commit hash
+ if commit_diff_request.commit_hash is not None:
+ try:
+ output = await GitProtocol.run(
+ repository,
+ "show",
+ "-m",
+ f"--format={GIT_LOG_FORMAT}",
+ commit_diff_request.commit_hash,
+ )
+ parsed_commit, diff_files = self._parse_git_diff_output(output)
+ return CommitDiffs([CommitDiff(commit=parsed_commit, files=diff_files)])
+ except Exception as e:
+ log.warning(
+ "Could not retrieve diff for commit"
+ f" {commit_diff_request.commit_hash}: {e}"
+ )
+ return CommitDiffs([])
+
+ # Handle merge request ref (e.g., "mr/42")
+ if commit_diff_request.merge_request is not None:
+ try:
+ # Get all commits in the merge request branch
+ output = await GitProtocol.run(
+ repository,
+ "log",
+ f"-{commit_diff_request.limit}",
+ f"--format={GIT_LOG_FORMAT}",
+ commit_diff_request.merge_request,
+ )
+ commits = self._parse_git_log_output(output)
+ except Exception as e:
+ log.warning(
+ "Could not retrieve commits for merge request"
+ f" {commit_diff_request.merge_request}: {e}"
+ )
+ return CommitDiffs([])
+ else:
+ # Handle commit range (after..before) or branch history (before only)
+ # Convert CommitDiffRequest to CommitRequest for git_history_get
+ commit_request = CommitRequest(
+ after=commit_diff_request.after,
+ before=commit_diff_request.before,
+ limit=commit_diff_request.limit,
+ )
+
+ commits = await self.git_history_get(client, repository, commit_request)
+ if not commits:
+ return CommitDiffs([])
+
+ commit_diffs = CommitDiffs([])
+ for commit in commits:
+ try:
+ output = await GitProtocol.run(
+ repository, "show", "-m", f"--format={GIT_LOG_FORMAT}", commit.id
+ )
+ parsed_commit, diff_files = self._parse_git_diff_output(output)
+ commit_diff = CommitDiff(commit=parsed_commit, files=diff_files)
+ commit_diffs.append(commit_diff)
+ except Exception as e:
+ log.warning(f"Could not retrieve diff for commit {commit.id}: {e}")
+ continue
+
+ return commit_diffs