"""Local worktree helpers for runtime-managed subagents.""" from __future__ import annotations import asyncio import os import shutil import sys import tempfile from pathlib import Path from typing import Any from opc.core.config import OPCConfig from opc.layer4_tools.execution_context import build_task_execution_context, venv_python_path async def create_worktree( base_path: str | None, *, config: OPCConfig | None = None, on_progress: Any = None, ) -> dict[str, Any]: source = Path(base_path or os.getcwd()).resolve() root = await _find_git_root(source) temp_dir = Path(tempfile.mkdtemp(prefix="opc-native-v2-")) if root is not None: proc = await asyncio.create_subprocess_exec( "git", "-C", str(root), "worktree", "add", "--detach", str(temp_dir), "HEAD", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() if proc.returncode == 0: info = { "path": str(temp_dir), "git_root": str(root), "mode": "git_worktree", "stdout": stdout.decode("utf-8", errors="replace"), } return await _prepare_execution_environment(info, config=config, on_progress=on_progress) shutil.rmtree(temp_dir, ignore_errors=True) temp_dir = Path(tempfile.mkdtemp(prefix="opc-native-v2-copy-")) shutil.copytree( source, temp_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".git", ".venv", "__pycache__", ".pytest_cache"), ) info = { "path": str(temp_dir), "git_root": str(root) if root else "", "mode": "copy", } return await _prepare_execution_environment(info, config=config, on_progress=on_progress) async def cleanup_worktree(info: dict[str, Any] | None) -> None: if not info: return path = Path(str(info.get("path", "") or "")).resolve() mode = str(info.get("mode", "") or "") git_root = str(info.get("git_root", "") or "") if not path.exists(): return if mode == "git_worktree" and git_root: proc = await asyncio.create_subprocess_exec( "git", "-C", git_root, "worktree", "remove", "--force", str(path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) await proc.communicate() return shutil.rmtree(path, ignore_errors=True) async def _find_git_root(path: Path) -> Path | None: current = path if current.is_file(): current = current.parent for candidate in [current, *current.parents]: if (candidate / ".git").exists(): return candidate return None async def _prepare_execution_environment( info: dict[str, Any], *, config: OPCConfig | None = None, on_progress: Any = None, ) -> dict[str, Any]: workspace = Path(str(info.get("path", "") or "")).resolve() info["execution_context"] = build_task_execution_context(workspace_root=workspace, config=config) if config is None: return info venv_cfg = config.system.native_runtime.execution_environment.worktree_venv if not venv_cfg.enabled: return info provider = _resolve_venv_provider(venv_cfg.provider) venv_path = workspace / str(venv_cfg.venv_dir or ".opc-venv") python_path = venv_python_path(venv_path) try: if not python_path.exists(): create_args = _venv_create_args(provider, venv_path, system_site_packages=venv_cfg.system_site_packages) await _run_process(create_args, cwd=workspace, on_progress=on_progress) sync_commands = _build_sync_commands( workspace=workspace, python_path=python_path, provider=provider, editable_project=venv_cfg.editable_project, requirements_files=_detect_requirements_files( workspace=workspace, configured=venv_cfg.requirements_files, auto_detect=venv_cfg.auto_detect_requirements, ), ) for command in sync_commands: await _run_process(command, cwd=workspace, on_progress=on_progress) info.update( { "venv_path": str(venv_path), "python_executable": str(python_path), "venv_provider": provider, "environment_prepared": True, "environment_sync_commands": len(sync_commands), } ) info["execution_context"] = build_task_execution_context( workspace_root=workspace, config=config, venv_path=venv_path, python_executable=python_path, venv_provider=provider, ) except Exception as exc: error = str(exc) info.update( { "venv_path": str(venv_path), "python_executable": str(python_path), "venv_provider": provider, "environment_prepared": False, "environment_error": error, } ) info["execution_context"] = build_task_execution_context( workspace_root=workspace, config=config, venv_path=venv_path, python_executable=python_path, venv_provider=provider, preparation_error=error, ) if venv_cfg.fail_if_prepare_fails: raise return info def _resolve_venv_provider(provider: str) -> str: normalized = str(provider or "auto").strip().lower() or "auto" if normalized == "uv": return "uv" if normalized == "venv": return "venv" return "uv" if shutil.which("uv") else "venv" def _venv_create_args(provider: str, venv_path: Path, *, system_site_packages: bool) -> list[str]: if provider == "uv": return ["uv", "venv", str(venv_path), "--python", sys.executable] args = [sys.executable, "-m", "venv", str(venv_path)] if system_site_packages: args.append("--system-site-packages") return args def _detect_requirements_files( *, workspace: Path, configured: list[str], auto_detect: bool, ) -> list[Path]: candidates: list[Path] = [] for entry in configured: text = str(entry or "").strip() if text: candidates.append((workspace / text).resolve()) if auto_detect: for name in ("requirements.txt", "requirements-dev.txt", "requirements-test.txt", "requirements-ci.txt"): candidate = (workspace / name).resolve() if candidate.exists(): candidates.append(candidate) seen: set[str] = set() resolved: list[Path] = [] for item in candidates: key = str(item) if key in seen or not item.exists(): continue seen.add(key) resolved.append(item) return resolved def _build_sync_commands( *, workspace: Path, python_path: Path, provider: str, editable_project: bool, requirements_files: list[Path], ) -> list[list[str]]: commands: list[list[str]] = [] if editable_project and (workspace / "pyproject.toml").exists(): if provider == "uv" and shutil.which("uv"): commands.append(["uv", "pip", "install", "--python", str(python_path), "-e", str(workspace)]) else: commands.append([str(python_path), "-m", "pip", "install", "-e", str(workspace)]) for item in requirements_files: if provider == "uv" and shutil.which("uv"): commands.append(["uv", "pip", "install", "--python", str(python_path), "-r", str(item)]) else: commands.append([str(python_path), "-m", "pip", "install", "-r", str(item)]) return commands async def _run_process(args: list[str], *, cwd: Path, on_progress: Any = None) -> None: if on_progress: try: await on_progress(f"[worktree-env] {' '.join(args[:4])}") except TypeError: await on_progress(f"[worktree-env] {' '.join(args[:4])}") proc = await asyncio.create_subprocess_exec( *args, cwd=str(cwd), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() if proc.returncode == 0: return stdout_text = stdout.decode("utf-8", errors="replace").strip() stderr_text = stderr.decode("utf-8", errors="replace").strip() detail = stderr_text or stdout_text or f"exit code {proc.returncode}" raise RuntimeError(f"{' '.join(args[:4])} failed: {detail}")