mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Feat git operations (#863)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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)}))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for git functionality."""
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user