Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion codeframe/core/adapters/subprocess_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,16 @@ def _drain_stderr() -> None:

stderr_output = "".join(stderr_chunks)

return self._map_result(
modified_files = self._detect_modified_files(workspace_path)

result = self._map_result(
exit_code=process.returncode,
stdout="\n".join(stdout_lines),
stderr=stderr_output,
workspace_path=workspace_path,
)
result.modified_files = modified_files
return result

def _map_result(
self,
Expand Down Expand Up @@ -196,6 +200,44 @@ def _map_result(
error=stderr or f"Process exited with code {exit_code}",
)

def _detect_modified_files(self, workspace_path: Path) -> list[str]:
"""Detect files modified by the subprocess via git diff.

Combines modified, staged, and untracked files. Returns an empty list
if git is unavailable or the workspace is not a git repo.
"""
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"],
cwd=str(workspace_path),
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
# Also covers repos with no commits (HEAD does not exist)
return []

files = [f for f in result.stdout.strip().splitlines() if f]

# Also pick up untracked files
untracked = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
cwd=str(workspace_path),
capture_output=True,
text=True,
timeout=10,
)
if untracked.returncode == 0:
files.extend(
f for f in untracked.stdout.strip().splitlines() if f
)

# Deduplicate while preserving order
return list(dict.fromkeys(files))
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return []

def _extract_blocker_question(self, output: str) -> str:
"""Extract a meaningful blocker question from output."""
lines = [line.strip() for line in output.splitlines() if line.strip()]
Expand Down
6 changes: 6 additions & 0 deletions tests/core/adapters/test_claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
class TestClaudeCodeAdapter:
"""Unit tests for ClaudeCodeAdapter."""

@pytest.fixture(autouse=True)
def _no_git(self):
"""Prevent _detect_modified_files from calling real git."""
with patch.object(ClaudeCodeAdapter, "_detect_modified_files", return_value=[]):
yield

Comment on lines +15 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a v2 marker for this test module.

The v2 adapter test updates introduced around Line 15 are in a module without @pytest.mark.v2 (or module-level pytestmark).

Suggested patch
 import pytest
 
 from codeframe.core.adapters.agent_adapter import AgentAdapter
 from codeframe.core.adapters.claude_code import ClaudeCodeAdapter
 
+pytestmark = pytest.mark.v2
+
 
 class TestClaudeCodeAdapter:

As per coding guidelines: tests/**/*.py: Test files must use the @pytest.mark.v2 decorator or module-level pytestmark = pytest.mark.v2 for v2 functionality tests.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.fixture(autouse=True)
def _no_git(self):
"""Prevent _detect_modified_files from calling real git."""
with patch.object(ClaudeCodeAdapter, "_detect_modified_files", return_value=[]):
yield
import pytest
from codeframe.core.adapters.agent_adapter import AgentAdapter
from codeframe.core.adapters.claude_code import ClaudeCodeAdapter
pytestmark = pytest.mark.v2
class TestClaudeCodeAdapter:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/adapters/test_claude_code.py` around lines 15 - 20, This module's
v2 adapter tests lack the required v2 marker; add a module-level marker by
defining pytestmark = pytest.mark.v2 at the top of the test module (near the
existing _no_git fixture and tests that exercise ClaudeCodeAdapter) so the
entire file is marked v2; alternatively, add `@pytest.mark.v2` to each test
function, but prefer the single module-level pytestmark to ensure all tests
(including fixtures like _no_git) run under v2 semantics.

def test_name(self) -> None:
with patch("shutil.which", return_value="/usr/bin/claude"):
adapter = ClaudeCodeAdapter()
Expand Down
6 changes: 6 additions & 0 deletions tests/core/adapters/test_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
class TestOpenCodeAdapter:
"""Unit tests for OpenCodeAdapter."""

@pytest.fixture(autouse=True)
def _no_git(self):
"""Prevent _detect_modified_files from calling real git."""
with patch.object(OpenCodeAdapter, "_detect_modified_files", return_value=[]):
yield

Comment on lines +15 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a v2 marker for this test module.

The new v2-related adapter test behavior added around Line 15 is in a test module that is not marked with @pytest.mark.v2 (or module-level pytestmark).

Suggested patch
 import pytest
 
 from codeframe.core.adapters.agent_adapter import AgentAdapter
 from codeframe.core.adapters.opencode import OpenCodeAdapter
 
+pytestmark = pytest.mark.v2
+
 
 class TestOpenCodeAdapter:

As per coding guidelines: tests/**/*.py: New v2 Python tests must be marked with @pytest.mark.v2 decorator or pytestmark = pytest.mark.v2.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/adapters/test_opencode.py` around lines 15 - 20, This test module
adds v2-specific behavior but lacks the v2 marker; add a module-level pytest
mark by inserting either a module decorator `@pytest.mark.v2` above the tests or a
top-level assignment pytestmark = pytest.mark.v2 so the fixture _no_git and all
tests in the module (e.g., the _no_git autouse fixture) are executed under the
v2 test configuration; ensure you import pytest if not already present.

def test_name(self) -> None:
with patch("shutil.which", return_value="/usr/bin/opencode"):
adapter = OpenCodeAdapter()
Expand Down
119 changes: 119 additions & 0 deletions tests/core/adapters/test_subprocess_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ def test_init_stores_resolved_path(self):
class TestSubprocessAdapterRun:
"""Tests for subprocess execution."""

@pytest.fixture(autouse=True)
def _no_git(self):
"""Prevent _detect_modified_files from calling real git."""
with patch.object(SubprocessAdapter, "_detect_modified_files", return_value=[]):
yield

@pytest.fixture
def adapter(self):
with patch("shutil.which", return_value="/usr/bin/test-agent"):
Expand Down Expand Up @@ -222,6 +228,119 @@ def test_default_returns_empty_prompt(self):
assert adapter.get_stdin("") == ""


class TestSubprocessAdapterModifiedFiles:
"""Tests for git diff file detection after execution."""

Comment on lines +231 to +233

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mark the newly added v2 tests with @pytest.mark.v2.

The new test class introduced at Line 231 is not marked as v2.

Suggested patch
+@pytest.mark.v2
 class TestSubprocessAdapterModifiedFiles:
     """Tests for git diff file detection after execution."""

As per coding guidelines: tests/**/*.py: New v2 Python tests must be marked with @pytest.mark.v2 decorator or pytestmark = pytest.mark.v2.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/adapters/test_subprocess_adapter.py` around lines 231 - 233, The
new test class TestSubprocessAdapterModifiedFiles is missing the v2 marker;
update the test to be marked with pytest v2 by adding either the decorator
`@pytest.mark.v2` above the class definition or by setting pytestmark =
pytest.mark.v2 at module scope so the class is recognized as a v2 test (ensure
pytest is imported as needed).

@pytest.fixture
def adapter(self):
with patch("shutil.which", return_value="/usr/bin/test-agent"):
return SubprocessAdapter("test-agent")

def _make_mock_process(
self, stdout_lines=None, stderr_text="", returncode=0
):
mock = MagicMock()
mock.stdout = iter(stdout_lines or [])
mock.stderr = MagicMock()
mock.stderr.read.return_value = stderr_text
mock.stdin = MagicMock()
mock.returncode = returncode
mock.wait.return_value = None
return mock

def test_populates_modified_files_on_success(self, adapter, tmp_path):
"""After successful execution, modified_files should list changed files."""
mock_process = self._make_mock_process(
stdout_lines=["done\n"], returncode=0
)
with (
patch("subprocess.Popen", return_value=mock_process),
patch(
"subprocess.run",
side_effect=[
MagicMock(returncode=0, stdout="src/main.py\ntests/test_main.py\n"),
MagicMock(returncode=0, stdout=""), # no untracked files
],
),
):
result = adapter.run("task-1", "fix", tmp_path)

assert result.status == "completed"
assert result.modified_files == ["src/main.py", "tests/test_main.py"]

def test_empty_modified_files_when_no_changes(self, adapter, tmp_path):
mock_process = self._make_mock_process(
stdout_lines=["done\n"], returncode=0
)
with (
patch("subprocess.Popen", return_value=mock_process),
patch(
"subprocess.run",
side_effect=[
MagicMock(returncode=0, stdout=""),
MagicMock(returncode=0, stdout=""),
],
),
):
result = adapter.run("task-1", "fix", tmp_path)

assert result.modified_files == []

def test_detects_files_even_on_failure(self, adapter, tmp_path):
"""Failed execution should still detect modified files."""
mock_process = self._make_mock_process(
stderr_text="error", returncode=1
)
with (
patch("subprocess.Popen", return_value=mock_process),
patch(
"subprocess.run",
side_effect=[
MagicMock(returncode=0, stdout="src/broken.py\n"),
MagicMock(returncode=0, stdout=""),
],
),
):
result = adapter.run("task-1", "fix", tmp_path)

assert result.status == "failed"
assert "src/broken.py" in result.modified_files

def test_graceful_when_not_git_repo(self, adapter, tmp_path):
"""Should return empty modified_files if git is unavailable."""
mock_process = self._make_mock_process(
stdout_lines=["done\n"], returncode=0
)
with (
patch("subprocess.Popen", return_value=mock_process),
patch(
"subprocess.run",
side_effect=FileNotFoundError("git not found"),
),
):
result = adapter.run("task-1", "fix", tmp_path)

assert result.status == "completed"
assert result.modified_files == []

def test_graceful_when_git_fails(self, adapter, tmp_path):
"""Should return empty modified_files if git diff fails."""
mock_process = self._make_mock_process(
stdout_lines=["done\n"], returncode=0
)
with (
patch("subprocess.Popen", return_value=mock_process),
patch(
"subprocess.run",
return_value=MagicMock(returncode=128, stdout=""),
),
):
result = adapter.run("task-1", "fix", tmp_path)

assert result.status == "completed"
assert result.modified_files == []


class TestSubprocessAdapterBlockerExtraction:
"""Tests for blocker question extraction."""

Expand Down
Loading