#!/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
