Feat git operations (#863)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2025-10-27 16:49:03 -06:00
committed by GitHub
parent e380762e32
commit ce0a71af55
17 changed files with 1725 additions and 2 deletions
@@ -21,6 +21,7 @@ from openhands.agent_server.desktop_router import desktop_router
from openhands.agent_server.desktop_service import get_desktop_service
from openhands.agent_server.event_router import event_router
from openhands.agent_server.file_router import file_router
from openhands.agent_server.git_router import git_router
from openhands.agent_server.middleware import LocalhostCORSMiddleware
from openhands.agent_server.server_details_router import (
get_server_info,
@@ -133,6 +134,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
api_router.include_router(conversation_router)
api_router.include_router(tool_router)
api_router.include_router(bash_router)
api_router.include_router(git_router)
api_router.include_router(file_router)
api_router.include_router(vscode_router)
api_router.include_router(desktop_router)
@@ -1,5 +1,6 @@
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import (
APIRouter,
@@ -10,15 +11,20 @@ from fastapi import (
status,
)
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from openhands.agent_server.bash_service import get_default_bash_event_service
from openhands.agent_server.config import get_default_config
from openhands.agent_server.models import Success
from openhands.agent_server.conversation_service import get_default_conversation_service
from openhands.agent_server.models import ExecuteBashRequest, Success
from openhands.sdk.logger import get_logger
logger = get_logger(__name__)
file_router = APIRouter(prefix="/file", tags=["Files"])
config = get_default_config()
conversation_service = get_default_conversation_service()
bash_event_service = get_default_bash_event_service()
@file_router.post("/upload/{path:path}")
@@ -91,3 +97,23 @@ async def download_file(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to download file: {str(e)}",
)
@file_router.get("/download-trajectory/{conversation_id}")
async def download_trajectory(
conversation_id: UUID,
) -> FileResponse:
"""Download a file from the workspace."""
config = get_default_config()
temp_file = config.conversations_path / f"{conversation_id.hex}.zip"
conversation_dir = config.conversations_path / conversation_id.hex
_, task = await bash_event_service.start_bash_command(
ExecuteBashRequest(command=f"zip -r {temp_file} {conversation_dir}")
)
await task
return FileResponse(
path=temp_file,
filename=temp_file.name,
media_type="application/octet-stream",
background=BackgroundTask(temp_file.unlink),
)
@@ -0,0 +1,34 @@
"""Git router for OpenHands SDK."""
import asyncio
import logging
from pathlib import Path
from fastapi import APIRouter
from openhands.sdk.git.git_changes import get_git_changes
from openhands.sdk.git.git_diff import get_git_diff
from openhands.sdk.git.models import GitChange, GitDiff
git_router = APIRouter(prefix="/git", tags=["Git"])
logger = logging.getLogger(__name__)
@git_router.get("/changes/{path:path}")
async def git_changes(
path: Path,
) -> list[GitChange]:
loop = asyncio.get_running_loop()
changes = await loop.run_in_executor(None, get_git_changes, path)
return changes
# bash event routes
@git_router.get("/diff/{path:path}")
async def git_diff(
path: Path,
) -> GitDiff:
loop = asyncio.get_running_loop()
changes = await loop.run_in_executor(None, get_git_diff, path)
return changes
@@ -0,0 +1,43 @@
"""Git-related exceptions for OpenHands SDK."""
class GitError(Exception):
"""Base exception for git-related errors."""
pass
class GitRepositoryError(GitError):
"""Exception raised when git repository operations fail."""
command: str | None
exit_code: int | None
def __init__(
self, message: str, command: str | None = None, exit_code: int | None = None
):
self.command = command
self.exit_code = exit_code
super().__init__(message)
class GitCommandError(GitError):
"""Exception raised when git command execution fails."""
command: list[str]
exit_code: int
stderr: str
def __init__(
self, message: str, command: list[str], exit_code: int, stderr: str = ""
):
self.command = command
self.exit_code = exit_code
self.stderr = stderr
super().__init__(message)
class GitPathError(GitError):
"""Exception raised when git path operations fail."""
pass
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Get git changes in the current working directory relative to the remote origin
if possible.
"""
import glob
import json
import logging
import os
from pathlib import Path
from openhands.sdk.git.exceptions import GitCommandError
from openhands.sdk.git.models import GitChange, GitChangeStatus
from openhands.sdk.git.utils import (
get_valid_ref,
run_git_command,
validate_git_repository,
)
logger = logging.getLogger(__name__)
def _map_git_status_to_enum(status: str) -> GitChangeStatus:
"""Map git status codes to GitChangeStatus enum values."""
status_mapping = {
"M": GitChangeStatus.UPDATED,
"A": GitChangeStatus.ADDED,
"D": GitChangeStatus.DELETED,
"U": GitChangeStatus.UPDATED, # Unmerged files are treated as updated
}
if status not in status_mapping:
raise ValueError(f"Unknown git status: {status}")
return status_mapping[status]
def get_changes_in_repo(repo_dir: str | Path) -> list[GitChange]:
"""Get git changes in a repository relative to the origin default branch.
This is different from `git status` as it compares against the remote branch
rather than the staging area.
Args:
repo_dir: Path to the git repository
Returns:
List of GitChange objects representing the changes
Raises:
GitRepositoryError: If the directory is not a valid git repository
GitCommandError: If git commands fail
"""
# Validate the repository first
validated_repo = validate_git_repository(repo_dir)
ref = get_valid_ref(validated_repo)
if not ref:
logger.warning(f"No valid git reference found for {validated_repo}")
return []
# Get changed files using secure git command
try:
changed_files_output = run_git_command(
["git", "--no-pager", "diff", "--name-status", ref], validated_repo
)
changed_files = (
changed_files_output.splitlines() if changed_files_output else []
)
except GitCommandError as e:
logger.error(f"Failed to get git diff for {validated_repo}: {e}")
raise
changes = []
for line in changed_files:
if not line.strip():
logger.warning("Empty line in git diff output, skipping")
continue
# Handle different output formats from git diff --name-status
# Depending on git config, format can be either:
# * "A file.txt"
# * "A file.txt"
# * "R100 old_file.txt new_file.txt" (rename with similarity percentage)
parts = line.split()
if len(parts) < 2:
logger.error(f"Unexpected git diff line format: {line}")
raise GitCommandError(
message=f"Unexpected git diff output format: {line}",
command=["git", "diff", "--name-status"],
exit_code=0,
stderr="Invalid output format",
)
status = parts[0].strip()
# Handle rename operations (status starts with 'R' followed
# by similarity percentage)
if status.startswith("R") and len(parts) == 3:
# Rename: convert to delete (old path) + add (new path)
old_path = parts[1].strip()
new_path = parts[2].strip()
changes.append(
GitChange(
status=GitChangeStatus.DELETED,
path=Path(old_path),
)
)
changes.append(
GitChange(
status=GitChangeStatus.ADDED,
path=Path(new_path),
)
)
logger.debug(f"Found git rename: {old_path} -> {new_path}")
continue
# Handle copy operations (status starts with 'C' followed by
# similarity percentage)
elif status.startswith("C") and len(parts) == 3:
# Copy: only add the new path (original remains)
new_path = parts[2].strip()
changes.append(
GitChange(
status=GitChangeStatus.ADDED,
path=Path(new_path),
)
)
logger.debug(f"Found git copy: -> {new_path}")
continue
# Handle regular operations (M, A, D, etc.)
elif len(parts) == 2:
path = parts[1].strip()
else:
logger.error(f"Unexpected git diff line format: {line}")
raise GitCommandError(
message=f"Unexpected git diff output format: {line}",
command=["git", "diff", "--name-status"],
exit_code=0,
stderr="Invalid output format",
)
if status == "??":
status = "A"
elif status == "*":
status = "M"
# Check for valid single-character status codes
if status in {"M", "A", "D", "U"}:
try:
changes.append(
GitChange(
status=_map_git_status_to_enum(status),
path=Path(path),
)
)
logger.debug(f"Found git change: {status} {path}")
except ValueError as e:
logger.error(f"Unknown git status '{status}' for file {path}")
raise GitCommandError(
message=f"Unknown git status: {status}",
command=["git", "diff", "--name-status"],
exit_code=0,
stderr=f"Unknown status code: {status}",
) from e
else:
logger.error(f"Unexpected git status '{status}' for file {path}")
raise GitCommandError(
message=f"Unexpected git status: {status}",
command=["git", "diff", "--name-status"],
exit_code=0,
stderr=f"Unexpected status code: {status}",
)
# Get untracked files
try:
untracked_output = run_git_command(
["git", "--no-pager", "ls-files", "--others", "--exclude-standard"],
validated_repo,
)
untracked_files = untracked_output.splitlines() if untracked_output else []
except GitCommandError as e:
logger.error(f"Failed to get untracked files for {validated_repo}: {e}")
untracked_files = []
for path in untracked_files:
if path.strip():
changes.append(
GitChange(
status=GitChangeStatus.ADDED,
path=Path(path.strip()),
)
)
logger.debug(f"Found untracked file: {path}")
logger.info(f"Found {len(changes)} total git changes in {validated_repo}")
return changes
def get_git_changes(cwd: str | Path) -> list[GitChange]:
git_dirs = {
os.path.dirname(f)[2:]
for f in glob.glob("./*/.git", root_dir=cwd, recursive=True)
}
# First try the workspace directory
changes = get_changes_in_repo(cwd)
# Filter out any changes which are in one of the git directories
changes = [
change
for change in changes
if next(
iter(
git_dir for git_dir in git_dirs if str(change.path).startswith(git_dir)
),
None,
)
is None
]
# Add changes from git directories
for git_dir in git_dirs:
git_dir_changes = get_changes_in_repo(str(Path(cwd, git_dir)))
for change in git_dir_changes:
# Create a new GitChange with the updated path
updated_change = GitChange(
status=change.status,
path=Path(git_dir) / change.path,
)
changes.append(updated_change)
changes.sort(key=lambda change: str(change.path))
return changes
if __name__ == "__main__":
try:
changes = get_git_changes(os.getcwd())
# Convert GitChange objects to dictionaries for JSON serialization
changes_dict = [
{
"status": change.status.value,
"path": str(change.path),
}
for change in changes
]
print(json.dumps(changes_dict))
except Exception as e:
print(json.dumps({"error": str(e)}))
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Get git diff in a single git file for the closest git repo in the file system"""
import json
import logging
import os
import sys
from pathlib import Path
from openhands.sdk.git.exceptions import (
GitCommandError,
GitPathError,
GitRepositoryError,
)
from openhands.sdk.git.models import GitDiff
from openhands.sdk.git.utils import (
get_valid_ref,
run_git_command,
validate_git_repository,
)
logger = logging.getLogger(__name__)
MAX_FILE_SIZE_FOR_GIT_DIFF = 1024 * 1024 # 1 Mb
def get_closest_git_repo(path: Path) -> Path | None:
"""Find the closest git repository by walking up the directory tree.
Args:
path: Starting path to search from
Returns:
Path to the git repository root, or None if not found
"""
current_path = path.resolve()
while True:
git_path = current_path / ".git"
if git_path.exists(): # Could be file (worktree) or directory
logger.debug(f"Found git repository at: {current_path}")
return current_path
parent = current_path.parent
if parent == current_path: # Reached filesystem root
logger.debug(f"No git repository found for path: {path}")
return None
current_path = parent
def get_git_diff(relative_file_path: str | Path) -> GitDiff:
"""Get git diff for a single file.
Args:
relative_file_path: Path to the file relative to current working directory
Returns:
GitDiff object containing diff information
Raises:
GitPathError: If file is too large or doesn't exist
GitRepositoryError: If not in a git repository
GitCommandError: If git commands fail
"""
path = Path(os.getcwd(), relative_file_path).resolve()
# Check if file exists
if not path.exists():
raise GitPathError(f"File does not exist: {path}")
# Check file size
try:
file_size = os.path.getsize(path)
if file_size > MAX_FILE_SIZE_FOR_GIT_DIFF:
raise GitPathError(
f"File too large for git diff: {file_size} bytes "
f"(max: {MAX_FILE_SIZE_FOR_GIT_DIFF} bytes)"
)
except OSError as e:
raise GitPathError(f"Cannot access file: {path}") from e
# Find git repository
closest_git_repo = get_closest_git_repo(path)
if not closest_git_repo:
raise GitRepositoryError(f"File is not in a git repository: {path}")
# Validate the git repository
validated_repo = validate_git_repository(closest_git_repo)
current_rev = get_valid_ref(validated_repo)
if not current_rev:
logger.warning(f"No valid git reference found for {validated_repo}")
return GitDiff(modified="", original="")
# Get the relative path from the git repo root
try:
relative_path_from_repo = path.relative_to(validated_repo)
except ValueError as e:
raise GitPathError(f"File is not within git repository: {path}") from e
# Get old content (from the ref)
try:
original = run_git_command(
["git", "show", f"{current_rev}:{relative_path_from_repo}"], validated_repo
)
except GitCommandError:
logger.debug(f"No old content found for {path} at ref {current_rev}")
original = ""
# Get new content (current file)
try:
with open(path, encoding="utf-8") as f:
modified = "\n".join(f.read().splitlines())
except (OSError, UnicodeDecodeError) as e:
logger.error(f"Failed to read file {path}: {e}")
modified = ""
logger.info(f"Generated git diff for {path}")
return GitDiff(
modified=modified,
original=original,
)
if __name__ == "__main__":
diff = get_git_diff(sys.argv[-1])
print(json.dumps(diff))
+21
View File
@@ -0,0 +1,21 @@
from enum import Enum
from pathlib import Path
from pydantic import BaseModel
class GitChangeStatus(Enum):
MOVED = "MOVED"
ADDED = "ADDED"
DELETED = "DELETED"
UPDATED = "UPDATED"
class GitChange(BaseModel):
status: GitChangeStatus
path: Path
class GitDiff(BaseModel):
modified: str | None
original: str | None
+189
View File
@@ -0,0 +1,189 @@
import logging
import shlex
import subprocess
from pathlib import Path
from openhands.sdk.git.exceptions import GitCommandError, GitRepositoryError
logger = logging.getLogger(__name__)
# Git empty tree hash - this is a well-known constant in git
# representing the hash of an empty tree object
GIT_EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
def run_git_command(args: list[str], cwd: str | Path) -> str:
"""Run a git command safely without shell injection vulnerabilities.
Args:
args: List of command arguments (e.g., ['git', 'status', '--porcelain'])
cwd: Working directory to run the command in
Returns:
Command output as string
Raises:
GitCommandError: If the git command fails
"""
try:
result = subprocess.run(
args,
cwd=cwd,
capture_output=True,
text=True,
check=False,
timeout=30, # Prevent hanging commands
)
if result.returncode != 0:
cmd_str = shlex.join(args)
error_msg = f"Git command failed: {cmd_str}"
logger.error(
f"{error_msg}. Exit code: {result.returncode}. Stderr: {result.stderr}"
)
raise GitCommandError(
message=error_msg,
command=args,
exit_code=result.returncode,
stderr=result.stderr.strip(),
)
logger.debug(f"Git command succeeded: {shlex.join(args)}")
return result.stdout.strip()
except subprocess.TimeoutExpired as e:
cmd_str = shlex.join(args)
error_msg = f"Git command timed out: {cmd_str}"
logger.error(error_msg)
raise GitCommandError(
message=error_msg,
command=args,
exit_code=-1,
stderr="Command timed out",
) from e
except FileNotFoundError as e:
error_msg = "Git command not found. Is git installed?"
logger.error(error_msg)
raise GitCommandError(
message=error_msg,
command=args,
exit_code=-1,
stderr="Git executable not found",
) from e
def get_valid_ref(repo_dir: str | Path) -> str | None:
"""Get a valid git reference to compare against.
Tries multiple strategies to find a valid reference:
1. Current branch's origin (e.g., origin/main)
2. Default branch (e.g., origin/main, origin/master)
3. Merge base with default branch
4. Empty tree (for new repositories)
Args:
repo_dir: Path to the git repository
Returns:
Valid git reference hash, or None if no valid reference found
"""
refs_to_try = []
# Try current branch's origin
try:
current_branch = run_git_command(
["git", "--no-pager", "rev-parse", "--abbrev-ref", "HEAD"], repo_dir
)
if current_branch and current_branch != "HEAD": # Not in detached HEAD state
refs_to_try.append(f"origin/{current_branch}")
logger.debug(f"Added current branch reference: origin/{current_branch}")
except GitCommandError:
logger.debug("Could not get current branch name")
# Try to get default branch from remote
try:
remote_info = run_git_command(
["git", "--no-pager", "remote", "show", "origin"], repo_dir
)
for line in remote_info.splitlines():
if "HEAD branch:" in line:
default_branch = line.split(":")[-1].strip()
if default_branch:
refs_to_try.append(f"origin/{default_branch}")
logger.debug(
f"Added default branch reference: origin/{default_branch}"
)
# Also try merge base with default branch
try:
merge_base = run_git_command(
[
"git",
"--no-pager",
"merge-base",
"HEAD",
f"origin/{default_branch}",
],
repo_dir,
)
if merge_base:
refs_to_try.append(merge_base)
logger.debug(f"Added merge base reference: {merge_base}")
except GitCommandError:
logger.debug("Could not get merge base")
break
except GitCommandError:
logger.debug("Could not get remote information")
# Add empty tree as fallback for new repositories
refs_to_try.append(GIT_EMPTY_TREE_HASH)
logger.debug(f"Added empty tree reference: {GIT_EMPTY_TREE_HASH}")
# Find the first valid reference
for ref in refs_to_try:
try:
result = run_git_command(
["git", "--no-pager", "rev-parse", "--verify", ref], repo_dir
)
if result:
logger.debug(f"Using valid reference: {ref} -> {result}")
return result
except GitCommandError:
logger.debug(f"Reference not valid: {ref}")
continue
logger.warning("No valid git reference found")
return None
def validate_git_repository(repo_dir: str | Path) -> Path:
"""Validate that the given directory is a git repository.
Args:
repo_dir: Path to check
Returns:
Validated Path object
Raises:
GitRepositoryError: If not a valid git repository
"""
repo_path = Path(repo_dir).resolve()
if not repo_path.exists():
raise GitRepositoryError(f"Directory does not exist: {repo_path}")
if not repo_path.is_dir():
raise GitRepositoryError(f"Path is not a directory: {repo_path}")
# Check if it's a git repository by looking for .git directory or file
git_dir = repo_path / ".git"
if not git_dir.exists():
# Maybe we're in a subdirectory, try to find the git root
try:
run_git_command(["git", "rev-parse", "--git-dir"], repo_path)
except GitCommandError as e:
raise GitRepositoryError(f"Not a git repository: {repo_path}") from e
return repo_path
@@ -4,6 +4,7 @@ from typing import Any
from pydantic import Field
from openhands.sdk.git.models import GitChange, GitDiff
from openhands.sdk.logger import get_logger
from openhands.sdk.utils.models import DiscriminatedUnionMixin
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
@@ -109,3 +110,31 @@ class BaseWorkspace(DiscriminatedUnionMixin, ABC):
Exception: If file download fails
"""
...
@abstractmethod
def git_changes(self, path: str | Path) -> list[GitChange]:
"""Get the git changes for the repository at the path given.
Args:
path: Path to the git repository
Returns:
list[GitChange]: List of changes
Raises:
Exception: If path is not a git repository or getting changes failed
"""
@abstractmethod
def git_diff(self, path: str | Path) -> GitDiff:
"""Get the git diff for the file at the path given.
Args:
path: Path to the file
Returns:
GitDiff: Git diff
Raises:
Exception: If path is not a git repository or getting diff failed
"""
@@ -1,6 +1,9 @@
import shutil
from pathlib import Path
from openhands.sdk.git.git_changes import get_git_changes
from openhands.sdk.git.git_diff import get_git_diff
from openhands.sdk.git.models import GitChange, GitDiff
from openhands.sdk.logger import get_logger
from openhands.sdk.utils.command import execute_command
from openhands.sdk.workspace.base import BaseWorkspace
@@ -137,3 +140,33 @@ class LocalWorkspace(BaseWorkspace):
destination_path=str(destination),
error=str(e),
)
def git_changes(self, path: str | Path) -> list[GitChange]:
"""Get the git changes for the repository at the path given.
Args:
path: Path to the git repository
Returns:
list[GitChange]: List of changes
Raises:
Exception: If path is not a git repository or getting changes failed
"""
path = Path(self.working_dir) / path
return get_git_changes(path)
def git_diff(self, path: str | Path) -> GitDiff:
"""Get the git diff for the file at the path given.
Args:
path: Path to the file
Returns:
GitDiff: Git diff
Raises:
Exception: If path is not a git repository or getting diff failed
"""
path = Path(self.working_dir) / path
return get_git_diff(path)
@@ -5,6 +5,7 @@ from typing import Any
import httpx
from pydantic import PrivateAttr
from openhands.sdk.git.models import GitChange, GitDiff
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
from openhands.sdk.workspace.remote.remote_workspace_mixin import RemoteWorkspaceMixin
@@ -93,3 +94,35 @@ class AsyncRemoteWorkspace(RemoteWorkspaceMixin):
generator = self._file_download_generator(source_path, destination_path)
result = await self._execute(generator)
return result
async def git_changes(self, path: str | Path) -> list[GitChange]:
"""Get the git changes for the repository at the path given.
Args:
path: Path to the git repository
Returns:
list[GitChange]: List of changes
Raises:
Exception: If path is not a git repository or getting changes failed
"""
generator = self._git_changes_generator(path)
result = await self._execute(generator)
return result
async def git_diff(self, path: str | Path) -> GitDiff:
"""Get the git diff for the file at the path given.
Args:
path: Path to the file
Returns:
GitDiff: Git diff
Raises:
Exception: If path is not a git repository or getting diff failed
"""
generator = self._git_diff_generator(path)
result = await self._execute(generator)
return result
@@ -5,6 +5,7 @@ from typing import Any
import httpx
from pydantic import PrivateAttr
from openhands.sdk.git.models import GitChange, GitDiff
from openhands.sdk.workspace.base import BaseWorkspace
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
from openhands.sdk.workspace.remote.remote_workspace_mixin import RemoteWorkspaceMixin
@@ -100,3 +101,35 @@ class RemoteWorkspace(RemoteWorkspaceMixin, BaseWorkspace):
generator = self._file_download_generator(source_path, destination_path)
result = self._execute(generator)
return result
def git_changes(self, path: str | Path) -> list[GitChange]:
"""Get the git changes for the repository at the path given.
Args:
path: Path to the git repository
Returns:
list[GitChange]: List of changes
Raises:
Exception: If path is not a git repository or getting changes failed
"""
generator = self._git_changes_generator(path)
result = self._execute(generator)
return result
def git_diff(self, path: str | Path) -> GitDiff:
"""Get the git diff for the file at the path given.
Args:
path: Path to the file
Returns:
GitDiff: Git diff
Raises:
Exception: If path is not a git repository or getting diff failed
"""
generator = self._git_diff_generator(path)
result = self._execute(generator)
return result
@@ -5,8 +5,9 @@ from pathlib import Path
from typing import Any
import httpx
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, TypeAdapter
from openhands.sdk.git.models import GitChange, GitDiff
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
@@ -265,3 +266,56 @@ class RemoteWorkspaceMixin(BaseModel):
destination_path=str(destination),
error=str(e),
)
def _git_changes_generator(
self,
path: str | Path,
) -> Generator[dict[str, Any], httpx.Response, list[GitChange]]:
"""Get the git changes for the repository at the path given.
Args:
path: Path to the git repository
Returns:
list[GitChange]: List of changes
Raises:
Exception: If path is not a git repository or getting changes failed
"""
# Make HTTP call
response = yield {
"method": "GET",
"url": Path("/api/git/changes") / self.working_dir / path,
"headers": self._headers,
"timeout": 60.0,
}
response.raise_for_status()
type_adapter = TypeAdapter(list[GitChange])
changes = type_adapter.validate_python(response.json())
return changes
def _git_diff_generator(
self,
path: str | Path,
) -> Generator[dict[str, Any], httpx.Response, GitDiff]:
"""Get the git diff for the file at the path given.
Args:
path: Path to the file
Returns:
GitDiff: Git diff
Raises:
Exception: If path is not a git repository or getting diff failed
"""
# Make HTTP call
response = yield {
"method": "GET",
"url": Path("/api/git/diff") / self.working_dir / path,
"headers": self._headers,
"timeout": 60.0,
}
response.raise_for_status()
diff = GitDiff.model_validate(response.json())
return diff
+214
View File
@@ -0,0 +1,214 @@
"""Tests for git_router.py endpoints."""
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from openhands.agent_server.api import create_app
from openhands.agent_server.config import Config
from openhands.sdk.git.models import GitChange, GitChangeStatus, GitDiff
@pytest.fixture
def client():
"""Create a test client for the FastAPI app without authentication."""
config = Config(session_api_keys=[]) # Disable authentication
return TestClient(create_app(config), raise_server_exceptions=False)
@pytest.mark.asyncio
async def test_git_changes_success(client):
"""Test successful git changes endpoint."""
# Mock the get_git_changes function
expected_changes = [
GitChange(status=GitChangeStatus.ADDED, path=Path("new_file.py")),
GitChange(status=GitChangeStatus.UPDATED, path=Path("existing_file.py")),
GitChange(status=GitChangeStatus.DELETED, path=Path("old_file.py")),
]
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
mock_git_changes.return_value = expected_changes
test_path = "src/test_repo"
response = client.get(f"/api/git/changes/{test_path}")
assert response.status_code == 200
response_data = response.json()
# Verify the response structure
assert len(response_data) == 3
assert response_data[0]["status"] == "ADDED"
assert response_data[0]["path"] == "new_file.py"
assert response_data[1]["status"] == "UPDATED"
assert response_data[1]["path"] == "existing_file.py"
assert response_data[2]["status"] == "DELETED"
assert response_data[2]["path"] == "old_file.py"
# Verify the mock was called correctly
mock_git_changes.assert_called_once_with(Path(test_path))
@pytest.mark.asyncio
async def test_git_changes_empty_result(client):
"""Test git changes endpoint with no changes."""
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
mock_git_changes.return_value = []
test_path = "src/empty_repo"
response = client.get(f"/api/git/changes/{test_path}")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_git_changes_with_exception(client):
"""Test git changes endpoint when git operation fails."""
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
mock_git_changes.side_effect = Exception("Git repository not found")
test_path = "nonexistent/repo"
response = client.get(f"/api/git/changes/{test_path}")
# Should return 500 due to exception
assert response.status_code == 500
@pytest.mark.asyncio
async def test_git_diff_success(client):
"""Test successful git diff endpoint."""
# Mock the get_git_diff function
expected_diff = GitDiff(
modified="def new_function():\n return 'updated'",
original="def old_function():\n return 'original'",
)
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
mock_git_diff.return_value = expected_diff
test_path = "src/test_file.py"
response = client.get(f"/api/git/diff/{test_path}")
assert response.status_code == 200
response_data = response.json()
# Verify the response structure
assert response_data["modified"] == expected_diff.modified
assert response_data["original"] == expected_diff.original
# Verify the mock was called correctly (now expects Path object)
mock_git_diff.assert_called_once_with(Path(test_path))
@pytest.mark.asyncio
async def test_git_diff_with_none_values(client):
"""Test git diff endpoint with None values."""
# Mock the get_git_diff function with None values
expected_diff = GitDiff(modified=None, original=None)
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
mock_git_diff.return_value = expected_diff
test_path = "nonexistent_file.py"
response = client.get(f"/api/git/diff/{test_path}")
assert response.status_code == 200
response_data = response.json()
# Verify the response structure
assert response_data["modified"] is None
assert response_data["original"] is None
@pytest.mark.asyncio
async def test_git_diff_with_exception(client):
"""Test git diff endpoint when git operation fails."""
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
mock_git_diff.side_effect = Exception("Git diff failed")
test_path = "nonexistent/file.py"
response = client.get(f"/api/git/diff/{test_path}")
# Should return 500 due to exception
assert response.status_code == 500
@pytest.mark.asyncio
async def test_git_diff_nested_path(client):
"""Test git diff endpoint with nested file path."""
expected_diff = GitDiff(modified="updated content", original="original content")
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
mock_git_diff.return_value = expected_diff
# Test with nested path
test_path = "src/utils/helper.py"
response = client.get(f"/api/git/diff/{test_path}")
assert response.status_code == 200
# Verify the correct path was passed (now expects Path object)
mock_git_diff.assert_called_once_with(Path(test_path))
@pytest.mark.asyncio
async def test_git_changes_with_all_status_types(client):
"""Test git changes endpoint with all possible GitChangeStatus values."""
# Test all possible status types
expected_changes = [
GitChange(status=GitChangeStatus.ADDED, path=Path("added.py")),
GitChange(status=GitChangeStatus.UPDATED, path=Path("updated.py")),
GitChange(status=GitChangeStatus.DELETED, path=Path("deleted.py")),
GitChange(status=GitChangeStatus.MOVED, path=Path("moved.py")),
]
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
mock_git_changes.return_value = expected_changes
test_path = "src/test_repo"
response = client.get(f"/api/git/changes/{test_path}")
assert response.status_code == 200
response_data = response.json()
assert len(response_data) == 4
assert response_data[0]["status"] == "ADDED"
assert response_data[1]["status"] == "UPDATED"
assert response_data[2]["status"] == "DELETED"
assert response_data[3]["status"] == "MOVED"
@pytest.mark.asyncio
async def test_git_changes_with_complex_paths(client):
"""Test git changes endpoint with complex file paths."""
# Test with various path complexities
expected_changes = [
GitChange(
status=GitChangeStatus.ADDED,
path=Path("src/deep/nested/file.py"),
),
GitChange(
status=GitChangeStatus.UPDATED,
path=Path("file with spaces.txt"),
),
GitChange(
status=GitChangeStatus.DELETED,
path=Path("special-chars_file@123.py"),
),
]
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
mock_git_changes.return_value = expected_changes
test_path = "src/complex_repo"
response = client.get(f"/api/git/changes/{test_path}")
assert response.status_code == 200
response_data = response.json()
assert len(response_data) == 3
assert response_data[0]["path"] == "src/deep/nested/file.py"
assert response_data[1]["path"] == "file with spaces.txt"
assert response_data[2]["path"] == "special-chars_file@123.py"
+1
View File
@@ -0,0 +1 @@
"""Tests for git functionality."""
+350
View File
@@ -0,0 +1,350 @@
"""Tests for git_changes.py functionality using temporary directories and bash commands.""" # noqa: E501
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
from openhands.sdk.git.git_changes import get_changes_in_repo, get_git_changes
from openhands.sdk.git.models import GitChange, GitChangeStatus
def run_bash_command(command: str, cwd: str) -> subprocess.CompletedProcess:
"""Run a bash command in the specified directory."""
return subprocess.run(
command,
shell=True,
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
def setup_git_repo(repo_dir: str) -> None:
"""Initialize a git repository with basic configuration."""
run_bash_command("git init", repo_dir)
run_bash_command("git config user.name 'Test User'", repo_dir)
run_bash_command("git config user.email 'test@example.com'", repo_dir)
def test_get_changes_in_repo_empty_repository():
"""Test get_changes_in_repo with an empty repository."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
changes = get_changes_in_repo(temp_dir)
assert changes == []
def test_get_changes_in_repo_new_files():
"""Test get_changes_in_repo with new files."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create new files
(Path(temp_dir) / "file1.txt").write_text("Hello World")
(Path(temp_dir) / "file2.py").write_text("print('Hello')")
changes = get_changes_in_repo(temp_dir)
assert len(changes) == 2
# Sort by path for consistent testing
changes.sort(key=lambda x: str(x.path))
assert changes[0].path == Path("file1.txt")
assert changes[0].status == GitChangeStatus.ADDED
assert changes[1].path == Path("file2.py")
assert changes[1].status == GitChangeStatus.ADDED
def test_get_changes_in_repo_modified_files():
"""Test get_changes_in_repo with modified files."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit initial files
(Path(temp_dir) / "file1.txt").write_text("Initial content")
(Path(temp_dir) / "file2.py").write_text("print('Initial')")
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Modify files
(Path(temp_dir) / "file1.txt").write_text("Modified content")
(Path(temp_dir) / "file2.py").write_text("print('Modified')")
changes = get_changes_in_repo(temp_dir)
# The function compares against empty tree for new repos without remote
# So modified files appear as ADDED since there's no remote origin
assert len(changes) == 2
# Sort by path for consistent testing
changes.sort(key=lambda x: str(x.path))
assert changes[0].path == Path("file1.txt")
assert changes[0].status == GitChangeStatus.ADDED
assert changes[1].path == Path("file2.py")
assert changes[1].status == GitChangeStatus.ADDED
def test_get_changes_in_repo_deleted_files():
"""Test get_changes_in_repo with deleted files."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit initial files
(Path(temp_dir) / "file1.txt").write_text("Content to delete")
(Path(temp_dir) / "file2.py").write_text("print('To delete')")
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Delete files
os.remove(Path(temp_dir) / "file1.txt")
os.remove(Path(temp_dir) / "file2.py")
changes = get_changes_in_repo(temp_dir)
# For repos without remote, deleted files don't show up in diff against empty tree # noqa: E501
# This is expected behavior - the function compares against empty tree
assert len(changes) == 0
def test_get_changes_in_repo_mixed_changes():
"""Test get_changes_in_repo with mixed file changes."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit initial files
(Path(temp_dir) / "existing.txt").write_text("Existing content")
(Path(temp_dir) / "to_modify.py").write_text("print('Original')")
(Path(temp_dir) / "to_delete.md").write_text("# To Delete")
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Make mixed changes
(Path(temp_dir) / "new_file.txt").write_text("New file content") # Added
(Path(temp_dir) / "to_modify.py").write_text("print('Modified')") # Modified
os.remove(Path(temp_dir) / "to_delete.md") # Deleted
changes = get_changes_in_repo(temp_dir)
# For repos without remote, all files (existing, new, modified) show up as ADDED
# when comparing against empty tree. Deleted files don't appear.
assert len(changes) == 3
# Convert to dict for easier testing
changes_dict = {str(change.path): change.status for change in changes}
assert changes_dict["existing.txt"] == GitChangeStatus.ADDED
assert changes_dict["new_file.txt"] == GitChangeStatus.ADDED
assert changes_dict["to_modify.py"] == GitChangeStatus.ADDED
def test_get_changes_in_repo_nested_directories():
"""Test get_changes_in_repo with files in nested directories."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create nested directory structure
nested_dir = Path(temp_dir) / "src" / "utils"
nested_dir.mkdir(parents=True)
(nested_dir / "helper.py").write_text("def helper(): pass")
(Path(temp_dir) / "src" / "main.py").write_text("import utils.helper")
(Path(temp_dir) / "README.md").write_text("# Project")
changes = get_changes_in_repo(temp_dir)
assert len(changes) == 3
# Convert to set of paths for easier testing
paths = {str(change.path) for change in changes}
assert "src/utils/helper.py" in paths
assert "src/main.py" in paths
assert "README.md" in paths
# All should be added files
for change in changes:
assert change.status == GitChangeStatus.ADDED
def test_get_changes_in_repo_staged_and_unstaged():
"""Test get_changes_in_repo with both staged and unstaged changes."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit initial file
(Path(temp_dir) / "file.txt").write_text("Initial")
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Make changes and stage some
(Path(temp_dir) / "file.txt").write_text("Modified")
(Path(temp_dir) / "staged.txt").write_text("Staged content")
(Path(temp_dir) / "unstaged.txt").write_text("Unstaged content")
# Stage some changes
run_bash_command("git add staged.txt", temp_dir)
changes = get_changes_in_repo(temp_dir)
assert len(changes) == 3
# Convert to dict for easier testing
changes_dict = {str(change.path): change.status for change in changes}
# All files appear as ADDED when comparing against empty tree
assert changes_dict["file.txt"] == GitChangeStatus.ADDED
assert changes_dict["staged.txt"] == GitChangeStatus.ADDED
assert changes_dict["unstaged.txt"] == GitChangeStatus.ADDED
def test_get_changes_in_repo_non_git_directory():
"""Test get_changes_in_repo with a non-git directory."""
from openhands.sdk.git.exceptions import GitRepositoryError
with tempfile.TemporaryDirectory() as temp_dir:
# Don't initialize git repo
(Path(temp_dir) / "file.txt").write_text("Content")
with pytest.raises(GitRepositoryError):
get_changes_in_repo(temp_dir)
def test_get_changes_in_repo_nonexistent_directory():
"""Test get_changes_in_repo with a nonexistent directory."""
from openhands.sdk.git.exceptions import GitRepositoryError
# The function will raise an exception for nonexistent directories
with pytest.raises(GitRepositoryError):
get_changes_in_repo("/nonexistent/directory")
def test_get_git_changes_function():
"""Test the get_git_changes function (main entry point)."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create test files
(Path(temp_dir) / "test1.txt").write_text("Test content 1")
(Path(temp_dir) / "test2.py").write_text("print('Test 2')")
# Call get_git_changes with explicit path
changes = get_git_changes(temp_dir)
assert len(changes) == 2
# Sort by path for consistent testing
changes.sort(key=lambda x: str(x.path))
assert changes[0].path == Path("test1.txt")
assert changes[0].status == GitChangeStatus.ADDED
assert changes[1].path == Path("test2.py")
assert changes[1].status == GitChangeStatus.ADDED
def test_get_git_changes_with_path_argument():
"""Test get_git_changes with explicit path argument."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create test files
(Path(temp_dir) / "explicit_path.txt").write_text("Explicit path test")
changes = get_git_changes(temp_dir)
assert len(changes) == 1
assert changes[0].path == Path("explicit_path.txt")
assert changes[0].status == GitChangeStatus.ADDED
def test_git_change_model_properties():
"""Test GitChange model properties and serialization."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create a test file
test_file = Path(temp_dir) / "model_test.py"
test_file.write_text("# Model test file")
changes = get_changes_in_repo(temp_dir)
assert len(changes) == 1
change = changes[0]
# Test model properties
assert isinstance(change, GitChange)
assert isinstance(change.path, Path)
assert isinstance(change.status, GitChangeStatus)
assert change.path == Path("model_test.py")
assert change.status == GitChangeStatus.ADDED
# Test serialization
change_dict = change.model_dump()
assert "path" in change_dict
assert "status" in change_dict
assert change_dict["status"] == GitChangeStatus.ADDED
def test_git_changes_with_gitignore():
"""Test that gitignore files are respected."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create .gitignore
(Path(temp_dir) / ".gitignore").write_text("*.log\n__pycache__/\n")
# Create files that should be ignored
(Path(temp_dir) / "debug.log").write_text("Log content")
pycache_dir = Path(temp_dir) / "__pycache__"
pycache_dir.mkdir()
(pycache_dir / "module.pyc").write_text("Compiled python")
# Create files that should not be ignored
(Path(temp_dir) / "main.py").write_text("print('Main')")
changes = get_changes_in_repo(temp_dir)
# Should only see .gitignore and main.py, not the ignored files
paths = {str(change.path) for change in changes}
assert ".gitignore" in paths
assert "main.py" in paths
assert "debug.log" not in paths
assert "__pycache__/module.pyc" not in paths
def test_git_changes_with_binary_files():
"""Test git changes detection with binary files."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create a binary file (simulate with bytes)
binary_file = Path(temp_dir) / "image.png"
binary_file.write_bytes(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00")
# Create a text file
(Path(temp_dir) / "text.txt").write_text("Text content")
changes = get_changes_in_repo(temp_dir)
assert len(changes) == 2
# Both files should be detected as added
paths = {str(change.path) for change in changes}
assert "image.png" in paths
assert "text.txt" in paths
for change in changes:
assert change.status == GitChangeStatus.ADDED
+283
View File
@@ -0,0 +1,283 @@
"""Tests for git_diff.py functionality using temporary directories and bash commands."""
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
from openhands.sdk.git.git_diff import get_closest_git_repo, get_git_diff
from openhands.sdk.git.models import GitDiff
def run_bash_command(command: str, cwd: str) -> subprocess.CompletedProcess:
"""Run a bash command in the specified directory."""
return subprocess.run(
command,
shell=True,
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
def setup_git_repo(repo_dir: str) -> None:
"""Initialize a git repository with basic configuration."""
run_bash_command("git init", repo_dir)
run_bash_command("git config user.name 'Test User'", repo_dir)
run_bash_command("git config user.email 'test@example.com'", repo_dir)
def run_in_directory(temp_dir: str, func, *args, **kwargs):
"""Helper to run a function in a specific directory."""
original_cwd = os.getcwd()
try:
os.chdir(temp_dir)
return func(*args, **kwargs)
finally:
os.chdir(original_cwd)
def test_get_git_diff_new_file():
"""Test get_git_diff with a new file."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create a new file
test_file = Path(temp_dir) / "new_file.txt"
test_content = "This is a new file\nwith multiple lines\nof content."
test_file.write_text(test_content)
diff = run_in_directory(temp_dir, get_git_diff, "new_file.txt")
assert isinstance(diff, GitDiff)
assert diff.modified == test_content
assert diff.original == "" # Empty string for new files
def test_get_git_diff_modified_file():
"""Test get_git_diff with a modified file."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit initial file
test_file = Path(temp_dir) / "modified_file.txt"
original_content = "Original content\nLine 2\nLine 3"
test_file.write_text(original_content)
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Modify the file
modified_content = "Modified content\nLine 2 changed\nLine 3\nNew line 4"
test_file.write_text(modified_content)
diff = run_in_directory(temp_dir, get_git_diff, "modified_file.txt")
assert isinstance(diff, GitDiff)
assert diff.modified == modified_content
# For repos without remote, original is empty when comparing against empty tree
assert diff.original == ""
def test_get_git_diff_deleted_file():
"""Test get_git_diff with a deleted file."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit initial file
test_file = Path(temp_dir) / "deleted_file.txt"
original_content = "This file will be deleted\nLine 2\nLine 3"
test_file.write_text(original_content)
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Delete the file
os.remove(test_file)
# The function will raise GitPathError for deleted files
from openhands.sdk.git.exceptions import GitPathError
with pytest.raises(GitPathError):
run_in_directory(temp_dir, get_git_diff, "deleted_file.txt")
def test_get_git_diff_nested_path():
"""Test get_git_diff with files in nested directories."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create nested directory structure
nested_dir = Path(temp_dir) / "src" / "utils"
nested_dir.mkdir(parents=True)
# Create and commit initial file
test_file = nested_dir / "helper.py"
original_content = "def helper():\n return 'original'"
test_file.write_text(original_content)
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Modify the file
modified_content = (
"def helper():\n return 'modified'\n\ndef new_function():\n pass"
)
test_file.write_text(modified_content)
diff = run_in_directory(temp_dir, get_git_diff, "src/utils/helper.py")
assert isinstance(diff, GitDiff)
assert diff.modified == modified_content
# For repos without remote, original is empty when comparing against empty tree
assert diff.original == ""
def test_get_git_diff_no_repository():
"""Test get_git_diff with a non-git directory."""
with tempfile.TemporaryDirectory() as temp_dir:
# Don't initialize git repo
test_file = Path(temp_dir) / "file.txt"
test_file.write_text("Content")
from openhands.sdk.git.exceptions import GitRepositoryError
with pytest.raises(GitRepositoryError):
run_in_directory(temp_dir, get_git_diff, "file.txt")
def test_get_git_diff_nonexistent_file():
"""Test get_git_diff with a nonexistent file."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
from openhands.sdk.git.exceptions import GitPathError
with pytest.raises(GitPathError):
run_in_directory(temp_dir, get_git_diff, "nonexistent.txt")
def test_get_closest_git_repo():
"""Test the get_closest_git_repo helper function."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create nested directory structure
nested_dir = Path(temp_dir) / "src" / "utils"
nested_dir.mkdir(parents=True)
# Test finding git repo from nested directory
git_repo = get_closest_git_repo(nested_dir)
assert git_repo == Path(temp_dir)
# Test with non-git directory
with tempfile.TemporaryDirectory() as non_git_dir:
git_repo = get_closest_git_repo(Path(non_git_dir))
assert git_repo is None
def test_git_diff_model_properties():
"""Test GitDiff model properties and serialization."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit initial file
test_file = Path(temp_dir) / "model_test.py"
original_content = "# Original model test"
test_file.write_text(original_content)
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Modify the file
modified_content = "# Modified model test\nprint('Hello')"
test_file.write_text(modified_content)
diff = run_in_directory(temp_dir, get_git_diff, "model_test.py")
# Test model properties
assert isinstance(diff, GitDiff)
assert isinstance(diff.modified, str)
assert isinstance(diff.original, str)
assert diff.modified == modified_content
# For repos without remote, original is empty when comparing against empty tree
assert diff.original == ""
# Test serialization
diff_dict = diff.model_dump()
assert "modified" in diff_dict
assert "original" in diff_dict
assert diff_dict["modified"] == modified_content
assert diff_dict["original"] == ""
def test_git_diff_with_empty_file():
"""Test git diff with empty files."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create and commit empty file
test_file = Path(temp_dir) / "empty.txt"
test_file.write_text("")
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Add content to the file
new_content = "Now has content"
test_file.write_text(new_content)
diff = run_in_directory(temp_dir, get_git_diff, "empty.txt")
assert isinstance(diff, GitDiff)
assert diff.modified == new_content
assert diff.original == ""
def test_git_diff_with_special_characters():
"""Test git diff with files containing special characters."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create file with special characters
test_file = Path(temp_dir) / "special_chars.txt"
original_content = (
"Original: àáâãäå\n中文\n🚀 emoji\n\"quotes\" and 'apostrophes'"
)
test_file.write_text(original_content, encoding="utf-8")
run_bash_command("git add .", temp_dir)
run_bash_command("git commit -m 'Initial commit'", temp_dir)
# Modify with more special characters
modified_content = (
"Modified: àáâãäå\n中文修改\n🎉 new emoji\n"
"\"new quotes\" and 'new apostrophes'\n\ttabs and\nlines"
)
test_file.write_text(modified_content, encoding="utf-8")
diff = run_in_directory(temp_dir, get_git_diff, "special_chars.txt")
assert isinstance(diff, GitDiff)
assert diff.modified == modified_content
# For repos without remote, original is empty when comparing against empty tree
assert diff.original == ""
def test_git_diff_large_file_error():
"""Test git diff with a file that's too large."""
with tempfile.TemporaryDirectory() as temp_dir:
setup_git_repo(temp_dir)
# Create a file larger than MAX_FILE_SIZE_FOR_GIT_DIFF (1MB)
test_file = Path(temp_dir) / "large_file.txt"
large_content = "x" * (1024 * 1024 + 1) # 1MB + 1 byte
test_file.write_text(large_content)
from openhands.sdk.git.exceptions import GitPathError
with pytest.raises(GitPathError):
run_in_directory(temp_dir, get_git_diff, "large_file.txt")