fix(cli): add agents plan start alias or update spec to reflect v3 plan use/execute commands #8661
@@ -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
|
||||
|
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
Non-blocking — Mocks inline in step file instead of
features/mocks/MagicMockobjects are constructed inline within this step definition file. Per project convention (CONTRIBUTING.md), all mock/fake/stub/test double objects must live infeatures/mocks/exclusively.How to fix: Extract mock factory helpers (e.g. the
MagicMock()registry/lifecycle setup) into a dedicated module atfeatures/mocks/strategy_actor_mocks.pyand import from there. Apply the same pattern to the mock helpers infeatures/steps/git_worktree_class_methods_steps.pyandfeatures/steps/plan_apply_correction_diff_steps.py.Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker
Non-blocking — Mock objects must live in
features/mocks/, notfeatures/steps/MagicMockis imported and instantiated directly in this step definition file. Per CONTRIBUTING.md, all mock/fake/stub/test double objects must be placed exclusively infeatures/mocks/.How to fix: Create
features/mocks/strategy_actor_mocks.pywith 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