mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
295 lines
12 KiB
Python
295 lines
12 KiB
Python
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from openhands.runtime.utils import git_changes, git_diff, git_handler
|
|
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == 'win32', reason='Windows is not supported')
|
|
class TestGitHandler(unittest.TestCase):
|
|
def setUp(self):
|
|
# Create temporary directories for our test repositories
|
|
self.test_dir = tempfile.mkdtemp()
|
|
self.origin_dir = os.path.join(self.test_dir, 'origin')
|
|
self.local_dir = os.path.join(self.test_dir, 'local')
|
|
|
|
# Create the directories
|
|
os.makedirs(self.origin_dir, exist_ok=True)
|
|
os.makedirs(self.local_dir, exist_ok=True)
|
|
|
|
# Track executed commands for verification
|
|
self.executed_commands = []
|
|
self.created_files = []
|
|
|
|
# Initialize the GitHandler with our mock functions
|
|
self.git_handler = GitHandler(
|
|
execute_shell_fn=self._execute_command, create_file_fn=self._create_file
|
|
)
|
|
self.git_handler.set_cwd(self.local_dir)
|
|
|
|
self.git_handler.git_changes_cmd = f'python3 {git_changes.__file__}'
|
|
self.git_handler.git_diff_cmd = f'python3 {git_diff.__file__} "{{file_path}}"'
|
|
|
|
# Set up the git repositories
|
|
self._setup_git_repos()
|
|
|
|
def tearDown(self):
|
|
# Clean up the temporary directories
|
|
shutil.rmtree(self.test_dir)
|
|
|
|
def _execute_command(self, cmd, cwd=None):
|
|
"""Execute a shell command and return the result."""
|
|
result = subprocess.run(
|
|
args=cmd,
|
|
shell=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
cwd=cwd,
|
|
)
|
|
stderr = result.stderr or b''
|
|
stdout = result.stdout or b''
|
|
return CommandResult((stderr + stdout).decode(), result.returncode)
|
|
|
|
def run_command(self, cmd, cwd=None):
|
|
result = self._execute_command(cmd, cwd)
|
|
if result.exit_code != 0:
|
|
raise RuntimeError(
|
|
f'command_error:{cmd};{result.exit_code};{result.content}'
|
|
)
|
|
|
|
def _create_file(self, path, content):
|
|
"""Mock function for creating files."""
|
|
self.created_files.append((path, content))
|
|
try:
|
|
with open(path, 'w') as f:
|
|
f.write(content)
|
|
return 0
|
|
except Exception:
|
|
return -1
|
|
|
|
def write_file(
|
|
self,
|
|
dir: str,
|
|
name: str,
|
|
additional_content: tuple[str, ...] = ('Line 1', 'Line 2', 'Line 3'),
|
|
):
|
|
with open(os.path.join(dir, name), 'w') as f:
|
|
f.write(name)
|
|
for line in additional_content:
|
|
f.write('\n')
|
|
f.write(line)
|
|
assert os.path.exists(os.path.join(dir, name))
|
|
|
|
def _setup_git_repos(self):
|
|
"""Set up real git repositories for testing."""
|
|
# Set up origin repository
|
|
self.run_command('git init --initial-branch=main', self.origin_dir)
|
|
self._execute_command(
|
|
"git config user.email 'test@example.com'", self.origin_dir
|
|
)
|
|
self._execute_command("git config user.name 'Test User'", self.origin_dir)
|
|
|
|
# Set up the initial state...
|
|
self.write_file(self.origin_dir, 'unchanged.txt')
|
|
self.write_file(self.origin_dir, 'committed_modified.txt')
|
|
self.write_file(self.origin_dir, 'staged_modified.txt')
|
|
self.write_file(self.origin_dir, 'unstaged_modified.txt')
|
|
self.write_file(self.origin_dir, 'committed_delete.txt')
|
|
self.write_file(self.origin_dir, 'staged_delete.txt')
|
|
self.write_file(self.origin_dir, 'unstaged_delete.txt')
|
|
self.run_command("git add . && git commit -m 'Initial Commit'", self.origin_dir)
|
|
|
|
# Clone the origin repository to local
|
|
self.run_command(f'git clone "{self.origin_dir}" "{self.local_dir}"')
|
|
|
|
self._execute_command(
|
|
"git config user.email 'test@example.com'", self.local_dir
|
|
)
|
|
self._execute_command("git config user.name 'Test User'", self.local_dir)
|
|
|
|
self.run_command('git checkout -b feature-branch', self.local_dir)
|
|
|
|
# Setup committed changes...
|
|
self.write_file(self.local_dir, 'committed_modified.txt', ('Line 4',))
|
|
self.write_file(self.local_dir, 'committed_add.txt')
|
|
os.remove(os.path.join(self.local_dir, 'committed_delete.txt'))
|
|
self.run_command(
|
|
"git add . && git commit -m 'First batch of changes'", self.local_dir
|
|
)
|
|
|
|
# Setup staged changes...
|
|
self.write_file(self.local_dir, 'staged_modified.txt', ('Line 4',))
|
|
self.write_file(self.local_dir, 'staged_add.txt')
|
|
os.remove(os.path.join(self.local_dir, 'staged_delete.txt'))
|
|
self.run_command('git add .', self.local_dir)
|
|
|
|
# Setup unstaged changes...
|
|
self.write_file(self.local_dir, 'unstaged_modified.txt', ('Line 4',))
|
|
self.write_file(self.local_dir, 'unstaged_add.txt')
|
|
os.remove(os.path.join(self.local_dir, 'unstaged_delete.txt'))
|
|
|
|
def setup_nested(self):
|
|
nested_1 = Path(self.local_dir, 'nested 1')
|
|
nested_1.mkdir()
|
|
nested_1 = str(nested_1)
|
|
self.run_command('git init --initial-branch=main', nested_1)
|
|
self._execute_command("git config user.email 'test@example.com'", nested_1)
|
|
self._execute_command("git config user.name 'Test User'", nested_1)
|
|
self.write_file(nested_1, 'committed_add.txt')
|
|
self.run_command('git add .', nested_1)
|
|
self.run_command('git commit -m "Initial Commit"', nested_1)
|
|
self.write_file(nested_1, 'staged_add.txt')
|
|
|
|
nested_2 = Path(self.local_dir, 'nested_2')
|
|
nested_2.mkdir()
|
|
nested_2 = str(nested_2)
|
|
self.run_command('git init --initial-branch=main', nested_2)
|
|
self._execute_command("git config user.email 'test@example.com'", nested_2)
|
|
self._execute_command("git config user.name 'Test User'", nested_2)
|
|
self.write_file(nested_2, 'committed_add.txt')
|
|
self.run_command('git add .', nested_2)
|
|
self.run_command('git commit -m "Initial Commit"', nested_2)
|
|
self.write_file(nested_2, 'unstaged_add.txt')
|
|
|
|
def test_get_git_changes(self):
|
|
"""Test with unpushed commits, staged commits, and unstaged commits"""
|
|
changes = self.git_handler.get_git_changes()
|
|
|
|
expected_changes = [
|
|
{'status': 'A', 'path': 'committed_add.txt'},
|
|
{'status': 'D', 'path': 'committed_delete.txt'},
|
|
{'status': 'M', 'path': 'committed_modified.txt'},
|
|
{'status': 'A', 'path': 'staged_add.txt'},
|
|
{'status': 'D', 'path': 'staged_delete.txt'},
|
|
{'status': 'M', 'path': 'staged_modified.txt'},
|
|
{'status': 'A', 'path': 'unstaged_add.txt'},
|
|
{'status': 'D', 'path': 'unstaged_delete.txt'},
|
|
{'status': 'M', 'path': 'unstaged_modified.txt'},
|
|
]
|
|
|
|
assert changes == expected_changes
|
|
|
|
def test_get_git_changes_after_push(self):
|
|
"""Test with staged commits, and unstaged commits"""
|
|
self.run_command('git push -u origin feature-branch', self.local_dir)
|
|
changes = self.git_handler.get_git_changes()
|
|
|
|
expected_changes = [
|
|
{'status': 'A', 'path': 'staged_add.txt'},
|
|
{'status': 'D', 'path': 'staged_delete.txt'},
|
|
{'status': 'M', 'path': 'staged_modified.txt'},
|
|
{'status': 'A', 'path': 'unstaged_add.txt'},
|
|
{'status': 'D', 'path': 'unstaged_delete.txt'},
|
|
{'status': 'M', 'path': 'unstaged_modified.txt'},
|
|
]
|
|
|
|
assert changes == expected_changes
|
|
|
|
def test_get_git_changes_nested_repos(self):
|
|
"""Test with staged commits, and unstaged commits"""
|
|
self.setup_nested()
|
|
|
|
changes = self.git_handler.get_git_changes()
|
|
|
|
expected_changes = [
|
|
{'status': 'A', 'path': 'committed_add.txt'},
|
|
{'status': 'D', 'path': 'committed_delete.txt'},
|
|
{'status': 'M', 'path': 'committed_modified.txt'},
|
|
{'status': 'A', 'path': 'nested 1/committed_add.txt'},
|
|
{'status': 'A', 'path': 'nested 1/staged_add.txt'},
|
|
{'status': 'A', 'path': 'nested_2/committed_add.txt'},
|
|
{'status': 'A', 'path': 'nested_2/unstaged_add.txt'},
|
|
{'status': 'A', 'path': 'staged_add.txt'},
|
|
{'status': 'D', 'path': 'staged_delete.txt'},
|
|
{'status': 'M', 'path': 'staged_modified.txt'},
|
|
{'status': 'A', 'path': 'unstaged_add.txt'},
|
|
{'status': 'D', 'path': 'unstaged_delete.txt'},
|
|
{'status': 'M', 'path': 'unstaged_modified.txt'},
|
|
]
|
|
|
|
assert changes == expected_changes
|
|
|
|
def test_get_git_diff_staged_modified(self):
|
|
"""Test on a staged modified"""
|
|
diff = self.git_handler.get_git_diff('staged_modified.txt')
|
|
expected_diff = {
|
|
'original': 'staged_modified.txt\nLine 1\nLine 2\nLine 3',
|
|
'modified': 'staged_modified.txt\nLine 4',
|
|
}
|
|
assert diff == expected_diff
|
|
|
|
def test_get_git_diff_unchanged(self):
|
|
"""Test that get_git_diff delegates to the git_diff module."""
|
|
diff = self.git_handler.get_git_diff('unchanged.txt')
|
|
expected_diff = {
|
|
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
|
|
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
|
|
}
|
|
assert diff == expected_diff
|
|
|
|
def test_get_git_diff_unpushed(self):
|
|
"""Test that get_git_diff delegates to the git_diff module."""
|
|
diff = self.git_handler.get_git_diff('committed_modified.txt')
|
|
expected_diff = {
|
|
'original': 'committed_modified.txt\nLine 1\nLine 2\nLine 3',
|
|
'modified': 'committed_modified.txt\nLine 4',
|
|
}
|
|
assert diff == expected_diff
|
|
|
|
def test_get_git_diff_unstaged_add(self):
|
|
"""Test that get_git_diff delegates to the git_diff module."""
|
|
diff = self.git_handler.get_git_diff('unstaged_add.txt')
|
|
expected_diff = {
|
|
'original': '',
|
|
'modified': 'unstaged_add.txt\nLine 1\nLine 2\nLine 3',
|
|
}
|
|
assert diff == expected_diff
|
|
|
|
def test_get_git_changes_fallback(self):
|
|
"""Test that get_git_changes falls back to creating a script file when needed."""
|
|
# Break the git changes command
|
|
with patch(
|
|
'openhands.runtime.utils.git_handler.GIT_CHANGES_CMD',
|
|
'non-existant-command',
|
|
):
|
|
self.git_handler.git_changes_cmd = git_handler.GIT_CHANGES_CMD
|
|
|
|
changes = self.git_handler.get_git_changes()
|
|
|
|
expected_changes = [
|
|
{'status': 'A', 'path': 'committed_add.txt'},
|
|
{'status': 'D', 'path': 'committed_delete.txt'},
|
|
{'status': 'M', 'path': 'committed_modified.txt'},
|
|
{'status': 'A', 'path': 'staged_add.txt'},
|
|
{'status': 'D', 'path': 'staged_delete.txt'},
|
|
{'status': 'M', 'path': 'staged_modified.txt'},
|
|
{'status': 'A', 'path': 'unstaged_add.txt'},
|
|
{'status': 'D', 'path': 'unstaged_delete.txt'},
|
|
{'status': 'M', 'path': 'unstaged_modified.txt'},
|
|
]
|
|
|
|
assert changes == expected_changes
|
|
|
|
def test_get_git_diff_fallback(self):
|
|
"""Test that get_git_diff delegates to the git_diff module."""
|
|
# Break the git diff command
|
|
with patch(
|
|
'openhands.runtime.utils.git_handler.GIT_DIFF_CMD', 'non-existant-command'
|
|
):
|
|
self.git_handler.git_diff_cmd = git_handler.GIT_DIFF_CMD
|
|
|
|
diff = self.git_handler.get_git_diff('unchanged.txt')
|
|
expected_diff = {
|
|
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
|
|
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
|
|
}
|
|
assert diff == expected_diff
|