fix(cli): add agents plan start alias or update spec to reflect v3 plan use/execute commands #8661

Closed
HAL9000 wants to merge 5 commits from fix/plan-start-spec-alignment into master
8 changed files with 1798 additions and 627 deletions
@@ -0,0 +1,41 @@
Feature: GitWorktreeSandbox class methods for stale cleanup and diff
As a developer
I want class-level helpers to clean up stale worktrees and generate diffs
So that the CLI can manage sandbox lifecycle without a sandbox instance
Background:
Given a gwt_cm test git repository is initialised
Scenario: cleanup_stale removes a stale worktree branch
Given a gwt_cm stale worktree branch exists for plan "plan-stale-001"
When I call GitWorktreeSandbox.cleanup_stale for plan "plan-stale-001"
Then the gwt_cm stale branch should no longer exist
Scenario: cleanup_stale is idempotent when no stale branch exists
When I call GitWorktreeSandbox.cleanup_stale for plan "plan-nonexistent-999"
Then no gwt_cm exception should have been raised
Scenario: cleanup_stale handles empty plan_id gracefully
When I call GitWorktreeSandbox.cleanup_stale with empty plan_id
Then no gwt_cm exception should have been raised
Scenario: cleanup_stale handles empty original_path gracefully
When I call GitWorktreeSandbox.cleanup_stale with empty original_path
Then no gwt_cm exception should have been raised
Scenario: diff_against_head returns None when no worktree branch exists
When I call GitWorktreeSandbox.diff_against_head for plan "plan-no-branch-001"
Then the gwt_cm diff result should be None
Scenario: diff_against_head returns diff when worktree branch has changes
Given a gwt_cm worktree branch with changes exists for plan "plan-diff-001"
When I call GitWorktreeSandbox.diff_against_head for plan "plan-diff-001"
Then the gwt_cm diff result should not be None
Scenario: diff_against_head handles empty plan_id gracefully
When I call GitWorktreeSandbox.diff_against_head with empty plan_id
Then the gwt_cm diff result should be None
Scenario: diff_against_head handles empty original_path gracefully
When I call GitWorktreeSandbox.diff_against_head with empty original_path
Then the gwt_cm diff result should be None
@@ -0,0 +1,245 @@
"""Step definitions for GitWorktreeSandbox class methods feature.
All steps use the ``gwt_cm`` prefix to avoid collisions with other step files.
Note: GitWorktreeSandbox is imported lazily inside step functions to ensure
the isolated repo's src directory (added by environment.py before_all) takes
precedence over PYTHONPATH entries.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from behave import given, then, when
from behave.runner import Context
def _get_gwt() -> type:
"""Lazily import GitWorktreeSandbox to use the isolated repo's version."""
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
return GitWorktreeSandbox
def _init_test_repo_cm(ctx: Context) -> str:
"""Create a temporary git repo with an initial commit."""
repo_dir = tempfile.mkdtemp(prefix="gwt-cm-test-repo-")
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "commit.gpgSign", "false"],
cwd=repo_dir,
capture_output=True,
check=True,
)
readme = os.path.join(repo_dir, "README.md")
with open(readme, "w") as f:
f.write("# Test Repo\n")
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=repo_dir,
capture_output=True,
check=True,
)
return repo_dir
@given("a gwt_cm test git repository is initialised")
def step_gwt_cm_init_repo(ctx: Context) -> None:
"""Initialise a temporary git repository for testing."""
ctx.gwt_cm_repo_dir = _init_test_repo_cm(ctx)
ctx.gwt_cm_exception: Exception | None = None
ctx.gwt_cm_diff_result: str | None = None
@given('a gwt_cm stale worktree branch exists for plan "{plan_id}"')
def step_gwt_cm_create_stale_branch(ctx: Context, plan_id: str) -> None:
"""Create a stale worktree branch to simulate a previous execute."""
repo_dir: str = ctx.gwt_cm_repo_dir
branch_name = f"cleveragents/plan-{plan_id}"
# Create the branch directly without a worktree
subprocess.run(
["git", "branch", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
@when('I call GitWorktreeSandbox.cleanup_stale for plan "{plan_id}"')
def step_gwt_cm_call_cleanup_stale(ctx: Context, plan_id: str) -> None:
"""Call the cleanup_stale class method."""
GitWorktreeSandbox = _get_gwt()
try:
GitWorktreeSandbox.cleanup_stale(ctx.gwt_cm_repo_dir, plan_id)
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
@when("I call GitWorktreeSandbox.cleanup_stale with empty plan_id")
def step_gwt_cm_cleanup_stale_empty_plan_id(ctx: Context) -> None:
"""Call cleanup_stale with an empty plan_id."""
GitWorktreeSandbox = _get_gwt()
try:
GitWorktreeSandbox.cleanup_stale(ctx.gwt_cm_repo_dir, "")
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
@when("I call GitWorktreeSandbox.cleanup_stale with empty original_path")
def step_gwt_cm_cleanup_stale_empty_path(ctx: Context) -> None:
"""Call cleanup_stale with an empty original_path."""
GitWorktreeSandbox = _get_gwt()
try:
GitWorktreeSandbox.cleanup_stale("", "some-plan-id")
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
@then("the gwt_cm stale branch should no longer exist")
def step_gwt_cm_branch_not_exist(ctx: Context) -> None:
"""Assert that the stale branch has been removed."""
result = subprocess.run(
["git", "branch", "--list"],
cwd=ctx.gwt_cm_repo_dir,
capture_output=True,
text=True,
check=True,
)
# No cleveragents/plan-* branches should remain
branches = result.stdout.strip()
assert "cleveragents/plan-" not in branches, (
f"Expected no cleveragents/plan-* branches, but found: {branches}"
)
@then("no gwt_cm exception should have been raised")
def step_gwt_cm_no_exception(ctx: Context) -> None:
"""Assert that no exception was raised."""
assert ctx.gwt_cm_exception is None, (
f"Expected no exception, but got: {ctx.gwt_cm_exception}"
)
@given('a gwt_cm worktree branch with changes exists for plan "{plan_id}"')
def step_gwt_cm_create_branch_with_changes(ctx: Context, plan_id: str) -> None:
"""Create a branch with a committed change to simulate execute output."""
repo_dir: str = ctx.gwt_cm_repo_dir
branch_name = f"cleveragents/plan-{plan_id}"
# Create and switch to the new branch
subprocess.run(
["git", "checkout", "-b", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Add a new file and commit it
new_file = os.path.join(repo_dir, "generated.py")
with open(new_file, "w") as f:
f.write("# Generated by plan\nresult = 42\n")
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", f"Plan {plan_id} output"],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Switch back to the original branch
subprocess.run(
["git", "checkout", "master"],
cwd=repo_dir,
capture_output=True,
check=False,
)
# Try main if master doesn't exist
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_dir,
capture_output=True,
text=True,
check=False,
)
if result.stdout.strip() != "master":
subprocess.run(
["git", "checkout", "main"],
cwd=repo_dir,
capture_output=True,
check=False,
)
@when('I call GitWorktreeSandbox.diff_against_head for plan "{plan_id}"')
def step_gwt_cm_call_diff_against_head(ctx: Context, plan_id: str) -> None:
"""Call the diff_against_head class method."""
GitWorktreeSandbox = _get_gwt()
try:
ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head(
ctx.gwt_cm_repo_dir, plan_id
)
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
ctx.gwt_cm_diff_result = None
@when("I call GitWorktreeSandbox.diff_against_head with empty plan_id")
def step_gwt_cm_diff_empty_plan_id(ctx: Context) -> None:
"""Call diff_against_head with an empty plan_id."""
GitWorktreeSandbox = _get_gwt()
try:
ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head(
ctx.gwt_cm_repo_dir, ""
)
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
ctx.gwt_cm_diff_result = None
@when("I call GitWorktreeSandbox.diff_against_head with empty original_path")
def step_gwt_cm_diff_empty_path(ctx: Context) -> None:
"""Call diff_against_head with an empty original_path."""
GitWorktreeSandbox = _get_gwt()
try:
ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head("", "some-plan")
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
ctx.gwt_cm_diff_result = None
@then("the gwt_cm diff result should be None")
def step_gwt_cm_diff_is_none(ctx: Context) -> None:
"""Assert that the diff result is None."""
assert ctx.gwt_cm_diff_result is None, (
f"Expected diff result to be None, but got: {ctx.gwt_cm_diff_result!r}"
)
@then("the gwt_cm diff result should not be None")
def step_gwt_cm_diff_is_not_none(ctx: Context) -> None:
"""Assert that the diff result is not None."""
assert ctx.gwt_cm_diff_result is not None, (
"Expected diff result to not be None, but it was None"
)
@@ -0,0 +1,80 @@
"""Step definitions for strategy actor resolution feature.
All steps use the ``sar`` prefix to avoid collisions with other step files.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
Review

Non-blocking — Mocks inline in step file instead of features/mocks/

MagicMock objects are constructed inline within this step definition file. Per project convention (CONTRIBUTING.md), all mock/fake/stub/test double objects must live in features/mocks/ exclusively.

How to fix: Extract mock factory helpers (e.g. the MagicMock() registry/lifecycle setup) into a dedicated module at features/mocks/strategy_actor_mocks.py and import from there. Apply the same pattern to the mock helpers in features/steps/git_worktree_class_methods_steps.py and features/steps/plan_apply_correction_diff_steps.py.


Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

**Non-blocking — Mocks inline in step file instead of `features/mocks/`** `MagicMock` objects are constructed inline within this step definition file. Per project convention (CONTRIBUTING.md), all mock/fake/stub/test double objects must live in `features/mocks/` exclusively. **How to fix:** Extract mock factory helpers (e.g. the `MagicMock()` registry/lifecycle setup) into a dedicated module at `features/mocks/strategy_actor_mocks.py` and import from there. Apply the same pattern to the mock helpers in `features/steps/git_worktree_class_methods_steps.py` and `features/steps/plan_apply_correction_diff_steps.py`. --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker
Review

Non-blocking — Mock objects must live in features/mocks/, not features/steps/

MagicMock is imported and instantiated directly in this step definition file. Per CONTRIBUTING.md, all mock/fake/stub/test double objects must be placed exclusively in features/mocks/.

How to fix: Create features/mocks/strategy_actor_mocks.py with named factory functions that set up the mock registry and lifecycle objects. Import and call those factories from here.


Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

**Non-blocking — Mock objects must live in `features/mocks/`, not `features/steps/`** `MagicMock` is imported and instantiated directly in this step definition file. Per CONTRIBUTING.md, all mock/fake/stub/test double objects must be placed exclusively in `features/mocks/`. **How to fix:** Create `features/mocks/strategy_actor_mocks.py` with named factory functions that set up the mock registry and lifecycle objects. Import and call those factories from here. --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.strategy_actor import resolve_strategy_actor
@given("a sar mock provider registry is available")
def step_sar_mock_registry(ctx: Context) -> None:
"""Set up a mock provider registry."""
ctx.sar_registry: Any = MagicMock()
ctx.sar_registry.__bool__ = lambda self: True
@given("a sar mock lifecycle service is available")
def step_sar_mock_lifecycle(ctx: Context) -> None:
"""Set up a mock lifecycle service."""
ctx.sar_lifecycle: Any = MagicMock()
ctx.sar_lifecycle.get_plan = MagicMock(return_value=MagicMock())
ctx.sar_lifecycle.get_action = MagicMock(return_value=MagicMock())
@when('I call resolve_strategy_actor with config_value "{config_value}"')
def step_sar_call_with_config(ctx: Context, config_value: str) -> None:
"""Call resolve_strategy_actor with the given config_value."""
registry = getattr(ctx, "sar_registry", None)
lifecycle = getattr(ctx, "sar_lifecycle", MagicMock())
ctx.sar_result = resolve_strategy_actor(
provider_registry=registry,
lifecycle_service=lifecycle,
config_value=config_value,
)
@when("I call resolve_strategy_actor with no provider registry")
def step_sar_call_no_registry(ctx: Context) -> None:
"""Call resolve_strategy_actor with no provider registry."""
lifecycle = getattr(ctx, "sar_lifecycle", MagicMock())
ctx.sar_result = resolve_strategy_actor(
provider_registry=None,
lifecycle_service=lifecycle,
config_value=None,
)
@when("I call resolve_strategy_actor with no config_value")
def step_sar_call_no_config(ctx: Context) -> None:
"""Call resolve_strategy_actor with no config_value."""
registry = getattr(ctx, "sar_registry", None)
lifecycle = getattr(ctx, "sar_lifecycle", MagicMock())
ctx.sar_result = resolve_strategy_actor(
provider_registry=registry,
lifecycle_service=lifecycle,
config_value=None,
)
@then("the sar resolved actor should be None")
def step_sar_result_is_none(ctx: Context) -> None:
"""Assert that the resolved actor is None."""
assert ctx.sar_result is None, (
f"Expected resolved actor to be None, but got: {ctx.sar_result!r}"
)
@then("the sar resolved actor should not be None")
def step_sar_result_is_not_none(ctx: Context) -> None:
"""Assert that the resolved actor is not None."""
assert ctx.sar_result is not None, (
"Expected resolved actor to not be None, but it was None"
)
@@ -0,0 +1,27 @@
Feature: Strategy actor resolution for plan execution
As a developer
I want resolve_strategy_actor to select the correct strategize actor
So that plan execution uses the right LLM or stub actor
Scenario: resolve_strategy_actor returns None when config_value is "stub"
Given a sar mock provider registry is available
And a sar mock lifecycle service is available
When I call resolve_strategy_actor with config_value "stub"
Then the sar resolved actor should be None
Scenario: resolve_strategy_actor returns None when provider_registry is None
Given a sar mock lifecycle service is available
When I call resolve_strategy_actor with no provider registry
Then the sar resolved actor should be None
Scenario: resolve_strategy_actor returns LLMStrategizeActor when registry is available
Given a sar mock provider registry is available
And a sar mock lifecycle service is available
When I call resolve_strategy_actor with config_value "llm"
Then the sar resolved actor should not be None
Scenario: resolve_strategy_actor returns LLMStrategizeActor when config_value is None
Given a sar mock provider registry is available
And a sar mock lifecycle service is available
When I call resolve_strategy_actor with no config_value
Then the sar resolved actor should not be None
+76
View File
@@ -0,0 +1,76 @@
*** Settings ***
Documentation Integration tests for GitWorktreeSandbox class methods:
... cleanup_stale and diff_against_head.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_git_worktree_class_methods.py
*** Test Cases ***
Cleanup Stale With No Existing Branch Is Idempotent
[Documentation] cleanup_stale does nothing when no stale branch exists
${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-no-branch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cleanup-stale-no-branch-ok
Cleanup Stale Removes Existing Branch
[Documentation] cleanup_stale removes a stale worktree branch
${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-removes-branch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cleanup-stale-removes-branch-ok
Cleanup Stale With Empty Plan ID Is Safe
[Documentation] cleanup_stale handles empty plan_id without raising
${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-empty-plan-id cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cleanup-stale-empty-plan-id-ok
Diff Against Head Returns None When No Branch
[Documentation] diff_against_head returns None when no worktree branch exists
${result}= Run Process ${PYTHON} ${HELPER} diff-no-branch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} diff-no-branch-ok
Diff Against Head Returns Diff When Branch Has Changes
[Documentation] diff_against_head returns a non-empty diff when the branch has commits
${result}= Run Process ${PYTHON} ${HELPER} diff-with-changes cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} diff-with-changes-ok
Diff Against Head With Empty Plan ID Returns None
[Documentation] diff_against_head returns None for empty plan_id
${result}= Run Process ${PYTHON} ${HELPER} diff-empty-plan-id cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} diff-empty-plan-id-ok
Strategy Actor Resolves To None For Stub Config
[Documentation] resolve_strategy_actor returns None when config_value is "stub"
${result}= Run Process ${PYTHON} ${HELPER} strategy-actor-stub cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} strategy-actor-stub-ok
Strategy Actor Resolves To None Without Registry
[Documentation] resolve_strategy_actor returns None when no registry is provided
${result}= Run Process ${PYTHON} ${HELPER} strategy-actor-no-registry cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} strategy-actor-no-registry-ok
+301
View File
@@ -0,0 +1,301 @@
"""Robot Framework helper for GitWorktreeSandbox class methods integration tests.
Tests cleanup_stale, diff_against_head, and resolve_strategy_actor.
Exit code 0 = success, 1 = failure.
Usage:
python robot/helper_git_worktree_class_methods.py cleanup-stale-no-branch
python robot/helper_git_worktree_class_methods.py cleanup-stale-removes-branch
python robot/helper_git_worktree_class_methods.py cleanup-stale-empty-plan-id
python robot/helper_git_worktree_class_methods.py diff-no-branch
python robot/helper_git_worktree_class_methods.py diff-with-changes
python robot/helper_git_worktree_class_methods.py diff-empty-plan-id
python robot/helper_git_worktree_class_methods.py strategy-actor-stub
python robot/helper_git_worktree_class_methods.py strategy-actor-no-registry
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
# Ensure the isolated repo's src directory takes precedence over any
# PYTHONPATH entries (e.g. /app/src from the workspace environment).
_SRC = str(Path(__file__).resolve().parents[1] / "src")
# Remove any conflicting paths that might shadow our isolated repo
sys.path = [p for p in sys.path if not (p.endswith("/src") and p != _SRC)]
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Clear any cached cleveragents modules so our isolated version is used
for _mod_name in list(sys.modules.keys()):
if _mod_name == "cleveragents" or _mod_name.startswith("cleveragents."):
del sys.modules[_mod_name]
from cleveragents.infrastructure.sandbox.git_worktree import ( # noqa: E402
GitWorktreeSandbox,
)
def _init_test_repo() -> str:
"""Create a temporary git repo with an initial commit."""
repo_dir = tempfile.mkdtemp(prefix="gwt-cm-robot-")
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "commit.gpgSign", "false"],
cwd=repo_dir,
capture_output=True,
check=True,
)
readme = os.path.join(repo_dir, "README.md")
with open(readme, "w") as f:
f.write("# Test Repo\n")
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=repo_dir,
capture_output=True,
check=True,
)
return repo_dir
def cmd_cleanup_stale_no_branch() -> None:
"""cleanup_stale is idempotent when no stale branch exists."""
repo_dir = _init_test_repo()
try:
GitWorktreeSandbox.cleanup_stale(repo_dir, "plan-nonexistent-999")
print("cleanup-stale-no-branch-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_cleanup_stale_removes_branch() -> None:
"""cleanup_stale removes a stale worktree branch."""
repo_dir = _init_test_repo()
try:
plan_id = "plan-stale-001"
branch_name = f"cleveragents/plan-{plan_id}"
# Create a stale branch
subprocess.run(
["git", "branch", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Verify branch exists
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=repo_dir,
capture_output=True,
text=True,
check=True,
)
assert branch_name in result.stdout, f"Branch {branch_name} should exist"
# Call cleanup_stale
GitWorktreeSandbox.cleanup_stale(repo_dir, plan_id)
# Verify branch is gone
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=repo_dir,
capture_output=True,
text=True,
check=True,
)
assert branch_name not in result.stdout, (
f"Branch {branch_name} should have been removed"
)
print("cleanup-stale-removes-branch-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_cleanup_stale_empty_plan_id() -> None:
"""cleanup_stale handles empty plan_id without raising."""
repo_dir = _init_test_repo()
try:
GitWorktreeSandbox.cleanup_stale(repo_dir, "")
print("cleanup-stale-empty-plan-id-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_diff_no_branch() -> None:
"""diff_against_head returns None when no worktree branch exists."""
repo_dir = _init_test_repo()
try:
result = GitWorktreeSandbox.diff_against_head(repo_dir, "plan-no-branch-001")
assert result is None, f"Expected None, got: {result!r}"
print("diff-no-branch-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_diff_with_changes() -> None:
"""diff_against_head returns a non-empty diff when the branch has commits."""
repo_dir = _init_test_repo()
try:
plan_id = "plan-diff-001"
branch_name = f"cleveragents/plan-{plan_id}"
# Create and switch to the new branch
subprocess.run(
["git", "checkout", "-b", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Add a new file and commit it
new_file = os.path.join(repo_dir, "generated.py")
with open(new_file, "w") as f:
f.write("# Generated by plan\nresult = 42\n")
subprocess.run(
["git", "add", "."], cwd=repo_dir, capture_output=True, check=True
)
subprocess.run(
["git", "commit", "-m", f"Plan {plan_id} output"],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Switch back to the original branch
subprocess.run(
["git", "checkout", "master"],
cwd=repo_dir,
capture_output=True,
check=False,
)
# Try main if master doesn't exist
rev_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_dir,
capture_output=True,
text=True,
check=False,
)
if rev_result.stdout.strip() not in ("master", "main"):
subprocess.run(
["git", "checkout", "main"],
cwd=repo_dir,
capture_output=True,
check=False,
)
# Call diff_against_head
diff = GitWorktreeSandbox.diff_against_head(repo_dir, plan_id)
assert diff is not None, "Expected a non-None diff"
assert len(diff) > 0, "Expected a non-empty diff"
print("diff-with-changes-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_diff_empty_plan_id() -> None:
"""diff_against_head returns None for empty plan_id."""
repo_dir = _init_test_repo()
try:
result = GitWorktreeSandbox.diff_against_head(repo_dir, "")
assert result is None, f"Expected None, got: {result!r}"
print("diff-empty-plan-id-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_strategy_actor_stub() -> None:
"""resolve_strategy_actor returns None when config_value is 'stub'."""
from cleveragents.application.services.strategy_actor import resolve_strategy_actor
registry: Any = MagicMock()
lifecycle: Any = MagicMock()
result = resolve_strategy_actor(
provider_registry=registry,
lifecycle_service=lifecycle,
config_value="stub",
)
assert result is None, f"Expected None for stub config, got: {result!r}"
print("strategy-actor-stub-ok")
def cmd_strategy_actor_no_registry() -> None:
"""resolve_strategy_actor returns None when no registry is provided."""
from cleveragents.application.services.strategy_actor import resolve_strategy_actor
lifecycle: Any = MagicMock()
result = resolve_strategy_actor(
provider_registry=None,
lifecycle_service=lifecycle,
config_value=None,
)
assert result is None, f"Expected None for no registry, got: {result!r}"
print("strategy-actor-no-registry-ok")
_COMMANDS: dict[str, Any] = {
"cleanup-stale-no-branch": cmd_cleanup_stale_no_branch,
"cleanup-stale-removes-branch": cmd_cleanup_stale_removes_branch,
"cleanup-stale-empty-plan-id": cmd_cleanup_stale_empty_plan_id,
"diff-no-branch": cmd_diff_no_branch,
"diff-with-changes": cmd_diff_with_changes,
"diff-empty-plan-id": cmd_diff_empty_plan_id,
"strategy-actor-stub": cmd_strategy_actor_stub,
"strategy-actor-no-registry": cmd_strategy_actor_no_registry,
}
def main() -> None:
"""Entry point for Robot Framework helper."""
if len(sys.argv) < 2:
print("Usage: helper_git_worktree_class_methods.py <command>", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
if cmd not in _COMMANDS:
print(f"Unknown command: {cmd}", file=sys.stderr)
print(f"Available: {', '.join(_COMMANDS)}", file=sys.stderr)
sys.exit(1)
try:
_COMMANDS[cmd]()
except Exception as exc:
print(f"FAILED: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -164,127 +164,6 @@ class GitWorktreeSandbox:
"""Context after creation, ``None`` before ``create``."""
return self._context
# -- class helpers -------------------------------------------------------
@classmethod
def cleanup_stale(cls, repo_path: str, plan_id: str) -> bool:
"""Remove a stale worktree branch left by a previous execution.
Idempotent does nothing if no stale branch exists.
Args:
repo_path: Absolute path to the git repository root.
plan_id: The plan ULID whose stale branch should be removed.
Returns:
``True`` if a stale branch was found and cleaned up,
``False`` if no stale branch existed.
"""
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 False
logger.info(
"Cleaning up stale sandbox branch: branch=%s repo=%s",
branch_name,
repo_path,
)
try:
wt_result = _run_git(
["worktree", "list", "--porcelain"],
cwd=repo_path,
)
for wt_block in wt_result.stdout.split("\n\n"):
if f"branch refs/heads/{branch_name}" in wt_block:
for line in wt_block.splitlines():
if line.startswith("worktree "):
wt_path = line.split("worktree ", 1)[1]
try:
_run_git(
["worktree", "remove", "--force", wt_path],
cwd=repo_path,
)
except (
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
):
logger.warning(
"git worktree remove failed; "
"removing directory manually: %s",
wt_path,
)
shutil.rmtree(wt_path, ignore_errors=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
logger.warning(
"Failed to list worktrees for stale cleanup: %s",
branch_name,
)
branch_deleted = True
try:
_run_git(["branch", "-D", branch_name], cwd=repo_path)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
branch_deleted = False
logger.warning(
"Failed to delete stale branch %s",
branch_name,
)
with contextlib.suppress(
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
):
_run_git(["worktree", "prune"], cwd=repo_path)
if branch_deleted:
logger.info("Stale sandbox branch cleaned up: branch=%s", branch_name)
else:
logger.warning(
"Partial cleanup: worktree removed but branch persists: branch=%s",
branch_name,
)
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 ----------------------------------------------------
def create(self, plan_id: str) -> SandboxContext:
@@ -684,6 +563,143 @@ class GitWorktreeSandbox:
self._base_commit,
)
@classmethod
def cleanup_stale(cls, original_path: str, plan_id: str) -> None:
"""Remove any stale worktree and branch left by a previous execute.
Looks for a worktree branch named ``cleveragents/plan-<plan_id>``
in the repository at *original_path* and removes it if found.
Idempotent safe to call even when no stale sandbox exists.
Args:
original_path: Path to the git repository root.
plan_id: The plan ID whose stale sandbox should be removed.
"""
if not original_path or not plan_id:
return
safe_plan_id = _sanitise_branch_name(plan_id)
branch_name = f"cleveragents/plan-{safe_plan_id}"
try:
# List all worktrees to find any matching this plan
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=original_path,
capture_output=True,
text=True,
check=False,
timeout=_GIT_TIMEOUT,
)
if result.returncode != 0:
return
# Parse worktree list output
current_wt_path: str | None = None
current_branch: str | None = None
for line in result.stdout.splitlines():
if line.startswith("worktree "):
current_wt_path = line.split("worktree ", 1)[1].strip()
current_branch = None
elif line.startswith("branch "):
current_branch = line.split("branch ", 1)[1].strip()
# branch refs/heads/cleveragents/plan-...
if current_branch.endswith(branch_name) and current_wt_path:
# Remove the stale worktree
subprocess.run(
["git", "worktree", "remove", "--force", current_wt_path],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
current_wt_path = None
current_branch = None
# Delete the stale branch if it exists
subprocess.run(
["git", "branch", "-D", branch_name],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
# Prune stale worktree entries
subprocess.run(
["git", "worktree", "prune"],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError):
logger.debug(
"cleanup_stale: error during stale sandbox cleanup "
"(original_path=%s, plan_id=%s)",
original_path,
plan_id,
)
@classmethod
def diff_against_head(cls, original_path: str, plan_id: str) -> str | None:
"""Return a unified diff of the worktree branch against HEAD.
Looks for a worktree branch named ``cleveragents/plan-<plan_id>``
in the repository at *original_path* and returns a unified diff
of that branch against HEAD. Returns ``None`` when no such branch
exists.
Args:
original_path: Path to the git repository root.
plan_id: The plan ID whose worktree branch to diff.
Returns:
A unified diff string, or ``None`` if no worktree branch exists.
"""
if not original_path or not plan_id:
return None
safe_plan_id = _sanitise_branch_name(plan_id)
branch_name = f"cleveragents/plan-{safe_plan_id}"
try:
# Check if the branch exists
check = subprocess.run(
["git", "rev-parse", "--verify", branch_name],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
if check.returncode != 0:
return None
# Generate diff between HEAD and the worktree branch
diff_result = subprocess.run(
["git", "diff", "HEAD", branch_name],
cwd=original_path,
capture_output=True,
text=True,
check=False,
timeout=_GIT_TIMEOUT,
)
if diff_result.returncode != 0:
return None
diff_output = diff_result.stdout.strip()
return diff_output if diff_output else None
except (subprocess.TimeoutExpired, OSError):
logger.debug(
"diff_against_head: error generating worktree diff "
"(original_path=%s, plan_id=%s)",
original_path,
plan_id,
)
return None
def cleanup(self) -> None:
"""Remove the worktree and sandbox branch.