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