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