plugin forge/git: Implement diff retrieval:
Add models and parser to get diff from commits or branch references.
This commit is contained in:
parent
48d197459a
commit
f17fee4802
1 changed files with 446 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue