Merge pull request #1832 from AndyMik90/terminal/improve-worktree-venv

feat: symlink Python venvs in worktrees for instant setup
This commit is contained in:
Andy
2026-02-20 11:57:53 +01:00
committed by GitHub
5 changed files with 372 additions and 69 deletions
@@ -9,9 +9,10 @@ Each dependency ecosystem has different constraints:
- **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 @@ from .models import DependencyShareConfig, DependencyStrategy
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
+5 -6
View File
@@ -278,12 +278,11 @@ class SpecNumberLock:
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)
+140 -26
View File
@@ -50,6 +50,10 @@ _git_hook_check_done = False
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
@@ -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<string, 'symlink' | 'recreate' | 'copy' | 'skip'> = {
// 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<boolean> {
const venvPath = path.join(worktreePath, config.sourceRelPath);
const markerPath = path.join(venvPath, VENV_SETUP_COMPLETE_MARKER);
if (existsSync(venvPath)) {
debugLog('[TerminalWorktree] Skipping recreate', config.sourceRelPath, '- already exists');
return false;
// 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;
}
+137 -11
View File
@@ -69,13 +69,13 @@ class TestDependencyShareConfig:
"""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 @@ class TestDefaultStrategyMap:
"""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 @@ class TestGetDependencyConfigs:
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 @@ class TestGetDependencyConfigs:
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 @@ class TestSetupWorktreeDependencies:
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."""