Source code for ralph.mcp.tools.git_read

"""MCP Git read tool handlers.

Ports the Rust MCP Git read tools so agents can inspect repository state
through bounded read-only git commands from the workspace root.
"""

from __future__ import annotations

import subprocess
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable

if TYPE_CHECKING:
    from collections.abc import Mapping

from ralph.mcp.tools._git_diff_params import GitDiffParams
from ralph.mcp.tools._git_execution_error import ExecutionError
from ralph.mcp.tools._git_log_params import GitLogParams
from ralph.mcp.tools._git_show_params import GitShowParams
from ralph.mcp.tools.coordination import (
    CoordinationSessionLike,
    InvalidParamsError,
    ToolContent,
    ToolResult,
    require_capability,
)
from ralph.process.manager import SpawnOptions, get_process_manager

GIT_STATUS_READ_CAPABILITY = "GitStatusRead"
GIT_DIFF_READ_CAPABILITY = "GitDiffRead"
DEFAULT_LOG_COUNT = 10
type GitRunner = Callable[[list[str], Path], subprocess.CompletedProcess[bytes]]
type CwdProvider = Callable[[], Path]


[docs] @runtime_checkable class WorkspaceWithRoot(Protocol): """Workspace surface required for git command execution.""" @property def root(self) -> Path: """Return the absolute workspace root path.""" ...
def _workspace_root(workspace: object, *, cwd_provider: CwdProvider = Path.cwd) -> Path: if isinstance(workspace, WorkspaceWithRoot): return workspace.root root_value = cast("Path | str | None", getattr(workspace, "root", None)) if isinstance(root_value, Path): return root_value if isinstance(root_value, str): return Path(root_value) return cwd_provider()
[docs] def parse_git_diff_params(params: Mapping[str, object]) -> GitDiffParams: """Parse git diff params, keeping only string arguments.""" args_value = params.get("args") args = ( [value for value in args_value if isinstance(value, str)] if isinstance(args_value, list) else [] ) return GitDiffParams(args=args)
[docs] def parse_git_log_params(params: Mapping[str, object]) -> GitLogParams: """Parse git log params with the Rust default count.""" count_value = params.get("count", DEFAULT_LOG_COUNT) count = count_value if isinstance(count_value, int) and count_value >= 0 else DEFAULT_LOG_COUNT return GitLogParams(count=count)
[docs] def parse_git_show_params(params: Mapping[str, object]) -> GitShowParams: """Parse git show params.""" ref_value = params.get("ref") if not isinstance(ref_value, str): raise InvalidParamsError("Missing 'ref' parameter") return GitShowParams(git_ref=ref_value)
def _decode_output(data: bytes) -> str: return data.decode("utf-8", errors="replace")
[docs] def run_git_command( workspace: object, args: list[str], *, runner: GitRunner | None = None, cwd_provider: CwdProvider = Path.cwd, ) -> str: """Execute git and require a successful exit status.""" git_runner = runner or _run_git_subprocess try: output = git_runner(["git", *args], _workspace_root(workspace, cwd_provider=cwd_provider)) except FileNotFoundError as exc: raise ExecutionError(f"Failed to execute git: {exc}") from exc except PermissionError as exc: raise ExecutionError(f"Failed to execute git: {exc}") from exc except OSError as exc: raise ExecutionError(f"Failed to execute git: {exc}") from exc stdout = _decode_output(output.stdout) stderr = _decode_output(output.stderr) if output.returncode != 0: raise ExecutionError(f"git command failed: {stderr}") return stdout
[docs] def run_git_command_lenient( workspace: object, args: list[str], *, runner: GitRunner | None = None, cwd_provider: CwdProvider = Path.cwd, ) -> str: """Execute git and return combined stdout/stderr regardless of exit code.""" git_runner = runner or _run_git_subprocess try: output = git_runner(["git", *args], _workspace_root(workspace, cwd_provider=cwd_provider)) except FileNotFoundError as exc: raise ExecutionError(f"Failed to execute git: {exc}") from exc except PermissionError as exc: raise ExecutionError(f"Failed to execute git: {exc}") from exc except OSError as exc: raise ExecutionError(f"Failed to execute git: {exc}") from exc return f"{_decode_output(output.stdout)}{_decode_output(output.stderr)}"
def _run_git_subprocess(command: list[str], cwd: Path) -> subprocess.CompletedProcess[bytes]: proc = get_process_manager().spawn( command, SpawnOptions( cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, label="git-mcp-read", ), ) stdout, stderr = proc.communicate() returncode = proc.returncode if proc.returncode is not None else 0 return subprocess.CompletedProcess(command, returncode, stdout or b"", stderr or b"")
[docs] def handle_git_status( session: CoordinationSessionLike, workspace: object, _params: Mapping[str, object], ) -> ToolResult: """Read the git status of the workspace.""" require_capability(session, GIT_STATUS_READ_CAPABILITY, "Git status") output = run_git_command(workspace, ["status"]) return ToolResult(content=[ToolContent.text_content(output)], is_error=False)
[docs] def handle_git_diff( session: CoordinationSessionLike, workspace: object, params: Mapping[str, object], ) -> ToolResult: """Read the git diff of the workspace.""" require_capability(session, GIT_DIFF_READ_CAPABILITY, "Git diff") parsed = parse_git_diff_params(params) output = run_git_command_lenient(workspace, ["diff", *parsed.args]) return ToolResult(content=[ToolContent.text_content(output)], is_error=False)
[docs] def handle_git_log( session: CoordinationSessionLike, workspace: object, params: Mapping[str, object], ) -> ToolResult: """Read the git commit log.""" require_capability(session, GIT_STATUS_READ_CAPABILITY, "Git log") parsed = parse_git_log_params(params) output = run_git_command(workspace, ["log", f"-{parsed.count}", "--oneline"]) return ToolResult(content=[ToolContent.text_content(output)], is_error=False)
[docs] def handle_git_show( session: CoordinationSessionLike, workspace: object, params: Mapping[str, object], ) -> ToolResult: """Show a git object by ref.""" require_capability(session, GIT_STATUS_READ_CAPABILITY, "Git show") parsed = parse_git_show_params(params) output = run_git_command(workspace, ["show", parsed.git_ref]) return ToolResult(content=[ToolContent.text_content(output)], is_error=False)
__all__ = [ "GIT_DIFF_READ_CAPABILITY", "GIT_STATUS_READ_CAPABILITY", "ExecutionError", "GitDiffParams", "GitLogParams", "GitShowParams", "WorkspaceWithRoot", "handle_git_diff", "handle_git_log", "handle_git_show", "handle_git_status", "parse_git_diff_params", "parse_git_log_params", "parse_git_show_params", "run_git_command", "run_git_command_lenient", ]