Files
placeholder/features/steps/git_worktree_coverage_boost_steps.py
freemo 4b9798bc40 fix(sandbox): use spec-required cleveragents/plan- branch prefix in GitWorktreeSandbox
- Implemented branch naming change in GitWorktreeSandbox.create() from sandbox/<plan_id> to cleveragents/plan-<plan_id>, located in src/cleveragents/infrastructure/sandbox/git_worktree.py
- Updated the create() method docstring to reflect the new branch naming convention
- Updated test fixtures:
  - features/steps/git_worktree_coverage_boost_steps.py: sandbox/test -> cleveragents/plan-test, sandbox/cleanup-test -> cleveragents/plan-cleanup-test, sandbox/branch-delete-fail -> cleveragents/plan-branch-delete-fail
- Updated scenario:
  - features/garbage_collection.feature: sandbox/test-plan -> cleveragents/plan-test-plan
- Design rationale:
  - Adopted the spec-required format: cleveragents/plan-{safe_plan_id} to align with the spec-defined namespace for all CleverAgents-managed git branches
  - Ensures consistent branch naming across code, tests, and features for easier policy enforcement and maintainability

ISSUES CLOSED: #2601
2026-04-05 08:29:55 +00:00

578 lines
20 KiB
Python

"""Step definitions for git worktree sandbox coverage boost.
All steps use the ``gwtcb`` prefix to avoid collisions with existing steps.
These scenarios target uncovered lines and branches in git_worktree.py.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.infrastructure.sandbox.git_worktree import (
GitWorktreeSandbox,
_sanitise_branch_name,
)
from cleveragents.infrastructure.sandbox.protocol import (
SandboxCommitError,
SandboxCreationError,
SandboxRollbackError,
SandboxStateError,
SandboxStatus,
)
_MODULE = "cleveragents.infrastructure.sandbox.git_worktree"
def _make_sandbox(original_path: str) -> GitWorktreeSandbox:
"""Helper: create a GitWorktreeSandbox with sensible defaults."""
return GitWorktreeSandbox(
resource_id="res-cov-001",
original_path=original_path,
git_timeout=10,
)
def _init_test_repo() -> str:
"""Create a temporary git repo with an initial commit."""
repo_dir = tempfile.mkdtemp(prefix="gwtcb-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
# ---------------------------------------------------------------------------
# _sanitise_branch_name
# ---------------------------------------------------------------------------
@when('gwtcb _sanitise_branch_name is called with "{raw}"')
def step_gwtcb_sanitise(ctx: Context, raw: str) -> None:
ctx.gwtcb_result = _sanitise_branch_name(raw)
ctx.gwtcb_error = None
@then('gwtcb the result should be "{expected}"')
def step_gwtcb_sanitise_result(ctx: Context, expected: str) -> None:
assert ctx.gwtcb_result == expected, (
f"Expected '{expected}', got '{ctx.gwtcb_result}'"
)
# ---------------------------------------------------------------------------
# create: path is not repo root
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox pointed at a non-root subdirectory")
def step_gwtcb_non_root_subdir(ctx: Context) -> None:
repo_dir = _init_test_repo()
# Create a subdirectory inside the repo
subdir = os.path.join(repo_dir, "subdir")
os.makedirs(subdir, exist_ok=True)
ctx.gwtcb_sandbox = _make_sandbox(subdir)
ctx.gwtcb_error = None
@when('gwtcb create is called for plan "{plan_id}"')
def step_gwtcb_create(ctx: Context, plan_id: str) -> None:
try:
ctx.gwtcb_sandbox.create(plan_id)
except (SandboxCreationError, SandboxStateError, ValueError) as exc:
ctx.gwtcb_error = exc
# ---------------------------------------------------------------------------
# create: timeout
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox with mocked _run_git that times out")
def step_gwtcb_create_timeout_setup(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
# Patch _run_git to raise TimeoutExpired on first call
patcher = patch(
f"{_MODULE}._run_git",
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
)
ctx.gwtcb_patcher = patcher
patcher.start()
@when("gwtcb create is called expecting a timeout error")
def step_gwtcb_create_timeout(ctx: Context) -> None:
try:
ctx.gwtcb_sandbox.create("plan-timeout")
except SandboxCreationError as exc:
ctx.gwtcb_error = exc
finally:
if hasattr(ctx, "gwtcb_patcher"):
ctx.gwtcb_patcher.stop()
# ---------------------------------------------------------------------------
# get_path: worktree_path is None
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox in CREATED state with None worktree_path")
def step_gwtcb_created_none_path(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
# Force internal state: CREATED with worktree_path = None
ctx.gwtcb_sandbox._status = SandboxStatus.CREATED
ctx.gwtcb_sandbox._worktree_path = None
@when('gwtcb get_path is called with "{path}"')
def step_gwtcb_get_path(ctx: Context, path: str) -> None:
try:
ctx.gwtcb_sandbox.get_path(path)
except SandboxStateError as exc:
ctx.gwtcb_error = exc
# ---------------------------------------------------------------------------
# commit: worktree not initialised
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox in ACTIVE state with no worktree or branch")
def step_gwtcb_active_no_worktree(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
# Force ACTIVE with None worktree and branch to hit line 344-345
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
ctx.gwtcb_sandbox._worktree_path = None
ctx.gwtcb_sandbox._branch_name = None
@when('gwtcb commit is called with message "{message}"')
def step_gwtcb_commit(ctx: Context, message: str) -> None:
try:
ctx.gwtcb_commit_result = ctx.gwtcb_sandbox.commit(message)
except (SandboxStateError, SandboxCommitError) as exc:
ctx.gwtcb_error = exc
# ---------------------------------------------------------------------------
# commit: modified files in diff output
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox ready to commit with modified-file diff output")
def step_gwtcb_commit_modified_diff(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
ctx.gwtcb_commit_result = None
# Set sandbox to ACTIVE with valid worktree/branch
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
ctx.gwtcb_sandbox._worktree_path = repo_dir
ctx.gwtcb_sandbox._branch_name = "cleveragents/plan-test"
ctx.gwtcb_sandbox._base_commit = "abc123"
ctx.gwtcb_sandbox._original_branch = "main"
# Prepare the diff output including M (modified), A (added), D (deleted)
diff_output = "M\tsrc/modified.py\nA\tsrc/new.py\nD\tsrc/old.py\n"
# Mock _run_git for add, commit, rev-parse, merge
def mock_run_git(args, cwd, timeout=10):
result = MagicMock()
result.stdout = "abc456\n"
result.stderr = ""
return result
ctx.gwtcb_run_git_patcher = patch(f"{_MODULE}._run_git", side_effect=mock_run_git)
ctx.gwtcb_run_git_patcher.start()
# Mock subprocess.run for diff --cached --name-status
real_subprocess_run = subprocess.run
def mock_subprocess_run(cmd, **kwargs):
if isinstance(cmd, list) and "diff" in cmd and "--name-status" in cmd:
result = MagicMock()
result.stdout = diff_output
result.stderr = ""
result.returncode = 0
return result
return real_subprocess_run(cmd, **kwargs)
ctx.gwtcb_subprocess_patcher = patch(
"subprocess.run", side_effect=mock_subprocess_run
)
ctx.gwtcb_subprocess_patcher.start()
@then('the gwtcb commit result should include changed file "{filename}"')
def step_gwtcb_commit_changed_file(ctx: Context, filename: str) -> None:
if hasattr(ctx, "gwtcb_run_git_patcher"):
ctx.gwtcb_run_git_patcher.stop()
if hasattr(ctx, "gwtcb_subprocess_patcher"):
ctx.gwtcb_subprocess_patcher.stop()
assert ctx.gwtcb_commit_result is not None, "Expected a commit result"
assert filename in ctx.gwtcb_commit_result.changed_files, (
f"Expected '{filename}' in changed_files, got {ctx.gwtcb_commit_result.changed_files}"
)
@then('the gwtcb commit result should include added file "{filename}"')
def step_gwtcb_commit_added_file(ctx: Context, filename: str) -> None:
assert ctx.gwtcb_commit_result is not None, "Expected a commit result"
assert filename in ctx.gwtcb_commit_result.added_files, (
f"Expected '{filename}' in added_files, got {ctx.gwtcb_commit_result.added_files}"
)
@then('the gwtcb commit result should include deleted file "{filename}"')
def step_gwtcb_commit_deleted_file(ctx: Context, filename: str) -> None:
assert ctx.gwtcb_commit_result is not None, "Expected a commit result"
assert filename in ctx.gwtcb_commit_result.deleted_files, (
f"Expected '{filename}' in deleted_files, got {ctx.gwtcb_commit_result.deleted_files}"
)
# ---------------------------------------------------------------------------
# commit: timeout and CalledProcessError
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox in ACTIVE state ready to commit")
def step_gwtcb_active_ready_commit(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
ctx.gwtcb_commit_result = None
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
ctx.gwtcb_sandbox._worktree_path = repo_dir
ctx.gwtcb_sandbox._branch_name = "cleveragents/plan-test"
ctx.gwtcb_sandbox._base_commit = "abc123"
ctx.gwtcb_sandbox._original_branch = "main"
@given("gwtcb _run_git is mocked to raise TimeoutExpired on commit")
def step_gwtcb_mock_commit_timeout(ctx: Context) -> None:
patcher = patch(
f"{_MODULE}._run_git",
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
)
ctx.gwtcb_patcher = patcher
patcher.start()
@when("gwtcb commit is called expecting a timeout error")
def step_gwtcb_commit_timeout(ctx: Context) -> None:
try:
ctx.gwtcb_commit_result = ctx.gwtcb_sandbox.commit("timeout commit")
except SandboxCommitError as exc:
ctx.gwtcb_error = exc
finally:
if hasattr(ctx, "gwtcb_patcher"):
ctx.gwtcb_patcher.stop()
@given("gwtcb _run_git is mocked to raise CalledProcessError on commit")
def step_gwtcb_mock_commit_process_error(ctx: Context) -> None:
patcher = patch(
f"{_MODULE}._run_git",
side_effect=subprocess.CalledProcessError(
returncode=1,
cmd="git commit",
stderr="commit failed",
),
)
ctx.gwtcb_patcher = patcher
patcher.start()
@when("gwtcb commit is called expecting a process error")
def step_gwtcb_commit_process_error(ctx: Context) -> None:
try:
ctx.gwtcb_commit_result = ctx.gwtcb_sandbox.commit("error commit")
except SandboxCommitError as exc:
ctx.gwtcb_error = exc
finally:
if hasattr(ctx, "gwtcb_patcher"):
ctx.gwtcb_patcher.stop()
# ---------------------------------------------------------------------------
# rollback: worktree not initialised
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox in ACTIVE state with no worktree or base commit")
def step_gwtcb_active_no_worktree_rollback(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
# Force ACTIVE with None worktree and base_commit to hit line 472-473
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
ctx.gwtcb_sandbox._worktree_path = None
ctx.gwtcb_sandbox._base_commit = None
@when("gwtcb rollback is called expecting a state error")
def step_gwtcb_rollback_state_error(ctx: Context) -> None:
try:
ctx.gwtcb_sandbox.rollback()
except SandboxStateError as exc:
ctx.gwtcb_error = exc
# ---------------------------------------------------------------------------
# rollback: timeout and CalledProcessError
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox in ACTIVE state ready to rollback")
def step_gwtcb_active_ready_rollback(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
ctx.gwtcb_sandbox._worktree_path = repo_dir
ctx.gwtcb_sandbox._base_commit = "abc123"
@given("gwtcb _run_git is mocked to raise TimeoutExpired on rollback")
def step_gwtcb_mock_rollback_timeout(ctx: Context) -> None:
patcher = patch(
f"{_MODULE}._run_git",
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
)
ctx.gwtcb_patcher = patcher
patcher.start()
@when("gwtcb rollback is called expecting a timeout error")
def step_gwtcb_rollback_timeout(ctx: Context) -> None:
try:
ctx.gwtcb_sandbox.rollback()
except SandboxRollbackError as exc:
ctx.gwtcb_error = exc
finally:
if hasattr(ctx, "gwtcb_patcher"):
ctx.gwtcb_patcher.stop()
@given("gwtcb _run_git is mocked to raise CalledProcessError on rollback")
def step_gwtcb_mock_rollback_process_error(ctx: Context) -> None:
patcher = patch(
f"{_MODULE}._run_git",
side_effect=subprocess.CalledProcessError(
returncode=1,
cmd="git reset",
stderr="rollback failed",
),
)
ctx.gwtcb_patcher = patcher
patcher.start()
@when("gwtcb rollback is called expecting a process error")
def step_gwtcb_rollback_process_error(ctx: Context) -> None:
try:
ctx.gwtcb_sandbox.rollback()
except SandboxRollbackError as exc:
ctx.gwtcb_error = exc
finally:
if hasattr(ctx, "gwtcb_patcher"):
ctx.gwtcb_patcher.stop()
# ---------------------------------------------------------------------------
# cleanup: worktree remove failure (fallback to shutil)
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox with a worktree directory that exists")
def step_gwtcb_cleanup_worktree_exists(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
# Create a real temp dir to act as the worktree path
worktree_dir = tempfile.mkdtemp(prefix="gwtcb-worktree-")
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
ctx.gwtcb_sandbox._worktree_path = worktree_dir
ctx.gwtcb_sandbox._branch_name = "cleveragents/plan-cleanup-test"
@given("gwtcb _run_git is mocked to fail on worktree remove")
def step_gwtcb_mock_worktree_remove_fail(ctx: Context) -> None:
call_count = {"n": 0}
def mock_run_git(args, cwd, timeout=10):
call_count["n"] += 1
if args and args[0] == "worktree" and "remove" in args:
raise subprocess.CalledProcessError(
returncode=1,
cmd="git worktree remove",
stderr="worktree remove failed",
)
# Let branch -D and worktree prune succeed
result = MagicMock()
result.stdout = ""
result.stderr = ""
return result
patcher = patch(f"{_MODULE}._run_git", side_effect=mock_run_git)
ctx.gwtcb_patcher = patcher
patcher.start()
@when("gwtcb cleanup is called")
def step_gwtcb_cleanup(ctx: Context) -> None:
try:
ctx.gwtcb_sandbox.cleanup()
except Exception as exc:
ctx.gwtcb_error = exc
finally:
if hasattr(ctx, "gwtcb_patcher"):
ctx.gwtcb_patcher.stop()
# ---------------------------------------------------------------------------
# cleanup: branch delete failure
# ---------------------------------------------------------------------------
@given("a gwtcb sandbox with a branch name set but no worktree directory")
def step_gwtcb_cleanup_no_worktree_dir(ctx: Context) -> None:
repo_dir = _init_test_repo()
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
ctx.gwtcb_error = None
# Set branch name but worktree_path to a non-existent path
# so the worktree removal block is skipped
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
ctx.gwtcb_sandbox._worktree_path = "/tmp/nonexistent-gwtcb-worktree"
ctx.gwtcb_sandbox._branch_name = "cleveragents/plan-branch-delete-fail"
@given("gwtcb _run_git is mocked to fail on branch delete")
def step_gwtcb_mock_branch_delete_fail(ctx: Context) -> None:
def mock_run_git(args, cwd, timeout=10):
if args and args[0] == "branch" and "-D" in args:
raise subprocess.CalledProcessError(
returncode=1,
cmd="git branch -D",
stderr="branch delete failed",
)
# Let worktree prune succeed
result = MagicMock()
result.stdout = ""
result.stderr = ""
return result
patcher = patch(f"{_MODULE}._run_git", side_effect=mock_run_git)
ctx.gwtcb_patcher = patcher
patcher.start()
# ---------------------------------------------------------------------------
# Then - common assertions
# ---------------------------------------------------------------------------
@then('the gwtcb sandbox should be in the "{status}" state')
def step_gwtcb_check_status(ctx: Context, status: str) -> None:
expected = SandboxStatus(status)
assert ctx.gwtcb_sandbox.status == expected, (
f"Expected status {expected}, got {ctx.gwtcb_sandbox.status}"
)
@then("a gwtcb SandboxCreationError should be raised")
def step_gwtcb_creation_error(ctx: Context) -> None:
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
assert isinstance(ctx.gwtcb_error, SandboxCreationError), (
f"Expected SandboxCreationError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
)
@then('a gwtcb SandboxCreationError should be raised with message "{msg}"')
def step_gwtcb_creation_error_msg(ctx: Context, msg: str) -> None:
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
assert isinstance(ctx.gwtcb_error, SandboxCreationError), (
f"Expected SandboxCreationError, got {type(ctx.gwtcb_error).__name__}"
)
assert msg in str(ctx.gwtcb_error), (
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
)
@then('a gwtcb SandboxStateError should be raised with message "{msg}"')
def step_gwtcb_state_error_msg(ctx: Context, msg: str) -> None:
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
assert isinstance(ctx.gwtcb_error, SandboxStateError), (
f"Expected SandboxStateError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
)
assert msg in str(ctx.gwtcb_error), (
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
)
@then('a gwtcb SandboxCommitError should be raised with message "{msg}"')
def step_gwtcb_commit_error_msg(ctx: Context, msg: str) -> None:
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
assert isinstance(ctx.gwtcb_error, SandboxCommitError), (
f"Expected SandboxCommitError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
)
assert msg in str(ctx.gwtcb_error), (
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
)
@then('a gwtcb SandboxRollbackError should be raised with message "{msg}"')
def step_gwtcb_rollback_error_msg(ctx: Context, msg: str) -> None:
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
assert isinstance(ctx.gwtcb_error, SandboxRollbackError), (
f"Expected SandboxRollbackError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
)
assert msg in str(ctx.gwtcb_error), (
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
)