"""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}" )