diff --git a/apps/backend/core/workspace/dependency_strategy.py b/apps/backend/core/workspace/dependency_strategy.py index 4b9f601453..0510ec153c 100644 --- a/apps/backend/core/workspace/dependency_strategy.py +++ b/apps/backend/core/workspace/dependency_strategy.py @@ -9,9 +9,10 @@ - **node_modules**: Safe to symlink. Node's resolution algorithm follows symlinks correctly, and the directory is self-contained. -- **venv / .venv**: Must be recreated. Python's ``pyvenv.cfg`` discovery walks the - real directory hierarchy without resolving symlinks (CPython bug #106045), so a - symlinked venv resolves paths relative to the *target*, not the worktree. +- **venv / .venv**: Symlinked for fast worktree creation. CPython bug #106045 + (pyvenv.cfg symlink resolution) does not affect typical usage (running scripts, + imports, pip). A health check after symlinking verifies usability; if it fails, + the caller falls back to recreating the venv. - **vendor (PHP)**: Safe to symlink. Composer's autoloader uses ``__DIR__``-relative paths that resolve correctly through symlinks. @@ -42,9 +43,9 @@ DEFAULT_STRATEGY_MAP: dict[str, DependencyStrategy] = { # JavaScript / Node.js — symlink is safe and fast "node_modules": DependencyStrategy.SYMLINK, - # Python — venvs MUST be recreated (pyvenv.cfg symlink bug) - "venv": DependencyStrategy.RECREATE, - ".venv": DependencyStrategy.RECREATE, + # Python — symlink for fast worktree creation (health check + fallback to recreate) + "venv": DependencyStrategy.SYMLINK, + ".venv": DependencyStrategy.SYMLINK, # PHP — Composer vendor dir is safe to symlink "vendor_php": DependencyStrategy.SYMLINK, # Ruby — Bundler vendor/bundle is safe to symlink diff --git a/apps/backend/core/workspace/models.py b/apps/backend/core/workspace/models.py index b9092012ca..568cbd3cf4 100644 --- a/apps/backend/core/workspace/models.py +++ b/apps/backend/core/workspace/models.py @@ -278,12 +278,11 @@ def _scan_specs_dir(self, specs_dir: Path) -> int: class DependencyStrategy(Enum): """Strategy for sharing dependency directories across worktrees. - SYMLINK is fast but unsafe for certain ecosystems. Notably, Python venv - breaks when symlinked because CPython's pyvenv.cfg discovery walks the - real directory hierarchy without resolving symlinks first - (CPython bug #106045). This means a symlinked venv resolves its home - path relative to the symlink target's parent, not the worktree, causing - import failures and broken interpreters. + SYMLINK is fast and now safe for Python venvs with runtime health checks. + A post-symlink health check validates the venv is usable, automatically + falling back to RECREATE if the symlink is broken. This works around + CPython's pyvenv.cfg discovery issue (CPython bug #106045) while maintaining + fast worktree creation in the common case where symlinking succeeds. """ SYMLINK = "symlink" # Create a symlink to the source (fast, works for node_modules) diff --git a/apps/backend/core/workspace/setup.py b/apps/backend/core/workspace/setup.py index b3ed57da36..cb05322db9 100644 --- a/apps/backend/core/workspace/setup.py +++ b/apps/backend/core/workspace/setup.py @@ -50,6 +50,10 @@ def debug_warning(*args, **kwargs): MODULE = "workspace.setup" +# Marker file written inside a recreated venv to indicate setup completed successfully. +# If the marker is absent, the venv is treated as incomplete and will be rebuilt. +VENV_SETUP_COMPLETE_MARKER = ".setup_complete" + def choose_workspace( project_dir: Path, @@ -624,9 +628,52 @@ def setup_worktree_dependencies( results[strategy_name] = [] try: - performed = True + performed = False if config.strategy == DependencyStrategy.SYMLINK: performed = _apply_symlink_strategy(project_dir, worktree_path, config) + # For venvs, verify the symlink is usable — fall back to recreate + # Run health check whenever a venv symlink exists (not just on creation) + if config.dep_type in ("venv", ".venv"): + venv_path = worktree_path / config.source_rel_path + # Check if venv exists (symlinked or otherwise) + if venv_path.exists() or venv_path.is_symlink(): + if is_windows(): + python_bin = str(venv_path / "Scripts" / "python.exe") + else: + python_bin = str(venv_path / "bin" / "python") + try: + subprocess.run( + [python_bin, "-c", "import sys; print(sys.prefix)"], + capture_output=True, + text=True, + timeout=10, + check=True, + ) + debug( + MODULE, + f"Symlinked venv health check passed: {config.source_rel_path}", + ) + except (subprocess.SubprocessError, OSError): + debug_warning( + MODULE, + f"Symlinked venv health check failed, falling back to recreate: {config.source_rel_path}", + ) + # Remove the broken symlink and recreate + try: + if venv_path.is_symlink(): + venv_path.unlink() + elif venv_path.exists(): + shutil.rmtree(venv_path, ignore_errors=True) + except OSError: + pass # Best-effort removal; recreate strategy handles existing paths + performed = _apply_recreate_strategy( + project_dir, worktree_path, config + ) + # Update strategy name to reflect fallback + if performed: + strategy_name = "recreate" + # Ensure the key exists for the fallback strategy + results.setdefault(strategy_name, []) elif config.strategy == DependencyStrategy.RECREATE: performed = _apply_recreate_strategy(project_dir, worktree_path, config) elif config.strategy == DependencyStrategy.COPY: @@ -707,6 +754,54 @@ def _apply_symlink_strategy( return False +def _popen_with_cleanup( + cmd: list[str], + timeout: int, + label: str, +) -> tuple[int, str, str]: + """Run a command via Popen with proper process cleanup on timeout. + + On timeout: terminate → wait(10) → kill → wait(5) to ensure file locks + are released before any cleanup (e.g. shutil.rmtree). + + Returns (returncode, stdout, stderr). + Raises subprocess.TimeoutExpired if the command exceeds the given timeout (after cleanup is attempted). + """ + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout) + return proc.returncode, stdout, stderr + except subprocess.TimeoutExpired: + debug_warning(MODULE, f"{label} timed out, terminating process") + proc.terminate() + try: + proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + debug_warning(MODULE, f"{label} did not terminate, killing process") + proc.kill() + try: + proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + # Final cleanup attempt if kill() also hangs + debug_warning(MODULE, f"{label} could not be stopped even after kill()") + raise + finally: + # Ensure pipes are closed and process is reaped to avoid zombie processes + if proc.stdout: + proc.stdout.close() + if proc.stderr: + proc.stderr.close() + try: + proc.wait(timeout=0.1) + except subprocess.TimeoutExpired: + pass # Process still running, already logged warning above + + def _apply_recreate_strategy( project_dir: Path, worktree_path: Path, @@ -717,10 +812,25 @@ def _apply_recreate_strategy( Returns True if the venv was successfully created, False if skipped or failed. """ venv_path = worktree_path / config.source_rel_path + marker_path = venv_path / VENV_SETUP_COMPLETE_MARKER - if venv_path.exists(): - debug(MODULE, f"Skipping recreate {config.source_rel_path} - already exists") - return False + # Check for broken symlinks that exists() would miss + if venv_path.is_symlink() and not venv_path.exists(): + debug(MODULE, f"Removing broken symlink at {config.source_rel_path}") + try: + venv_path.unlink() + except OSError: + pass # Best-effort removal + elif venv_path.exists(): + if marker_path.exists(): + debug( + MODULE, + f"Skipping recreate {config.source_rel_path} - already complete (marker present)", + ) + return False + # Venv exists but marker is missing — incomplete, remove and rebuild + debug(MODULE, f"Removing incomplete venv {config.source_rel_path} (no marker)") + shutil.rmtree(venv_path, ignore_errors=True) # Detect Python executable from the source venv or fall back to sys.executable source_venv = project_dir / config.source_rel_path @@ -737,29 +847,34 @@ def _apply_recreate_strategy( # Create the venv try: debug(MODULE, f"Creating venv at {venv_path}") - result = subprocess.run( + returncode, _, stderr = _popen_with_cleanup( [python_exec, "-m", "venv", str(venv_path)], - capture_output=True, - text=True, timeout=120, + label=f"venv creation ({config.source_rel_path})", ) - if result.returncode != 0: - debug_warning(MODULE, f"venv creation failed: {result.stderr}") + if returncode != 0: + debug_warning(MODULE, f"venv creation failed: {stderr}") print_status( f"Warning: Could not create venv at {config.source_rel_path}", "warning", ) - # Clean up partial venv so retries aren't blocked if venv_path.exists(): shutil.rmtree(venv_path, ignore_errors=True) return False except subprocess.TimeoutExpired: - debug_warning(MODULE, f"venv creation timed out for {config.source_rel_path}") print_status( f"Warning: venv creation timed out for {config.source_rel_path}", "warning", ) - # Clean up partial venv so retries aren't blocked + if venv_path.exists(): + shutil.rmtree(venv_path, ignore_errors=True) + return False + except OSError as e: + debug_warning(MODULE, f"venv creation failed: {e}") + print_status( + f"Warning: Could not create venv at {config.source_rel_path}", + "warning", + ) if venv_path.exists(): shutil.rmtree(venv_path, ignore_errors=True) return False @@ -800,46 +915,45 @@ def _apply_recreate_strategy( if install_cmd: try: debug(MODULE, f"Installing deps from {req_file}") - pip_result = subprocess.run( + returncode, _, stderr = _popen_with_cleanup( install_cmd, - capture_output=True, - text=True, - timeout=120, + timeout=300, + label=f"pip install ({req_file})", ) - if pip_result.returncode != 0: + if returncode != 0: debug_warning( MODULE, - f"pip install failed (exit {pip_result.returncode}): " - f"{pip_result.stderr}", + f"pip install failed (exit {returncode}): {stderr}", ) print_status( f"Warning: Dependency install failed for {req_file}", "warning", ) - # Clean up broken venv so retries aren't blocked if venv_path.exists(): shutil.rmtree(venv_path, ignore_errors=True) return False except subprocess.TimeoutExpired: - debug_warning( - MODULE, - f"pip install timed out for {req_file}", - ) print_status( f"Warning: Dependency install timed out for {req_file}", "warning", ) - # Clean up broken venv so retries aren't blocked if venv_path.exists(): shutil.rmtree(venv_path, ignore_errors=True) return False except OSError as e: debug_warning(MODULE, f"pip install failed: {e}") - # Clean up broken venv so retries aren't blocked if venv_path.exists(): shutil.rmtree(venv_path, ignore_errors=True) return False + # Write completion marker so future runs know this venv is complete + try: + marker_path.touch() + except OSError as e: + debug_warning( + MODULE, f"Failed to write completion marker at {marker_path}: {e}" + ) + debug(MODULE, f"Recreated venv at {config.source_rel_path}") return True diff --git a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts index b241fba7f6..225a48f264 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts @@ -8,7 +8,7 @@ import type { OtherWorktreeInfo, } from '../../../shared/types'; import path from 'path'; -import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync, copyFileSync, cpSync, statSync } from 'fs'; +import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync, copyFileSync, cpSync, statSync, readlinkSync } from 'fs'; import { execFileSync, execFile } from 'child_process'; import { promisify } from 'util'; import { minimatch } from 'minimatch'; @@ -57,6 +57,21 @@ function isTimeoutError(error: unknown): boolean { ); } +/** + * Check if a path is a symlink or Windows junction (including broken ones). + * Uses readlinkSync which works for both symlinks and junctions on all platforms. + */ +function isSymlinkOrJunction(targetPath: string): boolean { + try { + // readlinkSync throws if the path is not a symlink/junction + // It works for both symlinks and junctions on Windows and Unix + readlinkSync(targetPath); + return true; + } catch { + return false; // Path doesn't exist or is not a symlink/junction + } +} + /** * Fix repositories that are incorrectly marked with core.bare=true. * This can happen when git worktree operations incorrectly set bare=true @@ -250,11 +265,12 @@ interface DependencyConfig { const DEFAULT_STRATEGY_MAP: Record = { // JavaScript / Node.js — symlink is safe and fast node_modules: 'symlink', - // Python — venvs MUST be recreated, not symlinked. - // CPython bug #106045: pyvenv.cfg discovery does not resolve symlinks, - // so a symlinked venv resolves paths relative to the target, not the worktree. - venv: 'recreate', - '.venv': 'recreate', + // Python — symlink for fast worktree creation. + // CPython bug #106045 (pyvenv.cfg symlink resolution) does not affect + // typical usage (running scripts, imports, pip). If the health check + // after symlinking fails, we fall back to recreate automatically. + venv: 'symlink', + '.venv': 'symlink', // PHP — Composer vendor dir is safe to symlink vendor_php: 'symlink', // Ruby — Bundler vendor/bundle is safe to symlink @@ -364,6 +380,32 @@ async function setupWorktreeDependencies(projectPath: string, worktreePath: stri switch (config.strategy) { case 'symlink': performed = applySymlinkStrategy(projectPath, worktreePath, config); + // For venvs, verify the symlink is usable — fall back to recreate if not + // Run health check whenever a venv exists (not just on fresh creation) + if (config.depType === 'venv' || config.depType === '.venv') { + const venvPath = path.join(worktreePath, config.sourceRelPath); + // Check if venv path exists (as symlink or otherwise) + if (existsSync(venvPath) || isSymlinkOrJunction(venvPath)) { + const pythonBin = isWindows() + ? path.join(venvPath, 'Scripts', 'python.exe') + : path.join(venvPath, 'bin', 'python'); + try { + await execFileAsync(pythonBin, ['-c', 'import sys; print(sys.prefix)'], { + timeout: 10000, + }); + debugLog('[TerminalWorktree] Symlinked venv health check passed:', config.sourceRelPath); + } catch { + debugLog('[TerminalWorktree] Symlinked venv health check failed, falling back to recreate:', config.sourceRelPath); + debugLog('[TerminalWorktree] Venv fallback: removing broken symlink and recreating for', config.sourceRelPath); + // Remove the broken symlink and recreate + try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ } + performed = await applyRecreateStrategy(projectPath, worktreePath, config); + if (performed) { + debugLog('[TerminalWorktree] Venv fallback to recreate succeeded:', config.sourceRelPath); + } + } + } + } break; case 'recreate': performed = await applyRecreateStrategy(projectPath, worktreePath, config); @@ -403,13 +445,15 @@ function applySymlinkStrategy(projectPath: string, worktreePath: string, config: return false; } - // Check for broken symlinks - try { - lstatSync(targetPath); - debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists (possibly broken symlink)'); - return false; - } catch { - // Target doesn't exist at all — good, we can create symlink + // Check for broken symlinks and remove them so a fresh symlink can be created + if (isSymlinkOrJunction(targetPath)) { + if (!existsSync(targetPath)) { + debugLog('[TerminalWorktree] Removing broken symlink for', config.sourceRelPath); + try { rmSync(targetPath, { force: true }); } catch { /* best-effort */ } + } else { + debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists (symlink)'); + return false; + } } const targetDir = path.dirname(targetPath); @@ -434,19 +478,31 @@ function applySymlinkStrategy(projectPath: string, worktreePath: string, config: } } +/** Marker file written inside a recreated venv to indicate setup completed successfully. */ +const VENV_SETUP_COMPLETE_MARKER = '.setup_complete'; + /** * Apply recreate strategy: create a fresh virtual environment in the worktree. * - * Python venvs cannot be symlinked due to CPython bug #106045 — pyvenv.cfg - * discovery does not resolve symlinks, so paths resolve relative to the - * symlink target instead of the worktree. + * Used as a fallback when venv symlinking fails (CPython bug #106045). + * Writes a completion marker so incomplete venvs can be detected and rebuilt. */ async function applyRecreateStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): Promise { const venvPath = path.join(worktreePath, config.sourceRelPath); - - if (existsSync(venvPath)) { - debugLog('[TerminalWorktree] Skipping recreate', config.sourceRelPath, '- already exists'); - return false; + const markerPath = path.join(venvPath, VENV_SETUP_COMPLETE_MARKER); + + // Check for broken symlinks that existsSync would miss + if (isSymlinkOrJunction(venvPath) && !existsSync(venvPath)) { + debugLog('[TerminalWorktree] Removing broken symlink at', config.sourceRelPath); + try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ } + } else if (existsSync(venvPath)) { + if (existsSync(markerPath)) { + debugLog('[TerminalWorktree] Skipping recreate', config.sourceRelPath, '- already complete (marker present)'); + return false; + } + // Venv exists but marker is missing — incomplete, remove and rebuild + debugLog('[TerminalWorktree] Removing incomplete venv', config.sourceRelPath, '(no marker)'); + try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ } } // Detect Python executable from the source venv or fall back to system Python @@ -514,7 +570,7 @@ async function applyRecreateStrategy(projectPath: string, worktreePath: string, debugLog('[TerminalWorktree] Installing deps from', config.requirementsFile); await execFileAsync(pipExec, installArgs, { encoding: 'utf-8', - timeout: 120000, + timeout: 300000, }); } catch (error) { if (isTimeoutError(error)) { @@ -533,6 +589,13 @@ async function applyRecreateStrategy(projectPath: string, worktreePath: string, } } + // Write completion marker so future runs know this venv is complete + try { + writeFileSync(markerPath, ''); + } catch (error) { + debugLog('[TerminalWorktree] Failed to write completion marker at', markerPath, ':', error); + } + debugLog('[TerminalWorktree] Recreated venv at', config.sourceRelPath); return true; } diff --git a/tests/test_worktree_dependencies.py b/tests/test_worktree_dependencies.py index 4d5b6b89e2..a8aa13743c 100644 --- a/tests/test_worktree_dependencies.py +++ b/tests/test_worktree_dependencies.py @@ -69,13 +69,13 @@ def test_create_with_all_fields(self): """Config creates with all fields populated.""" config = DependencyShareConfig( dep_type="venv", - strategy=DependencyStrategy.RECREATE, + strategy=DependencyStrategy.SYMLINK, source_rel_path=".venv", requirements_file="requirements.txt", package_manager="uv", ) assert config.dep_type == "venv" - assert config.strategy == DependencyStrategy.RECREATE + assert config.strategy == DependencyStrategy.SYMLINK assert config.source_rel_path == ".venv" assert config.requirements_file == "requirements.txt" assert config.package_manager == "uv" @@ -88,13 +88,13 @@ def test_node_modules_is_symlink(self): """node_modules maps to SYMLINK.""" assert DEFAULT_STRATEGY_MAP["node_modules"] == DependencyStrategy.SYMLINK - def test_venv_is_recreate(self): - """venv maps to RECREATE.""" - assert DEFAULT_STRATEGY_MAP["venv"] == DependencyStrategy.RECREATE + def test_venv_is_symlink(self): + """venv maps to SYMLINK (fast worktree creation with health check fallback).""" + assert DEFAULT_STRATEGY_MAP["venv"] == DependencyStrategy.SYMLINK - def test_dot_venv_is_recreate(self): - """.venv maps to RECREATE.""" - assert DEFAULT_STRATEGY_MAP[".venv"] == DependencyStrategy.RECREATE + def test_dot_venv_is_symlink(self): + """.venv maps to SYMLINK (fast worktree creation with health check fallback).""" + assert DEFAULT_STRATEGY_MAP[".venv"] == DependencyStrategy.SYMLINK def test_vendor_php_is_symlink(self): """vendor_php maps to SYMLINK.""" @@ -138,7 +138,7 @@ def test_with_mock_project_index(self): by_type = {c.dep_type: c for c in configs} assert by_type["node_modules"].strategy == DependencyStrategy.SYMLINK assert by_type["node_modules"].source_rel_path == "node_modules" - assert by_type["venv"].strategy == DependencyStrategy.RECREATE + assert by_type["venv"].strategy == DependencyStrategy.SYMLINK assert by_type["venv"].source_rel_path == "apps/backend/.venv" assert by_type["venv"].requirements_file == "requirements.txt" assert by_type["venv"].package_manager == "uv" @@ -238,12 +238,12 @@ def test_multiple_python_services_own_venv_configs(self): assert "services/worker/.venv" in paths api_config = paths["services/api/.venv"] - assert api_config.strategy == DependencyStrategy.RECREATE + assert api_config.strategy == DependencyStrategy.SYMLINK assert api_config.package_manager == "pip" assert api_config.requirements_file == "requirements.txt" worker_config = paths["services/worker/.venv"] - assert worker_config.strategy == DependencyStrategy.RECREATE + assert worker_config.strategy == DependencyStrategy.SYMLINK assert worker_config.package_manager == "uv" assert worker_config.requirements_file == "pyproject.toml" @@ -582,6 +582,132 @@ def test_target_already_exists_skipped_gracefully(self, tmp_path: Path): assert not (worktree_path / "node_modules").is_symlink() +class TestVenvSymlinkWithHealthCheck: + """Tests for venv symlink strategy with health check and fallback to recreate.""" + + def test_venv_symlinked_when_source_exists(self, tmp_path: Path): + """Venv is symlinked (not recreated) when source venv exists.""" + from core.workspace.setup import setup_worktree_dependencies + + project_dir = tmp_path / "project" + project_dir.mkdir() + venv_dir = project_dir / ".venv" + venv_dir.mkdir() + # Create a minimal venv structure so the symlink target looks real + (venv_dir / "bin").mkdir() + (venv_dir / "lib").mkdir() + + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + project_index = { + "dependency_locations": [ + {"type": ".venv", "path": ".venv", "service": "backend"}, + ] + } + + results = setup_worktree_dependencies(project_dir, worktree_path, project_index) + + target = worktree_path / ".venv" + # The symlink should have been created (regardless of health check outcome) + assert target.exists() or target.is_symlink() + + def test_venv_health_check_fallback_to_recreate(self, tmp_path: Path): + """When symlinked venv health check fails, falls back to recreate.""" + from core.workspace.setup import setup_worktree_dependencies + + project_dir = tmp_path / "project" + project_dir.mkdir() + # Create a source venv that has no python binary (health check will fail) + venv_dir = project_dir / ".venv" + venv_dir.mkdir() + + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + project_index = { + "dependency_locations": [ + {"type": ".venv", "path": ".venv", "service": "backend"}, + ] + } + + # This should symlink, then health check fails (no python binary), + # then fall back to recreate (which will also fail since no real python + # in source). The important thing is it doesn't raise. + results = setup_worktree_dependencies(project_dir, worktree_path, project_index) + # Should not crash + assert isinstance(results, dict) + + +class TestRecreateStrategyMarker: + """Tests for the .setup_complete marker in the recreate strategy.""" + + def test_marker_constant_defined(self): + """VENV_SETUP_COMPLETE_MARKER is defined.""" + from core.workspace.setup import VENV_SETUP_COMPLETE_MARKER + assert VENV_SETUP_COMPLETE_MARKER == ".setup_complete" + + def test_incomplete_venv_detected_and_removed(self, tmp_path: Path): + """Venv without marker is detected as incomplete.""" + from core.workspace.setup import _apply_recreate_strategy, VENV_SETUP_COMPLETE_MARKER + from core.workspace.models import DependencyShareConfig, DependencyStrategy + + project_dir = tmp_path / "project" + project_dir.mkdir() + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + # Create an incomplete venv (no marker) + incomplete_venv = worktree_path / ".venv" + incomplete_venv.mkdir() + (incomplete_venv / "bin").mkdir() + + config = DependencyShareConfig( + dep_type=".venv", + strategy=DependencyStrategy.RECREATE, + source_rel_path=".venv", + ) + + # Will try to recreate (remove incomplete + rebuild). May fail due to + # no real python, but the incomplete venv should be removed. + _apply_recreate_strategy(project_dir, worktree_path, config) + + # The incomplete venv without marker should have been removed + # (recreation may or may not succeed depending on Python availability) + if incomplete_venv.exists(): + # If it was recreated successfully, marker should exist + assert (incomplete_venv / VENV_SETUP_COMPLETE_MARKER).exists() + + def test_complete_venv_skipped(self, tmp_path: Path): + """Venv with marker is skipped (not rebuilt).""" + from core.workspace.setup import _apply_recreate_strategy, VENV_SETUP_COMPLETE_MARKER + from core.workspace.models import DependencyShareConfig, DependencyStrategy + + project_dir = tmp_path / "project" + project_dir.mkdir() + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + # Create a complete venv (with marker) + complete_venv = worktree_path / ".venv" + complete_venv.mkdir() + (complete_venv / VENV_SETUP_COMPLETE_MARKER).touch() + # Add a canary file to verify the venv wasn't rebuilt + (complete_venv / "canary.txt").write_text("original") + + config = DependencyShareConfig( + dep_type=".venv", + strategy=DependencyStrategy.RECREATE, + source_rel_path=".venv", + ) + + result = _apply_recreate_strategy(project_dir, worktree_path, config) + + assert result is False # Skipped + # Canary file should still be present (not rebuilt) + assert (complete_venv / "canary.txt").read_text() == "original" + + class TestSymlinkNodeModulesToWorktreeBackwardCompat: """Tests for symlink_node_modules_to_worktree() backward compatibility."""