"""Steps for multi_project_sandbox.feature.""" from __future__ import annotations import os import shutil import subprocess import tempfile from io import StringIO from pathlib import Path from unittest.mock import MagicMock, patch from behave import given, then, when _PLAN_ID = "01TESTMULTIPROJ000000000000" def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["git", *args], cwd=cwd, capture_output=True, text=True, check=True, timeout=10, ) def _init_git_repo(path: str) -> None: _git(["init", "-q", "-b", "main"], path) _git(["config", "user.name", "T"], path) _git(["config", "user.email", "t@t"], path) _git(["config", "commit.gpgsign", "false"], path) # ── Given ────────────────────────────────────────────── @given('a temp git project "{name}" for mps') def step_create_project(context: object, name: str) -> None: if not hasattr(context, "mps_projects"): context.mps_projects = {} d = tempfile.mkdtemp(prefix=f"mps-{name}-") context.add_cleanup(shutil.rmtree, d, True) _init_git_repo(d) Path(d, "README.md").write_text(f"# {name}\n") _git(["add", "."], d) _git(["commit", "-q", "-m", "init"], d) context.mps_projects[name] = d @given('a temp git project named "{name}" containing "{filename}" for mps') def step_create_project_with_file(context: object, name: str, filename: str) -> None: if not hasattr(context, "mps_projects"): context.mps_projects = {} d = tempfile.mkdtemp(prefix=f"mps-{name}-") context.add_cleanup(shutil.rmtree, d, True) _init_git_repo(d) fpath = os.path.join(d, filename) os.makedirs(os.path.dirname(fpath), exist_ok=True) Path(fpath).write_text(f"# {name} {filename}\n") _git(["add", "."], d) _git(["commit", "-q", "-m", "init"], d) context.mps_projects[name] = d @given('a temp git project "{name}" with a worktree branch for mps') def step_create_project_with_worktree(context: object, name: str) -> None: if not hasattr(context, "mps_projects"): context.mps_projects = {} d = tempfile.mkdtemp(prefix=f"mps-{name}-") context.add_cleanup(shutil.rmtree, d, True) _init_git_repo(d) Path(d, f"{name}.py").write_text(f"# original {name}\n") _git(["add", "."], d) _git(["commit", "-q", "-m", "init"], d) branch = f"cleveragents/plan-{_PLAN_ID}" wt_dir = tempfile.mkdtemp(prefix=f"mps-wt-{name}-") context.add_cleanup(shutil.rmtree, wt_dir, True) _git(["worktree", "add", "-b", branch, wt_dir, "HEAD"], d) Path(wt_dir, f"{name}.py").write_text(f"# fixed {name}\n") _git(["add", "."], wt_dir) _git(["commit", "-q", "-m", f"fix {name}"], wt_dir) context.mps_projects[name] = d @given('a temp git project "{name}" with a conflicting worktree branch for mps') def step_create_project_with_conflict(context: object, name: str) -> None: if not hasattr(context, "mps_projects"): context.mps_projects = {} d = tempfile.mkdtemp(prefix=f"mps-{name}-") context.add_cleanup(shutil.rmtree, d, True) _init_git_repo(d) Path(d, f"{name}.py").write_text(f"# original {name}\n") _git(["add", "."], d) _git(["commit", "-q", "-m", "init"], d) branch = f"cleveragents/plan-{_PLAN_ID}" wt_dir = tempfile.mkdtemp(prefix=f"mps-wt-{name}-") context.add_cleanup(shutil.rmtree, wt_dir, True) _git(["worktree", "add", "-b", branch, wt_dir, "HEAD"], d) Path(wt_dir, f"{name}.py").write_text(f"# branch {name}\n") _git(["add", "."], wt_dir) _git(["commit", "-q", "-m", f"branch {name}"], wt_dir) # Create conflict on main Path(d, f"{name}.py").write_text(f"# main {name}\n") _git(["add", "."], d) _git(["commit", "-q", "-m", f"main {name}"], d) context.mps_projects[name] = d def _build_mocks(context: object, project_names: list[str]) -> tuple: """Build mock service + container for the given projects.""" links = [] resources = {} for name in project_names: rid = f"res-mps-{name}" mock_lr = MagicMock() mock_lr.resource_id = rid links.append((name, mock_lr)) mock_resource = MagicMock() mock_resource.resource_type_name = "git-checkout" mock_resource.location = context.mps_projects[name] mock_resource.resource_id = rid resources[rid] = mock_resource mock_plan = MagicMock() mock_plan.project_links = [MagicMock(project_name=name) for name, _ in links] mock_service = MagicMock() mock_service.get_plan.return_value = mock_plan mock_projects = {} for name, lr in links: mock_proj = MagicMock() mock_proj.linked_resources = [lr] mock_projects[name] = mock_proj mock_project_repo = MagicMock() mock_project_repo.get.side_effect = lambda n: mock_projects.get(n) mock_resource_registry = MagicMock() mock_resource_registry.show_resource.side_effect = lambda rid: resources[rid] mock_container = MagicMock() mock_container.namespaced_project_repo.return_value = mock_project_repo mock_container.resource_registry_service.return_value = mock_resource_registry context.mps_service = mock_service context.mps_container = mock_container return mock_service, mock_container @given('a mocked plan service linking project "{name}" for mps') def step_mock_single(context: object, name: str) -> None: _build_mocks(context, [name]) @given('a mocked plan service linking projects "{a}" and "{b}" for mps') def step_mock_multi(context: object, a: str, b: str) -> None: _build_mocks(context, [a, b]) @given("sandbox_infos for both projects for mps") def step_create_sandbox_infos(context: object) -> None: from cleveragents.cli.commands.plan import _create_sandbox_for_plan with patch( "cleveragents.application.container.get_container", return_value=context.mps_container, ): context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan( _PLAN_ID, context.mps_service ) for info in context.mps_sandbox_infos: context.add_cleanup(info.sandbox_obj.cleanup) @given("sandbox_infos with only one entry for mps") def step_create_single_sandbox_info(context: object) -> None: names = list(context.mps_projects.keys()) _build_mocks(context, [names[0]]) from cleveragents.cli.commands.plan import _create_sandbox_for_plan with patch( "cleveragents.application.container.get_container", return_value=context.mps_container, ): context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan( _PLAN_ID, context.mps_service ) for info in context.mps_sandbox_infos: context.add_cleanup(info.sandbox_obj.cleanup) @given('a file "{filename}" exists in the primary sandbox for mps') def step_write_file_to_primary(context: object, filename: str) -> None: primary = context.mps_sandbox_infos[0] fpath = os.path.join(primary.sandbox_path, filename) os.makedirs(os.path.dirname(fpath), exist_ok=True) Path(fpath).write_text("# routed content\n") # ── When ─────────────────────────────────────────────── @when("I call _create_sandbox_for_plan for mps") def step_call_create(context: object) -> None: from cleveragents.cli.commands.plan import _create_sandbox_for_plan with patch( "cleveragents.application.container.get_container", return_value=context.mps_container, ): context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan( _PLAN_ID, context.mps_service ) for info in context.mps_sandbox_infos: context.add_cleanup(info.sandbox_obj.cleanup) @when("I call _route_sandbox_files_to_worktrees for mps") def step_call_route(context: object) -> None: from cleveragents.cli.commands.plan import _route_sandbox_files_to_worktrees _route_sandbox_files_to_worktrees(context.mps_sandbox_infos) @when("I call _apply_sandbox_changes for mps") def step_call_apply(context: object) -> None: from rich.console import Console from cleveragents.cli.commands.plan import _apply_sandbox_changes output = StringIO() console = Console(file=output, width=200) with patch( "cleveragents.application.container.get_container", return_value=context.mps_container, ): context.mps_apply_result = _apply_sandbox_changes( _PLAN_ID, context.mps_service, console, ) context.mps_console_output = output.getvalue() # ── Then ─────────────────────────────────────────────── @then("sandbox_infos should have {count:d} entry for mps") @then("sandbox_infos should have {count:d} entries for mps") def step_check_count(context: object, count: int) -> None: assert len(context.mps_sandbox_infos) == count, ( f"Expected {count} sandbox_infos, got {len(context.mps_sandbox_infos)}" ) @then("sandbox_root should be a directory for mps") def step_check_root_dir(context: object) -> None: assert os.path.isdir(context.mps_sandbox_root), ( f"sandbox_root is not a directory: {context.mps_sandbox_root}" ) @then("each sandbox_info should have a different sandbox_path for mps") def step_check_unique_paths(context: object) -> None: paths = [info.sandbox_path for info in context.mps_sandbox_infos] assert len(paths) == len(set(paths)), f"Duplicate sandbox paths: {paths}" @then('"{filename}" should exist in the beta sandbox for mps') def step_file_in_beta(context: object, filename: str) -> None: beta_info = context.mps_sandbox_infos[1] fpath = os.path.join(beta_info.sandbox_path, filename) assert os.path.isfile(fpath), f"{filename} not found in beta sandbox" @then('"{filename}" should not exist in the alpha sandbox for mps') def step_file_not_in_alpha(context: object, filename: str) -> None: alpha_info = context.mps_sandbox_infos[0] fpath = os.path.join(alpha_info.sandbox_path, filename) assert not os.path.isfile(fpath), f"{filename} still in alpha sandbox" @then('"{filename}" should still exist in the alpha sandbox for mps') def step_file_still_in_alpha(context: object, filename: str) -> None: alpha_info = context.mps_sandbox_infos[0] fpath = os.path.join(alpha_info.sandbox_path, filename) assert os.path.isfile(fpath), f"{filename} not found in alpha sandbox" @then("both projects should have the merged changes for mps") def step_both_merged(context: object) -> None: for name, path in context.mps_projects.items(): content = Path(path, f"{name}.py").read_text() assert "fixed" in content, f"Project {name} not merged: {content}" @then("alpha should have the merged changes for mps") def step_alpha_merged(context: object) -> None: path = context.mps_projects["alpha"] content = Path(path, "alpha.py").read_text() assert "fixed" in content, f"Alpha not merged: {content}" @given( 'the file "{filename}" in the primary sandbox is overwritten with ' '"{content}" for mps' ) def step_overwrite_primary_file(context: object, filename: str, content: str) -> None: primary = context.mps_sandbox_infos[0] fpath = os.path.join(primary.sandbox_path, filename) Path(fpath).write_text(content + "\n") @then('"{filename}" in the alpha sandbox should contain "{text}" for mps') def step_alpha_file_contains(context: object, filename: str, text: str) -> None: alpha_info = context.mps_sandbox_infos[0] content = Path(alpha_info.sandbox_path, filename).read_text() assert text in content, ( f"Expected '{text}' in alpha's {filename} but got: {content}" ) @then('"{filename}" in the beta sandbox should not contain "{text}" for mps') def step_beta_file_not_contains(context: object, filename: str, text: str) -> None: beta_info = context.mps_sandbox_infos[1] fpath = os.path.join(beta_info.sandbox_path, filename) if not os.path.isfile(fpath): return # File doesn't exist in beta — that's fine content = Path(fpath).read_text() assert text not in content, ( f"'{text}' should not be in beta's {filename} but found: {content}" ) @then('"{filename}" should not exist in the beta sandbox for mps') def step_file_not_in_beta(context: object, filename: str) -> None: beta_info = context.mps_sandbox_infos[1] fpath = os.path.join(beta_info.sandbox_path, filename) assert not os.path.isfile(fpath), f"{filename} found in beta sandbox" @then('the console output should contain "Apply Summary" for mps') def step_console_has_apply_summary(context: object) -> None: output = context.mps_console_output assert "Apply Summary" in output, ( f"Expected 'Apply Summary' in console output but got:\n{output[:500]}" ) @then("beta should have the original content for mps") def step_beta_unchanged(context: object) -> None: path = context.mps_projects["beta"] content = Path(path, "beta.py").read_text() assert "original" in content or "main" in content, ( f"Expected beta to be unchanged but got: {content}" ) @then("_apply_sandbox_changes should return False for mps") def step_apply_returns_false(context: object) -> None: assert context.mps_apply_result is False, ( f"Expected False but got {context.mps_apply_result}" )