plugin forge/git: Implement diff retrieval:

Add models and parser to get diff from commits or branch references.
This commit is contained in:
Goffi 2026-03-04 12:32:48 +01:00
commit f17fee4802

View file

@ -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 <http://www.gnu.org/licenses/>.
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 <old_hash>..<new_hash> <mode>"
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