feat(plan): implement plan diff using git worktree branch #10002

Merged
hamza.khyari merged 1 commits from feature/plan-diff-worktree into master 2026-04-21 11:51:59 +00:00
6 changed files with 366 additions and 3 deletions
+9
View File
@@ -5,6 +5,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Plan diff shows worktree branch changes** (#9231): `plan diff` now detects
the worktree branch `cleveragents/plan-<id>` created during `plan execute`
and runs `git diff HEAD...<branch>` to display actual file changes. Falls
back to changeset-based diff when no worktree branch exists. Git operations
delegated to `GitWorktreeSandbox.diff_against_head()` in the Infrastructure
layer.
### Fixed
- **Worktree sandbox cleanup on plan cancel** (#9230): `plan cancel` now removes
+32
View File
@@ -0,0 +1,32 @@
@plan-diff-worktree
Feature: Plan diff shows worktree branch changes (#9231)
Verifies that plan diff displays the actual file changes from the
worktree branch created during plan execute, falling back to
changeset-based diff when no worktree branch exists.
Background:
Given the plan-diff in-memory database is initialized
Scenario: diff_against_head returns diff when worktree branch exists
Given a temp git repo with a worktree branch for plan "01TESTDIFF00000000000000" for pdt
And a file "hello.py" is changed on the worktree branch for pdt
When I call diff_against_head for plan "01TESTDIFF00000000000000" for pdt
Then the diff output should contain "hello.py" for pdt
And the diff output should not be None for pdt
Scenario: diff_against_head returns None when no branch exists
Given a temp git repo without a worktree branch for pdt
When I call diff_against_head for plan "01TESTDIFFNO000000000000" for pdt
Then the diff output should be None for pdt
Scenario: _get_worktree_diff returns diff via service resolution
Given a temp git repo with a worktree branch for plan "01TESTDIFFSVC00000000000" for pdt
And a file "app.py" is changed on the worktree branch for pdt
And a mocked service that resolves the git resource for pdt
When I call _get_worktree_diff for plan "01TESTDIFFSVC00000000000" for pdt
Then the diff output should contain "app.py" for pdt
Scenario: _get_worktree_diff returns None when no linked resources
Given a mocked service with no linked resources for plan diff for pdt
When I call _get_worktree_diff for plan "01TESTDIFFNONE0000000000" for pdt
Then the diff output should be None for pdt
+21 -2
View File
@@ -83,9 +83,28 @@ def step_setup_db(context: Context) -> None:
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
# Use a shared session so flush() in one repo call is visible
# to subsequent repo calls within the same scenario.
# Wrap with rollback-on-close to prevent close() from destroying
# the shared session (repos and helper code call session.close()).
_real = sessionmaker(bind=engine, expire_on_commit=False)()
class _SharedSession:
"""Proxy that turns close() into rollback() on the shared session."""
def close(self) -> None:
_real.rollback()
def __getattr__(self, name: str) -> object:
return getattr(_real, name)
_wrapper = _SharedSession()
def _shared_factory() -> Session:
return _wrapper # type: ignore[return-value]
context.drcov3_engine = engine
context.drcov3_factory = factory
context.drcov3_factory = _shared_factory
context.drcov3_error = None
context.drcov3_result = None
+191
View File
@@ -0,0 +1,191 @@
"""Steps for plan_diff_worktree.feature."""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
check=True,
timeout=10,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the plan-diff in-memory database is initialized")
def step_pdt_init(context: Context) -> None:
context.pdt_diff_output: str | None = None # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# Given — repo fixtures
# ---------------------------------------------------------------------------
@given('a temp git repo with a worktree branch for plan "{plan_id}" for pdt')
def step_create_repo_with_branch(context: Context, plan_id: str) -> None:
d = tempfile.mkdtemp(prefix="pdt-")
context.add_cleanup(shutil.rmtree, d, True) # type: ignore[attr-defined]
_git(["init", "-q", "-b", "main"], d)
_git(["config", "user.name", "T"], d)
_git(["config", "user.email", "t@t"], d)
_git(["config", "commit.gpgsign", "false"], d)
Path(d, "README.md").write_text("initial\n")
_git(["add", "."], d)
_git(["commit", "-q", "-m", "init"], d)
branch = f"cleveragents/plan-{plan_id}"
wt_dir = tempfile.mkdtemp(prefix="pdt-wt-")
context.add_cleanup(shutil.rmtree, wt_dir, True) # type: ignore[attr-defined]
_git(["worktree", "add", "-b", branch, wt_dir, "HEAD"], d)
context.pdt_repo = d # type: ignore[attr-defined]
context.pdt_wt_dir = wt_dir # type: ignore[attr-defined]
context.pdt_plan_id = plan_id # type: ignore[attr-defined]
context.pdt_branch = branch # type: ignore[attr-defined]
@given('a file "{filename}" is changed on the worktree branch for pdt')
def step_change_file_on_branch(context: Context, filename: str) -> None:
wt_dir: str = context.pdt_wt_dir # type: ignore[attr-defined]
Path(wt_dir, filename).write_text("new content\n")
_git(["add", "."], wt_dir)
_git(["commit", "-q", "-m", f"add {filename}"], wt_dir)
@given("a temp git repo without a worktree branch for pdt")
def step_create_clean_repo(context: Context) -> None:
d = tempfile.mkdtemp(prefix="pdt-clean-")
context.add_cleanup(shutil.rmtree, d, True) # type: ignore[attr-defined]
_git(["init", "-q", "-b", "main"], d)
_git(["config", "user.name", "T"], d)
_git(["config", "user.email", "t@t"], d)
_git(["config", "commit.gpgsign", "false"], d)
Path(d, "README.md").write_text("initial\n")
_git(["add", "."], d)
_git(["commit", "-q", "-m", "init"], d)
context.pdt_repo = d # type: ignore[attr-defined]
@given("a mocked service that resolves the git resource for pdt")
def step_mock_service_with_resource(context: Context) -> None:
mock_resource = MagicMock()
mock_resource.resource_type_name = "git-checkout"
mock_resource.location = context.pdt_repo # type: ignore[attr-defined]
mock_resource.resource_id = "res-pdt-test"
mock_lr = MagicMock()
mock_lr.resource_id = "res-pdt-test"
mock_project = MagicMock()
mock_project.linked_resources = [mock_lr]
mock_plan = MagicMock()
mock_plan.project_links = [MagicMock(project_name="local/pdt-test")]
mock_service = MagicMock()
mock_service.get_plan.return_value = mock_plan
mock_project_repo = MagicMock()
mock_project_repo.get.return_value = mock_project
mock_resource_registry = MagicMock()
mock_resource_registry.show_resource.return_value = mock_resource
mock_container = MagicMock()
mock_container.namespaced_project_repo.return_value = mock_project_repo
mock_container.resource_registry_service.return_value = mock_resource_registry
context.pdt_service = mock_service # type: ignore[attr-defined]
context.pdt_container = mock_container # type: ignore[attr-defined]
@given("a mocked service with no linked resources for plan diff for pdt")
def step_mock_service_no_resources(context: Context) -> None:
mock_plan = MagicMock()
mock_plan.project_links = []
mock_service = MagicMock()
mock_service.get_plan.return_value = mock_plan
mock_container = MagicMock()
context.pdt_service = mock_service # type: ignore[attr-defined]
context.pdt_container = mock_container # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# When — Infrastructure layer
# ---------------------------------------------------------------------------
@when('I call diff_against_head for plan "{plan_id}" for pdt')
def step_call_diff_against_head(context: Context, plan_id: str) -> None:
from cleveragents.infrastructure.sandbox.git_worktree import (
GitWorktreeSandbox,
)
context.pdt_diff_output = GitWorktreeSandbox.diff_against_head( # type: ignore[attr-defined]
context.pdt_repo, # type: ignore[attr-defined]
plan_id,
)
# ---------------------------------------------------------------------------
# When — CLI layer
# ---------------------------------------------------------------------------
@when('I call _get_worktree_diff for plan "{plan_id}" for pdt')
def step_call_get_worktree_diff(context: Context, plan_id: str) -> None:
from cleveragents.cli.commands.plan import _get_worktree_diff
service: Any = context.pdt_service # type: ignore[attr-defined]
container: Any = context.pdt_container # type: ignore[attr-defined]
with patch(
"cleveragents.cli.commands.plan.get_container",
return_value=container,
):
context.pdt_diff_output = _get_worktree_diff(plan_id, service) # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then('the diff output should contain "{text}" for pdt')
def step_diff_contains(context: Context, text: str) -> None:
output: str | None = context.pdt_diff_output # type: ignore[attr-defined]
assert output is not None, "Expected diff output but got None"
assert text in output, f"Expected '{text}' in diff output, got: {output[:200]}"
@then("the diff output should not be None for pdt")
def step_diff_not_none(context: Context) -> None:
assert context.pdt_diff_output is not None, "Expected diff output but got None" # type: ignore[attr-defined]
@then("the diff output should be None for pdt")
def step_diff_is_none(context: Context) -> None:
assert context.pdt_diff_output is None, ( # type: ignore[attr-defined]
f"Expected None but got: {context.pdt_diff_output}" # type: ignore[attr-defined]
)
+79
View File
@@ -3429,6 +3429,71 @@ def revert_plan(
raise typer.Abort() from e
_diff_logger = structlog.get_logger("cleveragents.cli.commands.plan.diff")
def _get_worktree_diff(
plan_id: str,
service: PlanLifecycleService,
) -> str | None:
"""Return a worktree-branch diff for a plan, or ``None``.
Resolves the plan's linked git-checkout resource and delegates to
:meth:`GitWorktreeSandbox.diff_against_head` in the Infrastructure
layer. Returns ``None`` when no worktree branch exists so the
caller can fall back to changeset-based diff.
"""
if not plan_id or not plan_id.strip():
return None
try:
plan = service.get_plan(plan_id)
except (NotFoundError, CleverAgentsError):
return None
container = get_container()
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
for project_name in project_names:
try:
project = container.namespaced_project_repo().get(project_name)
except (NotFoundError, CleverAgentsError) as exc:
_diff_logger.debug(
"worktree_diff.project_lookup_failed",
project_name=project_name,
error=str(exc),
)
continue
if project is None:
continue
for lr in getattr(project, "linked_resources", []):
try:
resource = container.resource_registry_service().show_resource(
lr.resource_id,
)
except (NotFoundError, CleverAgentsError) as exc:
_diff_logger.debug(
"worktree_diff.resource_lookup_failed",
resource_id=lr.resource_id,
error=str(exc),
)
continue
if (
resource.resource_type_name not in ("git-checkout", "git")
or not resource.location
):
continue
diff = GitWorktreeSandbox.diff_against_head(
resource.location,
plan_id,
)
if diff is not None:
return diff
return None
def _get_apply_service() -> PlanApplyService:
"""Get the PlanApplyService from the lifecycle service."""
from cleveragents.application.container import get_container
@@ -3487,6 +3552,20 @@ def plan_diff(
console.print(output)
return
# Try worktree branch diff first — this is where LLM output
# lives after plan execute (spec §13225).
try:
worktree_diff = _get_worktree_diff(
plan_id,
_get_lifecycle_service(),
)
if worktree_diff is not None:
console.print(worktree_diff)
return
except Exception:
pass # Container unavailable; fall through to changeset diff
# Fall back to changeset-based diff from plan metadata
output = service.diff(plan_id, fmt=_fmt)
console.print(output)
@@ -249,7 +249,40 @@ class GitWorktreeSandbox:
"Partial cleanup: worktree removed but branch persists: branch=%s",
branch_name,
)
return True # Branch was found; cleanup attempted (even if partial)
return True
@classmethod
def diff_against_head(cls, repo_path: str, plan_id: str) -> str | None:
"""Return a unified diff of the worktree branch vs HEAD.
Args:
repo_path: Absolute path to the git repository root.
plan_id: The plan ULID whose worktree branch to diff.
Returns:
The diff text, or ``None`` if no worktree branch exists.
"""
branch_name = f"cleveragents/plan-{plan_id}"
try:
_run_git(
["rev-parse", "--verify", f"refs/heads/{branch_name}"],
cwd=repo_path,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
try:
result = _run_git(
["diff", f"HEAD...{branch_name}"],
cwd=repo_path,
timeout=30,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
diff_text = result.stdout.strip()
return diff_text if diff_text else "No changes in worktree branch."
# -- protocol methods ----------------------------------------------------