Skip to content

Commit 8ba1fe0

Browse files
committed
fix: use query parameters for git API endpoints to preserve path slashes (OpenHands#2249)
Cherry-pick from upstream 97ab053
1 parent 58d2afc commit 8ba1fe0

File tree

2 files changed

+186
-52
lines changed

2 files changed

+186
-52
lines changed

openhands-agent-server/openhands/agent_server/git_router.py

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,74 @@
44
import logging
55
from pathlib import Path
66

7-
from fastapi import APIRouter
7+
from fastapi import APIRouter, Query
88

99
from openhands.agent_server.server_details_router import update_last_execution_time
1010
from openhands.sdk.git.git_changes import get_git_changes
1111
from openhands.sdk.git.git_diff import get_git_diff
1212
from openhands.sdk.git.models import GitChange, GitDiff
13+
from openhands.sdk.utils.deprecation import deprecated
1314

1415

1516
git_router = APIRouter(prefix="/git", tags=["Git"])
1617
logger = logging.getLogger(__name__)
1718

1819

19-
@git_router.get("/changes/{path:path}")
20-
async def git_changes(
21-
path: Path,
22-
) -> list[GitChange]:
20+
async def _get_git_changes(path: str) -> list[GitChange]:
21+
"""Internal helper to get git changes for a given path."""
2322
update_last_execution_time()
2423
loop = asyncio.get_running_loop()
25-
changes = await loop.run_in_executor(None, get_git_changes, path)
26-
return changes
24+
return await loop.run_in_executor(None, get_git_changes, Path(path))
2725

2826

29-
@git_router.get("/diff/{path:path}")
30-
async def git_diff(
31-
path: Path,
32-
) -> GitDiff:
27+
async def _get_git_diff(path: str) -> GitDiff:
28+
"""Internal helper to get git diff for a given path."""
3329
update_last_execution_time()
3430
loop = asyncio.get_running_loop()
35-
changes = await loop.run_in_executor(None, get_git_diff, path)
36-
return changes
31+
return await loop.run_in_executor(None, get_git_diff, Path(path))
32+
33+
34+
@git_router.get("/changes")
35+
async def git_changes_query(
36+
path: str = Query(..., description="The git repository path"),
37+
) -> list[GitChange]:
38+
"""Get git changes using query parameter (preferred method)."""
39+
return await _get_git_changes(path)
40+
41+
42+
@git_router.get("/changes/{path:path}")
43+
@deprecated(
44+
deprecated_in="1.15.0",
45+
removed_in="1.20.0",
46+
details=(
47+
"Use the /git/changes endpoint with a query parameter for the path "
48+
"instead of a path parameter. This allows for better handling of "
49+
"complex paths and is more consistent with other endpoints."
50+
),
51+
)
52+
async def git_changes_path(path: str) -> list[GitChange]:
53+
"""Get git changes using path parameter (legacy, for backwards compatibility)."""
54+
return await _get_git_changes(path)
55+
56+
57+
@git_router.get("/diff")
58+
async def git_diff_query(
59+
path: str = Query(..., description="The file path to get diff for"),
60+
) -> GitDiff:
61+
"""Get git diff using query parameter (preferred method)."""
62+
return await _get_git_diff(path)
63+
64+
65+
@git_router.get("/diff/{path:path}")
66+
@deprecated(
67+
deprecated_in="1.15.0",
68+
removed_in="1.20.0",
69+
details=(
70+
"Use the /git/diff endpoint with a query parameter for the path "
71+
"instead of a path parameter. This allows for better handling of "
72+
"complex paths and is more consistent with other endpoints."
73+
),
74+
)
75+
async def git_diff_path(path: str) -> GitDiff:
76+
"""Get git diff using path parameter (legacy, for backwards compatibility)."""
77+
return await _get_git_diff(path)

tests/agent_server/test_git_router.py

Lines changed: 132 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ def client():
1818
return TestClient(create_app(config), raise_server_exceptions=False)
1919

2020

21+
# =============================================================================
22+
# Query Parameter Tests (Preferred Method)
23+
# =============================================================================
24+
25+
2126
@pytest.mark.asyncio
22-
async def test_git_changes_success(client):
23-
"""Test successful git changes endpoint."""
24-
# Mock the get_git_changes function
27+
async def test_git_changes_query_param_success(client):
28+
"""Test successful git changes endpoint with query parameter."""
2529
expected_changes = [
2630
GitChange(status=GitChangeStatus.ADDED, path=Path("new_file.py")),
2731
GitChange(status=GitChangeStatus.UPDATED, path=Path("existing_file.py")),
@@ -32,12 +36,11 @@ async def test_git_changes_success(client):
3236
mock_git_changes.return_value = expected_changes
3337

3438
test_path = "src/test_repo"
35-
response = client.get(f"/api/git/changes/{test_path}")
39+
response = client.get("/api/git/changes", params={"path": test_path})
3640

3741
assert response.status_code == 200
3842
response_data = response.json()
3943

40-
# Verify the response structure
4144
assert len(response_data) == 3
4245
assert response_data[0]["status"] == "ADDED"
4346
assert response_data[0]["path"] == "new_file.py"
@@ -46,40 +49,64 @@ async def test_git_changes_success(client):
4649
assert response_data[2]["status"] == "DELETED"
4750
assert response_data[2]["path"] == "old_file.py"
4851

49-
# Verify the mock was called correctly
5052
mock_git_changes.assert_called_once_with(Path(test_path))
5153

5254

5355
@pytest.mark.asyncio
54-
async def test_git_changes_empty_result(client):
55-
"""Test git changes endpoint with no changes."""
56+
async def test_git_changes_query_param_empty_result(client):
57+
"""Test git changes endpoint with query parameter and no changes."""
5658
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
5759
mock_git_changes.return_value = []
5860

5961
test_path = "src/empty_repo"
60-
response = client.get(f"/api/git/changes/{test_path}")
62+
response = client.get("/api/git/changes", params={"path": test_path})
6163

6264
assert response.status_code == 200
6365
assert response.json() == []
6466

6567

6668
@pytest.mark.asyncio
67-
async def test_git_changes_with_exception(client):
68-
"""Test git changes endpoint when git operation fails."""
69+
async def test_git_changes_query_param_with_exception(client):
70+
"""Test git changes endpoint with query parameter when git operation fails."""
6971
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
7072
mock_git_changes.side_effect = Exception("Git repository not found")
7173

7274
test_path = "nonexistent/repo"
73-
response = client.get(f"/api/git/changes/{test_path}")
75+
response = client.get("/api/git/changes", params={"path": test_path})
7476

75-
# Should return 500 due to exception
7677
assert response.status_code == 500
7778

7879

7980
@pytest.mark.asyncio
80-
async def test_git_diff_success(client):
81-
"""Test successful git diff endpoint."""
82-
# Mock the get_git_diff function
81+
async def test_git_changes_missing_path_param(client):
82+
"""Test git changes endpoint returns 422 when path parameter is missing."""
83+
response = client.get("/api/git/changes")
84+
85+
assert response.status_code == 422
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_git_changes_query_param_absolute_path(client):
90+
"""Test git changes with query parameter and absolute path (main fix use case)."""
91+
expected_changes = [
92+
GitChange(status=GitChangeStatus.ADDED, path=Path("new_file.py")),
93+
]
94+
95+
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
96+
mock_git_changes.return_value = expected_changes
97+
98+
# This is the main use case - absolute paths with leading slash
99+
test_path = "/workspace/project"
100+
response = client.get("/api/git/changes", params={"path": test_path})
101+
102+
assert response.status_code == 200
103+
assert len(response.json()) == 1
104+
mock_git_changes.assert_called_once_with(Path(test_path))
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_git_diff_query_param_success(client):
109+
"""Test successful git diff endpoint with query parameter."""
83110
expected_diff = GitDiff(
84111
modified="def new_function():\n return 'updated'",
85112
original="def old_function():\n return 'original'",
@@ -89,74 +116,141 @@ async def test_git_diff_success(client):
89116
mock_git_diff.return_value = expected_diff
90117

91118
test_path = "src/test_file.py"
92-
response = client.get(f"/api/git/diff/{test_path}")
119+
response = client.get("/api/git/diff", params={"path": test_path})
93120

94121
assert response.status_code == 200
95122
response_data = response.json()
96123

97-
# Verify the response structure
98124
assert response_data["modified"] == expected_diff.modified
99125
assert response_data["original"] == expected_diff.original
100-
101-
# Verify the mock was called correctly (now expects Path object)
102126
mock_git_diff.assert_called_once_with(Path(test_path))
103127

104128

105129
@pytest.mark.asyncio
106-
async def test_git_diff_with_none_values(client):
107-
"""Test git diff endpoint with None values."""
108-
# Mock the get_git_diff function with None values
130+
async def test_git_diff_query_param_with_none_values(client):
131+
"""Test git diff endpoint with query parameter and None values."""
109132
expected_diff = GitDiff(modified=None, original=None)
110133

111134
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
112135
mock_git_diff.return_value = expected_diff
113136

114137
test_path = "nonexistent_file.py"
115-
response = client.get(f"/api/git/diff/{test_path}")
138+
response = client.get("/api/git/diff", params={"path": test_path})
116139

117140
assert response.status_code == 200
118141
response_data = response.json()
119142

120-
# Verify the response structure
121143
assert response_data["modified"] is None
122144
assert response_data["original"] is None
123145

124146

125147
@pytest.mark.asyncio
126-
async def test_git_diff_with_exception(client):
127-
"""Test git diff endpoint when git operation fails."""
148+
async def test_git_diff_query_param_with_exception(client):
149+
"""Test git diff endpoint with query parameter when git operation fails."""
128150
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
129151
mock_git_diff.side_effect = Exception("Git diff failed")
130152

131153
test_path = "nonexistent/file.py"
132-
response = client.get(f"/api/git/diff/{test_path}")
154+
response = client.get("/api/git/diff", params={"path": test_path})
133155

134-
# Should return 500 due to exception
135156
assert response.status_code == 500
136157

137158

138159
@pytest.mark.asyncio
139-
async def test_git_diff_nested_path(client):
140-
"""Test git diff endpoint with nested file path."""
141-
expected_diff = GitDiff(modified="updated content", original="original content")
160+
async def test_git_diff_missing_path_param(client):
161+
"""Test git diff endpoint returns 422 when path parameter is missing."""
162+
response = client.get("/api/git/diff")
163+
164+
assert response.status_code == 422
165+
166+
167+
# =============================================================================
168+
# Path Parameter Tests (Legacy/Backwards Compatibility)
169+
# =============================================================================
170+
171+
172+
@pytest.mark.asyncio
173+
async def test_git_changes_path_param_success(client):
174+
"""Test git changes endpoint with path parameter (legacy)."""
175+
expected_changes = [
176+
GitChange(status=GitChangeStatus.ADDED, path=Path("new_file.py")),
177+
GitChange(status=GitChangeStatus.UPDATED, path=Path("existing_file.py")),
178+
]
179+
180+
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
181+
mock_git_changes.return_value = expected_changes
182+
183+
test_path = "src/test_repo"
184+
response = client.get(f"/api/git/changes/{test_path}")
185+
186+
assert response.status_code == 200
187+
response_data = response.json()
188+
189+
assert len(response_data) == 2
190+
assert response_data[0]["status"] == "ADDED"
191+
assert response_data[1]["status"] == "UPDATED"
192+
mock_git_changes.assert_called_once_with(Path(test_path))
193+
194+
195+
@pytest.mark.asyncio
196+
async def test_git_changes_path_param_nested(client):
197+
"""Test git changes endpoint with nested path parameter."""
198+
expected_changes = [
199+
GitChange(status=GitChangeStatus.ADDED, path=Path("file.py")),
200+
]
201+
202+
with patch("openhands.agent_server.git_router.get_git_changes") as mock_git_changes:
203+
mock_git_changes.return_value = expected_changes
204+
205+
test_path = "src/deep/nested/repo"
206+
response = client.get(f"/api/git/changes/{test_path}")
207+
208+
assert response.status_code == 200
209+
mock_git_changes.assert_called_once_with(Path(test_path))
210+
211+
212+
@pytest.mark.asyncio
213+
async def test_git_diff_path_param_success(client):
214+
"""Test git diff endpoint with path parameter (legacy)."""
215+
expected_diff = GitDiff(modified="new content", original="old content")
142216

143217
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
144218
mock_git_diff.return_value = expected_diff
145219

146-
# Test with nested path
147-
test_path = "src/utils/helper.py"
220+
test_path = "src/test_file.py"
148221
response = client.get(f"/api/git/diff/{test_path}")
149222

150223
assert response.status_code == 200
224+
response_data = response.json()
151225

152-
# Verify the correct path was passed (now expects Path object)
226+
assert response_data["modified"] == "new content"
227+
assert response_data["original"] == "old content"
153228
mock_git_diff.assert_called_once_with(Path(test_path))
154229

155230

231+
@pytest.mark.asyncio
232+
async def test_git_diff_path_param_nested(client):
233+
"""Test git diff endpoint with nested path parameter."""
234+
expected_diff = GitDiff(modified="updated", original="original")
235+
236+
with patch("openhands.agent_server.git_router.get_git_diff") as mock_git_diff:
237+
mock_git_diff.return_value = expected_diff
238+
239+
test_path = "src/utils/helper.py"
240+
response = client.get(f"/api/git/diff/{test_path}")
241+
242+
assert response.status_code == 200
243+
mock_git_diff.assert_called_once_with(Path(test_path))
244+
245+
246+
# =============================================================================
247+
# Additional Edge Case Tests
248+
# =============================================================================
249+
250+
156251
@pytest.mark.asyncio
157252
async def test_git_changes_with_all_status_types(client):
158253
"""Test git changes endpoint with all possible GitChangeStatus values."""
159-
# Test all possible status types
160254
expected_changes = [
161255
GitChange(status=GitChangeStatus.ADDED, path=Path("added.py")),
162256
GitChange(status=GitChangeStatus.UPDATED, path=Path("updated.py")),
@@ -168,7 +262,7 @@ async def test_git_changes_with_all_status_types(client):
168262
mock_git_changes.return_value = expected_changes
169263

170264
test_path = "src/test_repo"
171-
response = client.get(f"/api/git/changes/{test_path}")
265+
response = client.get("/api/git/changes", params={"path": test_path})
172266

173267
assert response.status_code == 200
174268
response_data = response.json()
@@ -183,7 +277,6 @@ async def test_git_changes_with_all_status_types(client):
183277
@pytest.mark.asyncio
184278
async def test_git_changes_with_complex_paths(client):
185279
"""Test git changes endpoint with complex file paths."""
186-
# Test with various path complexities
187280
expected_changes = [
188281
GitChange(
189282
status=GitChangeStatus.ADDED,
@@ -203,7 +296,7 @@ async def test_git_changes_with_complex_paths(client):
203296
mock_git_changes.return_value = expected_changes
204297

205298
test_path = "src/complex_repo"
206-
response = client.get(f"/api/git/changes/{test_path}")
299+
response = client.get("/api/git/changes", params={"path": test_path})
207300

208301
assert response.status_code == 200
209302
response_data = response.json()

0 commit comments

Comments
 (0)