forked from cardosofelipe/fast-next-template
Implements the Git Operations MCP server (Issue #58) providing: Core features: - GitPython wrapper for local repository operations (clone, commit, push, pull, diff, log) - Branch management (create, delete, list, checkout) - Workspace isolation per project with file-based locking - Gitea provider for remote PR operations MCP Tools (17 registered): - clone_repository, git_status, create_branch, list_branches - checkout, commit, push, pull, diff, log - create_pull_request, get_pull_request, list_pull_requests - merge_pull_request, get_workspace, lock_workspace, unlock_workspace Technical details: - FastMCP + FastAPI with JSON-RPC 2.0 protocol - pydantic-settings for configuration (env prefix: GIT_OPS_) - Comprehensive error hierarchy with structured codes - 131 tests passing with 67% coverage - Async operations via ThreadPoolExecutor Closes: #105, #106, #107, #108, #109 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1113 lines
34 KiB
Python
1113 lines
34 KiB
Python
"""
|
|
Git operations wrapper using GitPython.
|
|
|
|
Provides high-level git operations with proper error handling,
|
|
async compatibility, and structured results.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import UTC, datetime
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
|
|
from git import Repo as GitRepo
|
|
|
|
from config import Settings, get_settings
|
|
from exceptions import (
|
|
BranchExistsError,
|
|
BranchNotFoundError,
|
|
CheckoutError,
|
|
CloneError,
|
|
CommitError,
|
|
DirtyWorkspaceError,
|
|
GitError,
|
|
MergeConflictError,
|
|
PullError,
|
|
PushError,
|
|
)
|
|
from models import (
|
|
BranchInfo,
|
|
BranchResult,
|
|
CheckoutResult,
|
|
CloneResult,
|
|
CommitInfo,
|
|
CommitResult,
|
|
DiffHunk,
|
|
DiffResult,
|
|
FileChange,
|
|
FileChangeType,
|
|
FileDiff,
|
|
ListBranchesResult,
|
|
LogResult,
|
|
PullResult,
|
|
PushResult,
|
|
StatusResult,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Thread pool for blocking git operations
|
|
_executor: ThreadPoolExecutor | None = None
|
|
|
|
|
|
def get_executor() -> ThreadPoolExecutor:
|
|
"""Get the shared thread pool executor."""
|
|
global _executor
|
|
if _executor is None:
|
|
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="git-ops-")
|
|
return _executor
|
|
|
|
|
|
async def run_in_executor(func: Any, *args: Any, **kwargs: Any) -> Any:
|
|
"""Run a blocking function in the thread pool."""
|
|
loop = asyncio.get_event_loop()
|
|
executor = get_executor()
|
|
partial_func = partial(func, *args, **kwargs)
|
|
return await loop.run_in_executor(executor, partial_func)
|
|
|
|
|
|
class GitWrapper:
|
|
"""
|
|
Wrapper for git operations using GitPython.
|
|
|
|
Provides async-compatible git operations with proper error handling.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
workspace_path: Path,
|
|
settings: Settings | None = None,
|
|
) -> None:
|
|
"""
|
|
Initialize GitWrapper.
|
|
|
|
Args:
|
|
workspace_path: Path to the git workspace
|
|
settings: Optional settings override
|
|
"""
|
|
self.workspace_path = workspace_path
|
|
self.settings = settings or get_settings()
|
|
self._repo: GitRepo | None = None
|
|
|
|
@property
|
|
def repo(self) -> GitRepo:
|
|
"""Get the GitPython Repo instance."""
|
|
if self._repo is None:
|
|
try:
|
|
self._repo = GitRepo(self.workspace_path)
|
|
except InvalidGitRepositoryError:
|
|
raise GitError(f"Not a git repository: {self.workspace_path}")
|
|
except NoSuchPathError:
|
|
raise GitError(f"Path does not exist: {self.workspace_path}")
|
|
return self._repo
|
|
|
|
def _refresh_repo(self) -> None:
|
|
"""Refresh the repo instance after operations that change it."""
|
|
self._repo = None
|
|
|
|
# Clone operations
|
|
|
|
async def clone(
|
|
self,
|
|
repo_url: str,
|
|
branch: str | None = None,
|
|
depth: int | None = None,
|
|
auth_token: str | None = None,
|
|
) -> CloneResult:
|
|
"""
|
|
Clone a repository.
|
|
|
|
Args:
|
|
repo_url: Repository URL to clone
|
|
branch: Branch to checkout after clone
|
|
depth: Shallow clone depth (None for full)
|
|
auth_token: Optional auth token for HTTPS
|
|
|
|
Returns:
|
|
CloneResult with clone status
|
|
"""
|
|
|
|
def _do_clone() -> CloneResult:
|
|
try:
|
|
# Build clone URL with auth if provided
|
|
clone_url = repo_url
|
|
if auth_token and repo_url.startswith("https://"):
|
|
# Insert token in URL: https://token@host/path
|
|
clone_url = re.sub(
|
|
r"^(https://)(.+)$",
|
|
rf"\1{auth_token}@\2",
|
|
repo_url,
|
|
)
|
|
|
|
# Build clone arguments
|
|
kwargs: dict[str, Any] = {
|
|
"url": clone_url,
|
|
"to_path": str(self.workspace_path),
|
|
}
|
|
|
|
if branch:
|
|
kwargs["branch"] = branch
|
|
|
|
if depth:
|
|
kwargs["depth"] = depth
|
|
|
|
# Set environment for auth
|
|
env = os.environ.copy()
|
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
|
|
logger.info(f"Cloning repository: {repo_url} -> {self.workspace_path}")
|
|
|
|
repo = GitRepo.clone_from(**kwargs)
|
|
self._repo = repo
|
|
|
|
return CloneResult(
|
|
success=True,
|
|
project_id="", # Set by caller
|
|
workspace_path=str(self.workspace_path),
|
|
branch=repo.active_branch.name,
|
|
commit_sha=repo.head.commit.hexsha,
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
logger.error(f"Clone failed: {e}")
|
|
raise CloneError(repo_url, str(e))
|
|
|
|
return await run_in_executor(_do_clone)
|
|
|
|
# Status operations
|
|
|
|
async def status(self, include_untracked: bool = True) -> StatusResult:
|
|
"""
|
|
Get git status.
|
|
|
|
Args:
|
|
include_untracked: Include untracked files
|
|
|
|
Returns:
|
|
StatusResult with working tree status
|
|
"""
|
|
|
|
def _get_status() -> StatusResult:
|
|
repo = self.repo
|
|
|
|
# Get staged changes
|
|
staged = []
|
|
for diff in repo.index.diff("HEAD"):
|
|
change_type = self._diff_to_change_type(diff.change_type)
|
|
staged.append(
|
|
FileChange(
|
|
path=diff.b_path or diff.a_path,
|
|
change_type=change_type,
|
|
old_path=diff.a_path if diff.renamed else None,
|
|
).to_dict()
|
|
)
|
|
|
|
# Get unstaged changes
|
|
unstaged = []
|
|
for diff in repo.index.diff(None):
|
|
change_type = self._diff_to_change_type(diff.change_type)
|
|
unstaged.append(
|
|
FileChange(
|
|
path=diff.b_path or diff.a_path,
|
|
change_type=change_type,
|
|
).to_dict()
|
|
)
|
|
|
|
# Get untracked files
|
|
untracked = list(repo.untracked_files) if include_untracked else []
|
|
|
|
# Get tracking info
|
|
ahead = behind = 0
|
|
try:
|
|
tracking = repo.active_branch.tracking_branch()
|
|
if tracking:
|
|
ahead = len(
|
|
list(repo.iter_commits(f"{tracking.name}..{repo.active_branch.name}"))
|
|
)
|
|
behind = len(
|
|
list(repo.iter_commits(f"{repo.active_branch.name}..{tracking.name}"))
|
|
)
|
|
except Exception:
|
|
pass # No tracking branch
|
|
|
|
is_clean = len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0
|
|
|
|
return StatusResult(
|
|
project_id="", # Set by caller
|
|
branch=repo.active_branch.name,
|
|
commit_sha=repo.head.commit.hexsha,
|
|
is_clean=is_clean,
|
|
staged=staged,
|
|
unstaged=unstaged,
|
|
untracked=untracked,
|
|
ahead=ahead,
|
|
behind=behind,
|
|
)
|
|
|
|
return await run_in_executor(_get_status)
|
|
|
|
# Branch operations
|
|
|
|
async def create_branch(
|
|
self,
|
|
branch_name: str,
|
|
from_ref: str | None = None,
|
|
checkout: bool = True,
|
|
) -> BranchResult:
|
|
"""
|
|
Create a new branch.
|
|
|
|
Args:
|
|
branch_name: Name for the new branch
|
|
from_ref: Reference to create from (default: HEAD)
|
|
checkout: Whether to checkout after creation
|
|
|
|
Returns:
|
|
BranchResult with creation status
|
|
"""
|
|
|
|
def _create_branch() -> BranchResult:
|
|
repo = self.repo
|
|
|
|
# Check if branch already exists
|
|
if branch_name in [b.name for b in repo.branches]:
|
|
raise BranchExistsError(branch_name)
|
|
|
|
try:
|
|
# Get the starting point
|
|
if from_ref:
|
|
start_point = repo.commit(from_ref)
|
|
else:
|
|
start_point = repo.head.commit
|
|
|
|
# Create branch
|
|
new_branch = repo.create_head(branch_name, start_point)
|
|
|
|
# Checkout if requested
|
|
if checkout:
|
|
new_branch.checkout()
|
|
|
|
return BranchResult(
|
|
success=True,
|
|
branch=branch_name,
|
|
commit_sha=new_branch.commit.hexsha,
|
|
is_current=checkout,
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
logger.error(f"Failed to create branch {branch_name}: {e}")
|
|
raise GitError(f"Failed to create branch: {e}")
|
|
|
|
return await run_in_executor(_create_branch)
|
|
|
|
async def delete_branch(
|
|
self,
|
|
branch_name: str,
|
|
force: bool = False,
|
|
) -> BranchResult:
|
|
"""
|
|
Delete a branch.
|
|
|
|
Args:
|
|
branch_name: Branch to delete
|
|
force: Force delete even if not merged
|
|
|
|
Returns:
|
|
BranchResult with deletion status
|
|
"""
|
|
|
|
def _delete_branch() -> BranchResult:
|
|
repo = self.repo
|
|
|
|
if branch_name not in [b.name for b in repo.branches]:
|
|
raise BranchNotFoundError(branch_name)
|
|
|
|
if repo.active_branch.name == branch_name:
|
|
raise GitError(f"Cannot delete current branch: {branch_name}")
|
|
|
|
try:
|
|
repo.delete_head(branch_name, force=force)
|
|
return BranchResult(
|
|
success=True,
|
|
branch=branch_name,
|
|
is_current=False,
|
|
)
|
|
except GitCommandError as e:
|
|
logger.error(f"Failed to delete branch {branch_name}: {e}")
|
|
raise GitError(f"Failed to delete branch: {e}")
|
|
|
|
return await run_in_executor(_delete_branch)
|
|
|
|
async def list_branches(self, include_remote: bool = False) -> ListBranchesResult:
|
|
"""
|
|
List branches.
|
|
|
|
Args:
|
|
include_remote: Include remote tracking branches
|
|
|
|
Returns:
|
|
ListBranchesResult with branch lists
|
|
"""
|
|
|
|
def _list_branches() -> ListBranchesResult:
|
|
repo = self.repo
|
|
|
|
local_branches = []
|
|
for branch in repo.branches:
|
|
tracking = branch.tracking_branch()
|
|
local_branches.append(
|
|
BranchInfo(
|
|
name=branch.name,
|
|
is_current=branch == repo.active_branch,
|
|
is_remote=False,
|
|
tracking_branch=tracking.name if tracking else None,
|
|
commit_sha=branch.commit.hexsha,
|
|
commit_message=branch.commit.message.split("\n")[0],
|
|
).to_dict()
|
|
)
|
|
|
|
remote_branches = []
|
|
if include_remote:
|
|
for remote in repo.remotes:
|
|
for ref in remote.refs:
|
|
# Skip HEAD refs
|
|
if ref.name.endswith("/HEAD"):
|
|
continue
|
|
remote_branches.append(
|
|
BranchInfo(
|
|
name=ref.name,
|
|
is_current=False,
|
|
is_remote=True,
|
|
commit_sha=ref.commit.hexsha,
|
|
commit_message=ref.commit.message.split("\n")[0],
|
|
).to_dict()
|
|
)
|
|
|
|
return ListBranchesResult(
|
|
project_id="", # Set by caller
|
|
current_branch=repo.active_branch.name,
|
|
local_branches=local_branches,
|
|
remote_branches=remote_branches,
|
|
)
|
|
|
|
return await run_in_executor(_list_branches)
|
|
|
|
async def checkout(
|
|
self,
|
|
ref: str,
|
|
create_branch: bool = False,
|
|
force: bool = False,
|
|
) -> CheckoutResult:
|
|
"""
|
|
Checkout a branch or ref.
|
|
|
|
Args:
|
|
ref: Branch, tag, or commit to checkout
|
|
create_branch: Create new branch with this name
|
|
force: Force checkout (discard local changes)
|
|
|
|
Returns:
|
|
CheckoutResult with checkout status
|
|
"""
|
|
|
|
def _checkout() -> CheckoutResult:
|
|
repo = self.repo
|
|
|
|
try:
|
|
if create_branch:
|
|
# Create and checkout new branch
|
|
if ref in [b.name for b in repo.branches]:
|
|
raise BranchExistsError(ref)
|
|
new_branch = repo.create_head(ref)
|
|
new_branch.checkout(force=force)
|
|
else:
|
|
# Checkout existing ref
|
|
if ref in [b.name for b in repo.branches]:
|
|
# Local branch
|
|
repo.heads[ref].checkout(force=force)
|
|
else:
|
|
# Try as a commit/tag
|
|
repo.git.checkout(ref, force=force)
|
|
|
|
return CheckoutResult(
|
|
success=True,
|
|
ref=ref,
|
|
commit_sha=repo.head.commit.hexsha,
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
error_msg = str(e)
|
|
if "would be overwritten" in error_msg:
|
|
raise DirtyWorkspaceError([])
|
|
raise CheckoutError(ref, error_msg)
|
|
|
|
return await run_in_executor(_checkout)
|
|
|
|
# Commit operations
|
|
|
|
async def commit(
|
|
self,
|
|
message: str,
|
|
files: list[str] | None = None,
|
|
author_name: str | None = None,
|
|
author_email: str | None = None,
|
|
allow_empty: bool = False,
|
|
) -> CommitResult:
|
|
"""
|
|
Create a commit.
|
|
|
|
Args:
|
|
message: Commit message
|
|
files: Specific files to commit (None = all staged)
|
|
author_name: Author name override
|
|
author_email: Author email override
|
|
allow_empty: Allow empty commits
|
|
|
|
Returns:
|
|
CommitResult with commit info
|
|
"""
|
|
|
|
def _commit() -> CommitResult:
|
|
repo = self.repo
|
|
|
|
try:
|
|
# Stage files if specified
|
|
if files:
|
|
repo.index.add(files)
|
|
elif not allow_empty:
|
|
# Stage all modified/deleted
|
|
repo.git.add("-A")
|
|
|
|
# Check if there's anything to commit
|
|
if not allow_empty and not repo.index.diff("HEAD") and not repo.untracked_files:
|
|
raise CommitError("Nothing to commit")
|
|
|
|
# Build author
|
|
author = None
|
|
if author_name and author_email:
|
|
from git import Actor
|
|
|
|
author = Actor(author_name, author_email)
|
|
elif author_name or author_email:
|
|
from git import Actor
|
|
|
|
author = Actor(
|
|
author_name or self.settings.git_author_name,
|
|
author_email or self.settings.git_author_email,
|
|
)
|
|
|
|
# Create commit
|
|
commit = repo.index.commit(
|
|
message,
|
|
author=author,
|
|
committer=author,
|
|
)
|
|
|
|
# Get stats
|
|
stats = commit.stats.total
|
|
files_changed = stats.get("files", 0)
|
|
insertions = stats.get("insertions", 0)
|
|
deletions = stats.get("deletions", 0)
|
|
|
|
return CommitResult(
|
|
success=True,
|
|
commit_sha=commit.hexsha,
|
|
short_sha=commit.hexsha[:7],
|
|
message=message,
|
|
files_changed=files_changed,
|
|
insertions=insertions,
|
|
deletions=deletions,
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
logger.error(f"Commit failed: {e}")
|
|
raise CommitError(str(e))
|
|
|
|
return await run_in_executor(_commit)
|
|
|
|
async def stage(self, files: list[str] | None = None) -> int:
|
|
"""
|
|
Stage files for commit.
|
|
|
|
Args:
|
|
files: Files to stage (None = all)
|
|
|
|
Returns:
|
|
Number of files staged
|
|
"""
|
|
|
|
def _stage() -> int:
|
|
repo = self.repo
|
|
if files:
|
|
repo.index.add(files)
|
|
return len(files)
|
|
else:
|
|
repo.git.add("-A")
|
|
return len(repo.index.diff("HEAD")) + len(repo.untracked_files)
|
|
|
|
return await run_in_executor(_stage)
|
|
|
|
async def unstage(self, files: list[str] | None = None) -> int:
|
|
"""
|
|
Unstage files.
|
|
|
|
Args:
|
|
files: Files to unstage (None = all)
|
|
|
|
Returns:
|
|
Number of files unstaged
|
|
"""
|
|
|
|
def _unstage() -> int:
|
|
repo = self.repo
|
|
staged = list(repo.index.diff("HEAD"))
|
|
|
|
if files:
|
|
repo.index.remove(files, working_tree=False)
|
|
return len(files)
|
|
else:
|
|
# Unstage all
|
|
if staged:
|
|
repo.git.reset("HEAD")
|
|
return len(staged)
|
|
|
|
return await run_in_executor(_unstage)
|
|
|
|
# Push/Pull operations
|
|
|
|
async def push(
|
|
self,
|
|
branch: str | None = None,
|
|
remote: str = "origin",
|
|
force: bool = False,
|
|
set_upstream: bool = True,
|
|
auth_token: str | None = None,
|
|
) -> PushResult:
|
|
"""
|
|
Push to remote.
|
|
|
|
Args:
|
|
branch: Branch to push (None = current)
|
|
remote: Remote name
|
|
force: Force push
|
|
set_upstream: Set upstream tracking
|
|
auth_token: Auth token for HTTPS
|
|
|
|
Returns:
|
|
PushResult with push status
|
|
"""
|
|
|
|
def _push() -> PushResult:
|
|
repo = self.repo
|
|
push_branch = branch or repo.active_branch.name
|
|
|
|
# Check force push policy
|
|
if force and not self.settings.enable_force_push:
|
|
raise PushError(push_branch, "Force push is disabled")
|
|
|
|
try:
|
|
# Build push info
|
|
push_info_list = []
|
|
|
|
if remote not in [r.name for r in repo.remotes]:
|
|
raise PushError(push_branch, f"Remote not found: {remote}")
|
|
|
|
remote_obj = repo.remote(remote)
|
|
|
|
# Configure auth if provided
|
|
if auth_token:
|
|
# Set credential helper temporarily
|
|
pass # TODO: Implement token-based auth
|
|
|
|
# Build refspec
|
|
refspec = f"{push_branch}:{push_branch}"
|
|
if set_upstream:
|
|
push_info_list = remote_obj.push(
|
|
refspec=refspec,
|
|
force=force,
|
|
set_upstream=True,
|
|
)
|
|
else:
|
|
push_info_list = remote_obj.push(
|
|
refspec=refspec,
|
|
force=force,
|
|
)
|
|
|
|
# Check for errors
|
|
for info in push_info_list:
|
|
if info.flags & info.ERROR:
|
|
raise PushError(push_branch, info.summary)
|
|
|
|
# Count commits pushed (approximate)
|
|
commits_pushed = 0
|
|
try:
|
|
tracking = repo.active_branch.tracking_branch()
|
|
if tracking:
|
|
commits_pushed = len(
|
|
list(repo.iter_commits(f"{tracking.name}..{push_branch}"))
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return PushResult(
|
|
success=True,
|
|
branch=push_branch,
|
|
remote=remote,
|
|
commits_pushed=commits_pushed,
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
error_msg = str(e)
|
|
if "rejected" in error_msg:
|
|
raise PushError(
|
|
push_branch,
|
|
"Push rejected - pull and merge first or force push",
|
|
)
|
|
raise PushError(push_branch, error_msg)
|
|
|
|
return await run_in_executor(_push)
|
|
|
|
async def pull(
|
|
self,
|
|
branch: str | None = None,
|
|
remote: str = "origin",
|
|
rebase: bool = False,
|
|
auth_token: str | None = None,
|
|
) -> PullResult:
|
|
"""
|
|
Pull from remote.
|
|
|
|
Args:
|
|
branch: Branch to pull (None = current)
|
|
remote: Remote name
|
|
rebase: Rebase instead of merge
|
|
auth_token: Auth token for HTTPS
|
|
|
|
Returns:
|
|
PullResult with pull status
|
|
"""
|
|
|
|
def _pull() -> PullResult:
|
|
repo = self.repo
|
|
pull_branch = branch or repo.active_branch.name
|
|
|
|
try:
|
|
if remote not in [r.name for r in repo.remotes]:
|
|
raise PullError(pull_branch, f"Remote not found: {remote}")
|
|
|
|
remote_obj = repo.remote(remote)
|
|
|
|
# Fetch first to check for conflicts
|
|
remote_obj.fetch()
|
|
|
|
# Get commits before pull
|
|
head_before = repo.head.commit.hexsha
|
|
|
|
# Perform pull
|
|
if rebase:
|
|
repo.git.pull("--rebase", remote, pull_branch)
|
|
else:
|
|
repo.git.pull(remote, pull_branch)
|
|
|
|
# Count new commits
|
|
commits_received = len(
|
|
list(repo.iter_commits(f"{head_before}..HEAD"))
|
|
)
|
|
|
|
# Check if fast-forward
|
|
fast_forward = commits_received > 0 and not repo.head.commit.parents
|
|
|
|
return PullResult(
|
|
success=True,
|
|
branch=pull_branch,
|
|
commits_received=commits_received,
|
|
fast_forward=fast_forward,
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
error_msg = str(e)
|
|
if "conflict" in error_msg.lower():
|
|
# Get conflicting files
|
|
conflicts = [
|
|
item.a_path
|
|
for item in repo.index.unmerged_blobs().keys()
|
|
]
|
|
raise MergeConflictError(conflicts)
|
|
raise PullError(pull_branch, error_msg)
|
|
|
|
return await run_in_executor(_pull)
|
|
|
|
async def fetch(
|
|
self,
|
|
remote: str = "origin",
|
|
prune: bool = False,
|
|
) -> bool:
|
|
"""
|
|
Fetch from remote.
|
|
|
|
Args:
|
|
remote: Remote name
|
|
prune: Prune deleted remote branches
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
|
|
def _fetch() -> bool:
|
|
repo = self.repo
|
|
try:
|
|
if remote not in [r.name for r in repo.remotes]:
|
|
raise GitError(f"Remote not found: {remote}")
|
|
|
|
remote_obj = repo.remote(remote)
|
|
remote_obj.fetch(prune=prune)
|
|
return True
|
|
except GitCommandError as e:
|
|
logger.error(f"Fetch failed: {e}")
|
|
raise GitError(f"Fetch failed: {e}")
|
|
|
|
return await run_in_executor(_fetch)
|
|
|
|
# Diff operations
|
|
|
|
async def diff(
|
|
self,
|
|
base: str | None = None,
|
|
head: str | None = None,
|
|
files: list[str] | None = None,
|
|
context_lines: int = 3,
|
|
) -> DiffResult:
|
|
"""
|
|
Get diff between refs.
|
|
|
|
Args:
|
|
base: Base reference (None = working tree)
|
|
head: Head reference (None = HEAD)
|
|
files: Specific files to diff
|
|
context_lines: Context lines to include
|
|
|
|
Returns:
|
|
DiffResult with diff info
|
|
"""
|
|
|
|
def _diff() -> DiffResult:
|
|
repo = self.repo
|
|
file_diffs = []
|
|
total_additions = 0
|
|
total_deletions = 0
|
|
|
|
try:
|
|
# Determine what to diff
|
|
if base is None and head is None:
|
|
# Working tree vs staged
|
|
diffs = repo.index.diff(None, create_patch=True)
|
|
elif base is None:
|
|
# Working tree vs specified ref
|
|
diffs = repo.commit(head).diff(None, create_patch=True)
|
|
elif head is None:
|
|
# Specified ref vs HEAD
|
|
diffs = repo.commit(base).diff("HEAD", create_patch=True)
|
|
else:
|
|
# Between two refs
|
|
diffs = repo.commit(base).diff(head, create_patch=True)
|
|
|
|
for diff in diffs:
|
|
# Filter by files if specified
|
|
if files and diff.a_path not in files and diff.b_path not in files:
|
|
continue
|
|
|
|
change_type = self._diff_to_change_type(diff.change_type)
|
|
path = diff.b_path or diff.a_path
|
|
|
|
# Parse hunks from patch
|
|
hunks = []
|
|
additions = 0
|
|
deletions = 0
|
|
|
|
if diff.diff:
|
|
patch_text = diff.diff.decode("utf-8", errors="replace")
|
|
# Parse hunks (simplified)
|
|
for line in patch_text.split("\n"):
|
|
if line.startswith("+") and not line.startswith("+++"):
|
|
additions += 1
|
|
elif line.startswith("-") and not line.startswith("---"):
|
|
deletions += 1
|
|
|
|
# Add as single hunk for now
|
|
hunks.append(
|
|
DiffHunk(
|
|
old_start=1,
|
|
old_lines=deletions,
|
|
new_start=1,
|
|
new_lines=additions,
|
|
content=patch_text[: self.settings.git_max_diff_lines],
|
|
)
|
|
)
|
|
|
|
file_diffs.append(
|
|
FileDiff(
|
|
path=path,
|
|
change_type=change_type,
|
|
old_path=diff.a_path if diff.renamed else None,
|
|
hunks=hunks,
|
|
additions=additions,
|
|
deletions=deletions,
|
|
is_binary=diff.diff is None and not diff.deleted_file,
|
|
).to_dict()
|
|
)
|
|
|
|
total_additions += additions
|
|
total_deletions += deletions
|
|
|
|
return DiffResult(
|
|
project_id="", # Set by caller
|
|
base=base,
|
|
head=head,
|
|
files=file_diffs,
|
|
total_additions=total_additions,
|
|
total_deletions=total_deletions,
|
|
files_changed=len(file_diffs),
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
raise GitError(f"Diff failed: {e}")
|
|
|
|
return await run_in_executor(_diff)
|
|
|
|
# Log operations
|
|
|
|
async def log(
|
|
self,
|
|
ref: str | None = None,
|
|
limit: int = 20,
|
|
skip: int = 0,
|
|
path: str | None = None,
|
|
) -> LogResult:
|
|
"""
|
|
Get commit log.
|
|
|
|
Args:
|
|
ref: Reference to start from
|
|
limit: Max commits to return
|
|
skip: Commits to skip
|
|
path: Filter by path
|
|
|
|
Returns:
|
|
LogResult with commit history
|
|
"""
|
|
|
|
def _log() -> LogResult:
|
|
repo = self.repo
|
|
commits = []
|
|
|
|
try:
|
|
kwargs: dict[str, Any] = {
|
|
"max_count": limit,
|
|
"skip": skip,
|
|
}
|
|
|
|
if path:
|
|
kwargs["paths"] = path
|
|
|
|
if ref:
|
|
iterator = repo.iter_commits(ref, **kwargs)
|
|
else:
|
|
iterator = repo.iter_commits(**kwargs)
|
|
|
|
for commit in iterator:
|
|
commits.append(
|
|
CommitInfo(
|
|
sha=commit.hexsha,
|
|
short_sha=commit.hexsha[:7],
|
|
message=commit.message,
|
|
author_name=commit.author.name,
|
|
author_email=commit.author.email,
|
|
authored_date=datetime.fromtimestamp(
|
|
commit.authored_date, tz=UTC
|
|
),
|
|
committer_name=commit.committer.name,
|
|
committer_email=commit.committer.email,
|
|
committed_date=datetime.fromtimestamp(
|
|
commit.committed_date, tz=UTC
|
|
),
|
|
parents=[p.hexsha for p in commit.parents],
|
|
).to_dict()
|
|
)
|
|
|
|
return LogResult(
|
|
project_id="", # Set by caller
|
|
commits=commits,
|
|
total_commits=len(commits),
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
raise GitError(f"Log failed: {e}")
|
|
|
|
return await run_in_executor(_log)
|
|
|
|
# Reset operations
|
|
|
|
async def reset(
|
|
self,
|
|
ref: str = "HEAD",
|
|
mode: str = "mixed",
|
|
files: list[str] | None = None,
|
|
) -> bool:
|
|
"""
|
|
Reset to a ref.
|
|
|
|
Args:
|
|
ref: Reference to reset to
|
|
mode: Reset mode (soft, mixed, hard)
|
|
files: Specific files to reset
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
|
|
def _reset() -> bool:
|
|
repo = self.repo
|
|
try:
|
|
if files:
|
|
# Reset specific files
|
|
repo.index.reset(commit=ref, paths=files)
|
|
else:
|
|
# Full reset
|
|
if mode == "soft":
|
|
repo.head.reset(ref, index=False, working_tree=False)
|
|
elif mode == "mixed":
|
|
repo.head.reset(ref, index=True, working_tree=False)
|
|
elif mode == "hard":
|
|
repo.head.reset(ref, index=True, working_tree=True)
|
|
else:
|
|
raise GitError(f"Invalid reset mode: {mode}")
|
|
|
|
return True
|
|
except GitCommandError as e:
|
|
raise GitError(f"Reset failed: {e}")
|
|
|
|
return await run_in_executor(_reset)
|
|
|
|
# Stash operations
|
|
|
|
async def stash(self, message: str | None = None) -> str | None:
|
|
"""
|
|
Stash changes.
|
|
|
|
Args:
|
|
message: Optional stash message
|
|
|
|
Returns:
|
|
Stash reference or None if nothing to stash
|
|
"""
|
|
|
|
def _stash() -> str | None:
|
|
repo = self.repo
|
|
try:
|
|
if message:
|
|
result = repo.git.stash("push", "-m", message)
|
|
else:
|
|
result = repo.git.stash("push")
|
|
|
|
if "No local changes to save" in result:
|
|
return None
|
|
|
|
return repo.git.stash("list").split("\n")[0].split(":")[0]
|
|
except GitCommandError as e:
|
|
raise GitError(f"Stash failed: {e}")
|
|
|
|
return await run_in_executor(_stash)
|
|
|
|
async def stash_pop(self, stash_ref: str | None = None) -> bool:
|
|
"""
|
|
Pop stashed changes.
|
|
|
|
Args:
|
|
stash_ref: Specific stash to pop
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
|
|
def _stash_pop() -> bool:
|
|
repo = self.repo
|
|
try:
|
|
if stash_ref:
|
|
repo.git.stash("pop", stash_ref)
|
|
else:
|
|
repo.git.stash("pop")
|
|
return True
|
|
except GitCommandError as e:
|
|
if "conflict" in str(e).lower():
|
|
raise MergeConflictError([])
|
|
raise GitError(f"Stash pop failed: {e}")
|
|
|
|
return await run_in_executor(_stash_pop)
|
|
|
|
# Utility methods
|
|
|
|
def _diff_to_change_type(self, change_type: str) -> FileChangeType:
|
|
"""Convert GitPython change type to our enum."""
|
|
mapping = {
|
|
"A": FileChangeType.ADDED,
|
|
"M": FileChangeType.MODIFIED,
|
|
"D": FileChangeType.DELETED,
|
|
"R": FileChangeType.RENAMED,
|
|
"C": FileChangeType.COPIED,
|
|
}
|
|
return mapping.get(change_type, FileChangeType.MODIFIED)
|
|
|
|
async def is_valid_ref(self, ref: str) -> bool:
|
|
"""Check if a reference is valid."""
|
|
|
|
def _check() -> bool:
|
|
try:
|
|
self.repo.commit(ref)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
return await run_in_executor(_check)
|
|
|
|
async def get_remote_url(self, remote: str = "origin") -> str | None:
|
|
"""Get the URL for a remote."""
|
|
|
|
def _get_url() -> str | None:
|
|
repo = self.repo
|
|
if remote in [r.name for r in repo.remotes]:
|
|
return repo.remote(remote).url
|
|
return None
|
|
|
|
return await run_in_executor(_get_url)
|
|
|
|
async def set_config(self, key: str, value: str, global_: bool = False) -> None:
|
|
"""Set a git config value."""
|
|
|
|
def _set_config() -> None:
|
|
repo = self.repo
|
|
with repo.config_writer("global" if global_ else "repository") as cw:
|
|
section, option = key.rsplit(".", 1)
|
|
cw.set_value(section, option, value)
|
|
|
|
await run_in_executor(_set_config)
|
|
|
|
async def get_config(self, key: str) -> str | None:
|
|
"""Get a git config value."""
|
|
|
|
def _get_config() -> str | None:
|
|
repo = self.repo
|
|
try:
|
|
cr = repo.config_reader()
|
|
section, option = key.rsplit(".", 1)
|
|
return cr.get_value(section, option)
|
|
except Exception:
|
|
return None
|
|
|
|
return await run_in_executor(_get_config)
|