Merge pull request #1832 from AndyMik90/terminal/improve-worktree-venv
feat: symlink Python venvs in worktrees for instant setup
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user