892 lines
31 KiB
Python
892 lines
31 KiB
Python
#!/usl/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 <http://www.gnu.org/licenses/>.
|
|
|
|
import re
|
|
from enum import StrEnum, auto
|
|
from pathlib import Path
|
|
from typing import Iterator, TYPE_CHECKING, Literal, cast
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
|
|
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"
|
|
# 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):
|
|
"""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 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."""
|
|
|
|
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,
|
|
)
|
|
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:
|
|
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_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.
|
|
|
|
@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:
|
|
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
|
|
|
|
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)
|
|
|
|
@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
|