forked from HAL9000/cleveragents-core
cc45b5ff2c
Implement GitWorktreeSandbox (worktree creation, commit+merge, rollback, cleanup, branch sanitization, git timeouts) and CopyOnWriteSandbox (directory copy, diff-based commit, rollback, cleanup). Update SandboxFactory to wire new strategies, remove stale overlay/ versioning constants, add snapshot placeholder, align resource type mapping to spec types (git-checkout, fs-mount, fs-directory, fs-file). Add 40 Behave scenarios (19 git_worktree + 21 copy_on_write) with gwt/cow prefixed steps. Update existing sandbox_factory_coverage feature (21 scenarios) to reflect new factory behavior. All quality gates pass: ruff, pyright, 2325 scenarios, 97% coverage. TASK-006 / Stage B4
405 lines
13 KiB
Python
405 lines
13 KiB
Python
"""Step definitions for git worktree sandbox feature.
|
|
|
|
All steps use the ``gwt`` prefix to avoid collisions with the 128+ existing
|
|
step files loaded globally by Behave.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
|
|
from cleveragents.infrastructure.sandbox.protocol import (
|
|
SandboxCreationError,
|
|
SandboxStateError,
|
|
SandboxStatus,
|
|
)
|
|
|
|
|
|
def _init_test_repo(ctx: Context) -> str:
|
|
"""Create a temporary git repo with an initial commit."""
|
|
repo_dir = tempfile.mkdtemp(prefix="gwt-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,
|
|
)
|
|
# Create initial files
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background / Given
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a gwt test git repository is initialised")
|
|
def step_gwt_init_repo(ctx: Context) -> None:
|
|
ctx.gwt_repo_dir = _init_test_repo(ctx)
|
|
ctx.gwt_sandbox = None
|
|
ctx.gwt_error = None
|
|
ctx.gwt_commit_result = None
|
|
ctx.gwt_resolved_path = None
|
|
|
|
|
|
@given("a gwt non-git directory")
|
|
def step_gwt_non_git_dir(ctx: Context) -> None:
|
|
ctx.gwt_non_git_dir = tempfile.mkdtemp(prefix="gwt-non-git-")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When - instantiation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("a gwt sandbox is instantiated")
|
|
def step_gwt_instantiate(ctx: Context) -> None:
|
|
ctx.gwt_sandbox = GitWorktreeSandbox(
|
|
resource_id="res-001",
|
|
original_path=ctx.gwt_repo_dir,
|
|
)
|
|
|
|
|
|
@when("a gwt sandbox is prepared with empty resource_id")
|
|
def step_gwt_empty_resource_id(ctx: Context) -> None:
|
|
try:
|
|
GitWorktreeSandbox(resource_id="", original_path=ctx.gwt_repo_dir)
|
|
except ValueError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when("a gwt sandbox is prepared with empty original_path")
|
|
def step_gwt_empty_original_path(ctx: Context) -> None:
|
|
try:
|
|
GitWorktreeSandbox(resource_id="res-001", original_path="")
|
|
except ValueError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when("a gwt sandbox is prepared with zero timeout")
|
|
def step_gwt_zero_timeout(ctx: Context) -> None:
|
|
try:
|
|
GitWorktreeSandbox(
|
|
resource_id="res-001",
|
|
original_path=ctx.gwt_repo_dir,
|
|
git_timeout=0,
|
|
)
|
|
except ValueError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When - create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('a gwt sandbox is created for plan "{plan_id}"')
|
|
def step_gwt_create(ctx: Context, plan_id: str) -> None:
|
|
ctx.gwt_sandbox = GitWorktreeSandbox(
|
|
resource_id="res-001",
|
|
original_path=ctx.gwt_repo_dir,
|
|
)
|
|
ctx.gwt_sandbox.create(plan_id)
|
|
|
|
|
|
@when("a gwt sandbox is created with empty plan_id")
|
|
def step_gwt_create_empty_plan(ctx: Context) -> None:
|
|
ctx.gwt_sandbox = GitWorktreeSandbox(
|
|
resource_id="res-001",
|
|
original_path=ctx.gwt_repo_dir,
|
|
)
|
|
try:
|
|
ctx.gwt_sandbox.create("")
|
|
except ValueError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when('a gwt sandbox is created on the non-git directory for plan "{plan_id}"')
|
|
def step_gwt_create_non_git(ctx: Context, plan_id: str) -> None:
|
|
ctx.gwt_sandbox = GitWorktreeSandbox(
|
|
resource_id="res-001",
|
|
original_path=ctx.gwt_non_git_dir,
|
|
)
|
|
try:
|
|
ctx.gwt_sandbox.create(plan_id)
|
|
except SandboxCreationError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When - path resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the gwt path "{path}" is resolved')
|
|
def step_gwt_resolve_path(ctx: Context, path: str) -> None:
|
|
try:
|
|
ctx.gwt_resolved_path = ctx.gwt_sandbox.get_path(path)
|
|
except (ValueError, SandboxStateError) as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when('the gwt path "{path}" is resolved on a cleaned-up sandbox')
|
|
def step_gwt_resolve_path_cleaned(ctx: Context, path: str) -> None:
|
|
try:
|
|
ctx.gwt_sandbox.get_path(path)
|
|
except SandboxStateError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When - file operations in worktree
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('a gwt file "{filename}" is created in the worktree with content "{content}"')
|
|
def step_gwt_create_file(ctx: Context, filename: str, content: str) -> None:
|
|
worktree_path = ctx.gwt_sandbox.context.sandbox_path
|
|
file_path = os.path.join(worktree_path, filename)
|
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
with open(file_path, "w") as f:
|
|
f.write(content)
|
|
|
|
|
|
@when('the gwt existing file "{filename}" is modified in the worktree')
|
|
def step_gwt_modify_file(ctx: Context, filename: str) -> None:
|
|
worktree_path = ctx.gwt_sandbox.context.sandbox_path
|
|
file_path = os.path.join(worktree_path, filename)
|
|
with open(file_path, "a") as f:
|
|
f.write("\n# Modified by sandbox\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When - commit / rollback / cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the gwt sandbox is committed with message "{message}"')
|
|
def step_gwt_commit(ctx: Context, message: str) -> None:
|
|
try:
|
|
ctx.gwt_commit_result = ctx.gwt_sandbox.commit(message)
|
|
except (SandboxStateError, Exception) as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when("the gwt sandbox commit is attempted on cleaned-up sandbox")
|
|
def step_gwt_commit_cleaned(ctx: Context) -> None:
|
|
try:
|
|
ctx.gwt_sandbox.commit("should fail")
|
|
except SandboxStateError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when("the gwt sandbox is rolled back")
|
|
def step_gwt_rollback(ctx: Context) -> None:
|
|
try:
|
|
ctx.gwt_sandbox.rollback()
|
|
except (SandboxStateError, Exception) as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when("the gwt sandbox rollback is attempted on created sandbox")
|
|
def step_gwt_rollback_created(ctx: Context) -> None:
|
|
try:
|
|
ctx.gwt_sandbox.rollback()
|
|
except SandboxStateError as exc:
|
|
ctx.gwt_error = exc
|
|
|
|
|
|
@when("the gwt sandbox is cleaned up")
|
|
def step_gwt_cleanup(ctx: Context) -> None:
|
|
ctx.gwt_sandbox.cleanup()
|
|
|
|
|
|
@when("the gwt sandbox is cleaned up again")
|
|
def step_gwt_cleanup_again(ctx: Context) -> None:
|
|
ctx.gwt_sandbox.cleanup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then - status / state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the gwt sandbox should be in the "{status}" state')
|
|
def step_gwt_check_status(ctx: Context, status: str) -> None:
|
|
expected = SandboxStatus(status)
|
|
assert ctx.gwt_sandbox.status == expected, (
|
|
f"Expected status {expected}, got {ctx.gwt_sandbox.status}"
|
|
)
|
|
|
|
|
|
@then('the gwt sandbox context should reference plan "{plan_id}"')
|
|
def step_gwt_check_plan(ctx: Context, plan_id: str) -> None:
|
|
assert ctx.gwt_sandbox.context is not None
|
|
assert ctx.gwt_sandbox.context.plan_id == plan_id
|
|
|
|
|
|
@then('the gwt sandbox context should have strategy metadata "{strategy}"')
|
|
def step_gwt_check_strategy(ctx: Context, strategy: str) -> None:
|
|
assert ctx.gwt_sandbox.context is not None
|
|
assert ctx.gwt_sandbox.context.metadata.get("strategy") == strategy
|
|
|
|
|
|
@then("the gwt sandbox worktree path should exist")
|
|
def step_gwt_worktree_exists(ctx: Context) -> None:
|
|
assert ctx.gwt_sandbox.context is not None
|
|
assert os.path.isdir(ctx.gwt_sandbox.context.sandbox_path)
|
|
|
|
|
|
@then("the gwt sandbox worktree path should not exist")
|
|
def step_gwt_worktree_not_exists(ctx: Context) -> None:
|
|
if ctx.gwt_sandbox.context is not None:
|
|
assert not os.path.exists(ctx.gwt_sandbox.context.sandbox_path)
|
|
|
|
|
|
@then("the gwt sandbox branch should not exist")
|
|
def step_gwt_branch_not_exists(ctx: Context) -> None:
|
|
result = subprocess.run(
|
|
["git", "branch", "--list"],
|
|
cwd=ctx.gwt_repo_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
branch_name = ctx.gwt_sandbox._branch_name
|
|
if branch_name:
|
|
assert branch_name not in result.stdout
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then - errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('a gwt ValueError should be raised with message "{msg}"')
|
|
def step_gwt_valueerror(ctx: Context, msg: str) -> None:
|
|
assert ctx.gwt_error is not None, "Expected an error but none occurred"
|
|
assert isinstance(ctx.gwt_error, ValueError), (
|
|
f"Expected ValueError, got {type(ctx.gwt_error).__name__}"
|
|
)
|
|
assert msg in str(ctx.gwt_error), (
|
|
f"Expected '{msg}' in error message, got: {ctx.gwt_error}"
|
|
)
|
|
|
|
|
|
@then("a gwt SandboxCreationError should be raised")
|
|
def step_gwt_creation_error(ctx: Context) -> None:
|
|
assert ctx.gwt_error is not None
|
|
assert isinstance(ctx.gwt_error, SandboxCreationError)
|
|
|
|
|
|
@then("a gwt SandboxStateError should be raised")
|
|
def step_gwt_state_error(ctx: Context) -> None:
|
|
assert ctx.gwt_error is not None
|
|
assert isinstance(ctx.gwt_error, SandboxStateError)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then - path resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the gwt resolved path should be inside the worktree")
|
|
def step_gwt_path_in_worktree(ctx: Context) -> None:
|
|
assert ctx.gwt_resolved_path is not None
|
|
sandbox_path = ctx.gwt_sandbox.context.sandbox_path
|
|
assert ctx.gwt_resolved_path.startswith(sandbox_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then - commit results
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the gwt commit result should indicate success")
|
|
def step_gwt_commit_success(ctx: Context) -> None:
|
|
assert ctx.gwt_commit_result is not None
|
|
assert ctx.gwt_commit_result.success is True
|
|
|
|
|
|
@then("the gwt commit result should have {count:d} changed files")
|
|
def step_gwt_commit_changed(ctx: Context, count: int) -> None:
|
|
assert ctx.gwt_commit_result is not None
|
|
assert len(ctx.gwt_commit_result.changed_files) == count
|
|
|
|
|
|
@then("the gwt commit result should have {count:d} added files")
|
|
def step_gwt_commit_added(ctx: Context, count: int) -> None:
|
|
assert ctx.gwt_commit_result is not None
|
|
assert len(ctx.gwt_commit_result.added_files) == count
|
|
|
|
|
|
@then('the gwt file "{filename}" should exist in the original repo')
|
|
def step_gwt_file_in_original(ctx: Context, filename: str) -> None:
|
|
file_path = os.path.join(ctx.gwt_repo_dir, filename)
|
|
assert os.path.exists(file_path), f"File {filename} not found in original repo"
|
|
|
|
|
|
@then('the gwt file "{filename}" should not exist in the worktree')
|
|
def step_gwt_file_not_in_worktree(ctx: Context, filename: str) -> None:
|
|
if ctx.gwt_sandbox.context:
|
|
file_path = os.path.join(ctx.gwt_sandbox.context.sandbox_path, filename)
|
|
assert not os.path.exists(file_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then - protocol properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the gwt sandbox_id should be a valid ULID")
|
|
def step_gwt_valid_ulid(ctx: Context) -> None:
|
|
sid = ctx.gwt_sandbox.sandbox_id
|
|
assert len(sid) == 26, f"Expected ULID length 26, got {len(sid)}"
|
|
|
|
|
|
@then('the gwt sandbox status should be "{status}"')
|
|
def step_gwt_status_is(ctx: Context, status: str) -> None:
|
|
assert ctx.gwt_sandbox.status == SandboxStatus(status)
|
|
|
|
|
|
@then("the gwt sandbox context should be None")
|
|
def step_gwt_context_none(ctx: Context) -> None:
|
|
assert ctx.gwt_sandbox.context is None
|
|
|
|
|
|
@then("the gwt sandbox branch name should be safe for git")
|
|
def step_gwt_branch_safe(ctx: Context) -> None:
|
|
branch = ctx.gwt_sandbox._branch_name
|
|
assert branch is not None
|
|
# Branch name should not contain spaces or special chars
|
|
assert " " not in branch
|
|
assert "!" not in branch
|
|
assert "@" not in branch
|
|
assert "#" not in branch
|