Skip to content

Commit 81fe3fc

Browse files
authored
feat(adapters): detect modified files via git diff after subprocess execution (#411)
## Summary - Added _detect_modified_files() to SubprocessAdapter — runs git diff + git ls-files after execution - Populates AgentResult.modified_files for all external engines (Claude Code, OpenCode) - Graceful fallback when git unavailable or not a git repo - Deduplication guard for overlapping results ## Validation - Review feedback: All addressed (1 round — fixed test mocks, deduplication, test rename) - Demo: All 9 acceptance criteria verified - Tests: 2147 passed, 0 failed (104 adapter tests) - CI: All checks green - Linting: Clean Closes #411
1 parent b2cef58 commit 81fe3fc

4 files changed

Lines changed: 174 additions & 1 deletion

File tree

codeframe/core/adapters/subprocess_adapter.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,16 @@ def _drain_stderr() -> None:
153153

154154
stderr_output = "".join(stderr_chunks)
155155

156-
return self._map_result(
156+
modified_files = self._detect_modified_files(workspace_path)
157+
158+
result = self._map_result(
157159
exit_code=process.returncode,
158160
stdout="\n".join(stdout_lines),
159161
stderr=stderr_output,
160162
workspace_path=workspace_path,
161163
)
164+
result.modified_files = modified_files
165+
return result
162166

163167
def _map_result(
164168
self,
@@ -196,6 +200,44 @@ def _map_result(
196200
error=stderr or f"Process exited with code {exit_code}",
197201
)
198202

203+
def _detect_modified_files(self, workspace_path: Path) -> list[str]:
204+
"""Detect files modified by the subprocess via git diff.
205+
206+
Combines modified, staged, and untracked files. Returns an empty list
207+
if git is unavailable or the workspace is not a git repo.
208+
"""
209+
try:
210+
result = subprocess.run(
211+
["git", "diff", "--name-only", "HEAD"],
212+
cwd=str(workspace_path),
213+
capture_output=True,
214+
text=True,
215+
timeout=10,
216+
)
217+
if result.returncode != 0:
218+
# Also covers repos with no commits (HEAD does not exist)
219+
return []
220+
221+
files = [f for f in result.stdout.strip().splitlines() if f]
222+
223+
# Also pick up untracked files
224+
untracked = subprocess.run(
225+
["git", "ls-files", "--others", "--exclude-standard"],
226+
cwd=str(workspace_path),
227+
capture_output=True,
228+
text=True,
229+
timeout=10,
230+
)
231+
if untracked.returncode == 0:
232+
files.extend(
233+
f for f in untracked.stdout.strip().splitlines() if f
234+
)
235+
236+
# Deduplicate while preserving order
237+
return list(dict.fromkeys(files))
238+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
239+
return []
240+
199241
def _extract_blocker_question(self, output: str) -> str:
200242
"""Extract a meaningful blocker question from output."""
201243
lines = [line.strip() for line in output.splitlines() if line.strip()]

tests/core/adapters/test_claude_code.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
class TestClaudeCodeAdapter:
1313
"""Unit tests for ClaudeCodeAdapter."""
1414

15+
@pytest.fixture(autouse=True)
16+
def _no_git(self):
17+
"""Prevent _detect_modified_files from calling real git."""
18+
with patch.object(ClaudeCodeAdapter, "_detect_modified_files", return_value=[]):
19+
yield
20+
1521
def test_name(self) -> None:
1622
with patch("shutil.which", return_value="/usr/bin/claude"):
1723
adapter = ClaudeCodeAdapter()

tests/core/adapters/test_opencode.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
class TestOpenCodeAdapter:
1313
"""Unit tests for OpenCodeAdapter."""
1414

15+
@pytest.fixture(autouse=True)
16+
def _no_git(self):
17+
"""Prevent _detect_modified_files from calling real git."""
18+
with patch.object(OpenCodeAdapter, "_detect_modified_files", return_value=[]):
19+
yield
20+
1521
def test_name(self) -> None:
1622
with patch("shutil.which", return_value="/usr/bin/opencode"):
1723
adapter = OpenCodeAdapter()

tests/core/adapters/test_subprocess_adapter.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ def test_init_stores_resolved_path(self):
4242
class TestSubprocessAdapterRun:
4343
"""Tests for subprocess execution."""
4444

45+
@pytest.fixture(autouse=True)
46+
def _no_git(self):
47+
"""Prevent _detect_modified_files from calling real git."""
48+
with patch.object(SubprocessAdapter, "_detect_modified_files", return_value=[]):
49+
yield
50+
4551
@pytest.fixture
4652
def adapter(self):
4753
with patch("shutil.which", return_value="/usr/bin/test-agent"):
@@ -222,6 +228,119 @@ def test_default_returns_empty_prompt(self):
222228
assert adapter.get_stdin("") == ""
223229

224230

231+
class TestSubprocessAdapterModifiedFiles:
232+
"""Tests for git diff file detection after execution."""
233+
234+
@pytest.fixture
235+
def adapter(self):
236+
with patch("shutil.which", return_value="/usr/bin/test-agent"):
237+
return SubprocessAdapter("test-agent")
238+
239+
def _make_mock_process(
240+
self, stdout_lines=None, stderr_text="", returncode=0
241+
):
242+
mock = MagicMock()
243+
mock.stdout = iter(stdout_lines or [])
244+
mock.stderr = MagicMock()
245+
mock.stderr.read.return_value = stderr_text
246+
mock.stdin = MagicMock()
247+
mock.returncode = returncode
248+
mock.wait.return_value = None
249+
return mock
250+
251+
def test_populates_modified_files_on_success(self, adapter, tmp_path):
252+
"""After successful execution, modified_files should list changed files."""
253+
mock_process = self._make_mock_process(
254+
stdout_lines=["done\n"], returncode=0
255+
)
256+
with (
257+
patch("subprocess.Popen", return_value=mock_process),
258+
patch(
259+
"subprocess.run",
260+
side_effect=[
261+
MagicMock(returncode=0, stdout="src/main.py\ntests/test_main.py\n"),
262+
MagicMock(returncode=0, stdout=""), # no untracked files
263+
],
264+
),
265+
):
266+
result = adapter.run("task-1", "fix", tmp_path)
267+
268+
assert result.status == "completed"
269+
assert result.modified_files == ["src/main.py", "tests/test_main.py"]
270+
271+
def test_empty_modified_files_when_no_changes(self, adapter, tmp_path):
272+
mock_process = self._make_mock_process(
273+
stdout_lines=["done\n"], returncode=0
274+
)
275+
with (
276+
patch("subprocess.Popen", return_value=mock_process),
277+
patch(
278+
"subprocess.run",
279+
side_effect=[
280+
MagicMock(returncode=0, stdout=""),
281+
MagicMock(returncode=0, stdout=""),
282+
],
283+
),
284+
):
285+
result = adapter.run("task-1", "fix", tmp_path)
286+
287+
assert result.modified_files == []
288+
289+
def test_detects_files_even_on_failure(self, adapter, tmp_path):
290+
"""Failed execution should still detect modified files."""
291+
mock_process = self._make_mock_process(
292+
stderr_text="error", returncode=1
293+
)
294+
with (
295+
patch("subprocess.Popen", return_value=mock_process),
296+
patch(
297+
"subprocess.run",
298+
side_effect=[
299+
MagicMock(returncode=0, stdout="src/broken.py\n"),
300+
MagicMock(returncode=0, stdout=""),
301+
],
302+
),
303+
):
304+
result = adapter.run("task-1", "fix", tmp_path)
305+
306+
assert result.status == "failed"
307+
assert "src/broken.py" in result.modified_files
308+
309+
def test_graceful_when_not_git_repo(self, adapter, tmp_path):
310+
"""Should return empty modified_files if git is unavailable."""
311+
mock_process = self._make_mock_process(
312+
stdout_lines=["done\n"], returncode=0
313+
)
314+
with (
315+
patch("subprocess.Popen", return_value=mock_process),
316+
patch(
317+
"subprocess.run",
318+
side_effect=FileNotFoundError("git not found"),
319+
),
320+
):
321+
result = adapter.run("task-1", "fix", tmp_path)
322+
323+
assert result.status == "completed"
324+
assert result.modified_files == []
325+
326+
def test_graceful_when_git_fails(self, adapter, tmp_path):
327+
"""Should return empty modified_files if git diff fails."""
328+
mock_process = self._make_mock_process(
329+
stdout_lines=["done\n"], returncode=0
330+
)
331+
with (
332+
patch("subprocess.Popen", return_value=mock_process),
333+
patch(
334+
"subprocess.run",
335+
return_value=MagicMock(returncode=128, stdout=""),
336+
),
337+
):
338+
result = adapter.run("task-1", "fix", tmp_path)
339+
340+
assert result.status == "completed"
341+
assert result.modified_files == []
342+
343+
225344
class TestSubprocessAdapterBlockerExtraction:
226345
"""Tests for blocker question extraction."""
227346

0 commit comments

Comments
 (0)