diff --git a/.husky/pre-commit b/.husky/pre-commit index afc49e5d..baf296d7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -127,6 +127,13 @@ fi if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then echo "Python changes detected, running backend checks..." + # Detect if we're in a worktree + IS_WORKTREE=false + if [ -f ".git" ]; then + # .git is a file (not directory) in worktrees + IS_WORKTREE=true + fi + # Determine ruff command (venv or global) RUFF="" if [ -f "apps/backend/.venv/bin/ruff" ]; then @@ -158,7 +165,16 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then echo "$STAGED_PY_FILES" | xargs git add fi else - echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff" + if [ "$IS_WORKTREE" = true ]; then + echo "" + echo "⚠️ WARNING: ruff not available in this worktree." + echo " Python linting checks will be skipped." + echo " This is expected for auto-claude worktrees." + echo " Full validation will occur when PR is created/merged." + echo "" + else + echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff" + fi fi # Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed) @@ -192,17 +208,28 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then elif [ -d "apps/backend/.venv" ]; then echo "Warning: venv exists but Python not found in it, using system Python" PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS + elif [ "$IS_WORKTREE" = true ]; then + echo "" + echo "⚠️ WARNING: Python venv not available in this worktree." + echo " Python tests will be skipped." + echo " This is expected for auto-claude worktrees." + echo " Full validation will occur when PR is created/merged." + echo "" + exit 77 # GNU convention for 'test skipped' (avoids pytest exit-code collision) else echo "Warning: No .venv found in apps/backend, using system Python" PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS fi ) - if [ $? -ne 0 ]; then + PYTHON_EXIT=$? + if [ $PYTHON_EXIT -eq 77 ]; then + echo "Backend checks passed! (Python tests skipped — worktree)" + elif [ $PYTHON_EXIT -ne 0 ]; then echo "Python tests failed. Please fix failing tests before committing." exit 1 + else + echo "Backend checks passed!" fi - - echo "Backend checks passed!" fi # ============================================================================= diff --git a/apps/backend/analysis/analyzers/project_analyzer_module.py b/apps/backend/analysis/analyzers/project_analyzer_module.py index 9e8ca7be..b7380dbb 100644 --- a/apps/backend/analysis/analyzers/project_analyzer_module.py +++ b/apps/backend/analysis/analyzers/project_analyzer_module.py @@ -31,6 +31,7 @@ class ProjectAnalyzer: """Run full project analysis.""" self._detect_project_type() self._find_and_analyze_services() + self._aggregate_dependency_locations() self._analyze_infrastructure() self._detect_conventions() self._map_dependencies() @@ -124,6 +125,63 @@ class ProjectAnalyzer: self.index["services"] = services + def _aggregate_dependency_locations(self) -> None: + """Aggregate dependency location metadata from all services. + + Collects dependency_locations from each service and stores them as + paths relative to the project root (e.g., 'apps/backend/.venv' + instead of just '.venv'). + """ + aggregated: list[dict[str, Any]] = [] + + for service_name, service_info in self.index.get("services", {}).items(): + service_deps = service_info.get("dependency_locations", []) + service_path = service_info.get("path", "") + + # Compute service-relative prefix once per service + service_rel: Path | None = None + if service_path: + try: + service_rel = Path(service_path).relative_to(self.project_dir) + except ValueError: + # Service path is outside the project root — skip its deps + # to avoid producing absolute paths that bypass containment + continue + + for dep in service_deps: + dep_path = dep.get("path") + if not dep_path: + continue + + # Build project-relative path from service path + dep path + if service_rel is not None: + project_relative = str(service_rel / dep_path) + else: + project_relative = dep_path + + entry: dict[str, Any] = { + "type": dep.get("type", "unknown"), + "path": project_relative, + "exists": dep.get("exists", False), + "service": service_name, + } + if dep.get("requirements_file"): + # Convert to project-relative path like we do for "path" + if service_rel is not None: + entry["requirements_file"] = str( + service_rel / dep["requirements_file"] + ) + else: + entry["requirements_file"] = dep["requirements_file"] + pkg_mgr = dep.get("package_manager") or service_info.get( + "package_manager" + ) + if pkg_mgr: + entry["package_manager"] = pkg_mgr + aggregated.append(entry) + + self.index["dependency_locations"] = aggregated + def _analyze_infrastructure(self) -> None: """Analyze infrastructure configuration.""" infra = {} diff --git a/apps/backend/analysis/analyzers/service_analyzer.py b/apps/backend/analysis/analyzers/service_analyzer.py index cd7201b9..d8f35171 100644 --- a/apps/backend/analysis/analyzers/service_analyzer.py +++ b/apps/backend/analysis/analyzers/service_analyzer.py @@ -40,6 +40,8 @@ class ServiceAnalyzer(BaseAnalyzer): self._find_key_directories() self._find_entry_points() self._detect_dependencies() + self._detect_dependency_locations() + self._detect_package_manager() self._detect_testing() self._find_dockerfile() @@ -209,6 +211,121 @@ class ServiceAnalyzer(BaseAnalyzer): deps.append(match.group(1)) self.analysis["dependencies"] = deps[:20] + def _detect_dependency_locations(self) -> None: + """Detect where dependencies live on disk for this service.""" + locations: list[dict[str, Any]] = [] + + # Node.js: node_modules (only if package.json exists) + if self._exists("package.json"): + node_modules = self.path / "node_modules" + locations.append( + { + "type": "node_modules", + "path": "node_modules", + "exists": node_modules.exists() and node_modules.is_dir(), + } + ) + + # Python: .venv or venv + for venv_dir in [".venv", "venv"]: + venv_path = self.path / venv_dir + if venv_path.exists() and venv_path.is_dir(): + entry: dict[str, Any] = { + "type": "venv", + "path": venv_dir, + "exists": True, + } + # Find requirements file + for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]: + if self._exists(req_file): + entry["requirements_file"] = req_file + break + locations.append(entry) + break + else: + # No venv found, still record requirements file if present + for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]: + if self._exists(req_file): + locations.append( + { + "type": "venv", + "path": ".venv", + "exists": False, + "requirements_file": req_file, + } + ) + break + + # PHP: vendor + vendor_path = self.path / "vendor" + if vendor_path.exists() and vendor_path.is_dir(): + locations.append( + { + "type": "vendor_php", + "path": "vendor", + "exists": True, + } + ) + + # Rust: target + target_path = self.path / "target" + if target_path.exists() and target_path.is_dir(): + locations.append( + { + "type": "cargo_target", + "path": "target", + "exists": True, + } + ) + + # Ruby: vendor/bundle + bundle_path = self.path / "vendor" / "bundle" + if bundle_path.exists() and bundle_path.is_dir(): + locations.append( + { + "type": "vendor_bundle", + "path": "vendor/bundle", + "exists": True, + } + ) + + self.analysis["dependency_locations"] = locations + + def _detect_package_manager(self) -> None: + """Detect the package manager used by this service.""" + # Node.js package managers + if self._exists("package-lock.json"): + self.analysis["package_manager"] = "npm" + elif self._exists("yarn.lock"): + self.analysis["package_manager"] = "yarn" + elif self._exists("pnpm-lock.yaml"): + self.analysis["package_manager"] = "pnpm" + elif self._exists("bun.lockb") or self._exists("bun.lock"): + self.analysis["package_manager"] = "bun" + # Python package managers + elif self._exists("Pipfile"): + self.analysis["package_manager"] = "pipenv" + elif self._exists("pyproject.toml"): + if self._exists("uv.lock"): + self.analysis["package_manager"] = "uv" + elif self._exists("poetry.lock"): + self.analysis["package_manager"] = "poetry" + else: + self.analysis["package_manager"] = "pip" + elif self._exists("requirements.txt"): + self.analysis["package_manager"] = "pip" + # Other + elif self._exists("Cargo.toml"): + self.analysis["package_manager"] = "cargo" + elif self._exists("go.mod"): + self.analysis["package_manager"] = "go_mod" + elif self._exists("Gemfile"): + self.analysis["package_manager"] = "gem" + elif self._exists("composer.json"): + self.analysis["package_manager"] = "composer" + else: + self.analysis["package_manager"] = None + def _detect_testing(self) -> None: """Detect testing framework and configuration.""" if self._exists("package.json"): diff --git a/apps/backend/core/workspace/dependency_strategy.py b/apps/backend/core/workspace/dependency_strategy.py new file mode 100644 index 00000000..4b9f6014 --- /dev/null +++ b/apps/backend/core/workspace/dependency_strategy.py @@ -0,0 +1,176 @@ +""" +Dependency Strategy Mapping +============================ + +Maps dependency types to sharing strategies for worktree creation. + +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. + +- **vendor (PHP)**: Safe to symlink. Composer's autoloader uses ``__DIR__``-relative + paths that resolve correctly through symlinks. + +- **cargo target / go modules**: Skip entirely. Rust's ``target/`` dir contains + per-machine build artifacts that must be rebuilt. Go uses a global module cache + (``$GOPATH/pkg/mod``), so there is nothing in-tree to share. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path, PurePosixPath, PureWindowsPath + +logger = logging.getLogger(__name__) + +from .models import DependencyShareConfig, DependencyStrategy + +# --------------------------------------------------------------------------- +# Default strategy map +# --------------------------------------------------------------------------- +# Maps dependency type identifiers to the strategy that should be used when +# sharing that dependency across worktrees. Data-driven — add new entries +# here rather than writing if/else branches. +# --------------------------------------------------------------------------- + +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, + # PHP — Composer vendor dir is safe to symlink + "vendor_php": DependencyStrategy.SYMLINK, + # Ruby — Bundler vendor/bundle is safe to symlink + "vendor_bundle": DependencyStrategy.SYMLINK, + # Rust — build output dir, skip (rebuilt per-worktree) + "cargo_target": DependencyStrategy.SKIP, + # Go — global module cache, nothing in-tree to share + "go_modules": DependencyStrategy.SKIP, +} + + +def get_dependency_configs( + project_index: dict | None, + project_dir: Path | None = None, +) -> list[DependencyShareConfig]: + """Derive dependency share configs from a project index. + + If *project_index* is ``None`` or lacks ``dependency_locations``, + falls back to a hardcoded node_modules config for backward compatibility + with existing worktree setups. + + Args: + project_index: Parsed ``project_index.json`` dict, or ``None``. + project_dir: Project root directory for resolved-path containment + checks (defense-in-depth). Should always be provided when + *project_index* is not ``None`` — omitting it disables the + resolved-path security check. + + Returns: + List of :class:`DependencyShareConfig` objects — one per discovered + dependency location. + """ + + configs: list[DependencyShareConfig] = [] + seen: set[str] = set() + + if project_index is not None: + if project_dir is None: + logger.warning( + "get_dependency_configs called with project_index but no " + "project_dir — resolved-path containment check is disabled" + ) + + # Use the aggregated top-level dependency_locations which already + # contain project-relative paths (e.g. "apps/backend/.venv" instead + # of just ".venv"). This avoids a monorepo path resolution bug + # where service-relative paths were incorrectly treated as project- + # relative. + dep_locations = project_index.get("dependency_locations") or [] + for dep in dep_locations: + if not isinstance(dep, dict): + continue + + dep_type = dep.get("type", "") + rel_path = dep.get("path", "") + + if not dep_type or not rel_path: + continue + + # Path containment: reject absolute paths and traversals. + # Check both POSIX and Windows path styles for cross-platform safety. + p = PurePosixPath(rel_path) + if p.is_absolute() or PureWindowsPath(rel_path).is_absolute(): + continue + if ".." in p.parts or ".." in PureWindowsPath(rel_path).parts: + continue + + # Defense-in-depth: verify the resolved path stays within project_dir + if project_dir is not None: + resolved = (project_dir / rel_path).resolve() + if not str(resolved).startswith(str(project_dir.resolve()) + os.sep): + continue + + # Deduplicate by relative path + if rel_path in seen: + continue + seen.add(rel_path) + + strategy = DEFAULT_STRATEGY_MAP.get(dep_type, DependencyStrategy.SKIP) + + # Validate requirements_file path containment too + req_file = dep.get("requirements_file") + if req_file: + rp = PurePosixPath(req_file) + if ( + rp.is_absolute() + or PureWindowsPath(req_file).is_absolute() + or ".." in rp.parts + or ".." in PureWindowsPath(req_file).parts + ): + req_file = None + + # Defense-in-depth: resolved-path containment (matches rel_path check) + if req_file and project_dir is not None: + resolved_req = (project_dir / req_file).resolve() + if not str(resolved_req).startswith( + str(project_dir.resolve()) + os.sep + ): + req_file = None + + configs.append( + DependencyShareConfig( + dep_type=dep_type, + strategy=strategy, + source_rel_path=rel_path, + requirements_file=req_file, + package_manager=dep.get("package_manager"), + ) + ) + + # Fallback: if no configs were discovered, default to node_modules-only + # so existing worktree behaviour is preserved. + if not configs: + configs.append( + DependencyShareConfig( + dep_type="node_modules", + strategy=DependencyStrategy.SYMLINK, + source_rel_path="node_modules", + ) + ) + configs.append( + DependencyShareConfig( + dep_type="node_modules", + strategy=DependencyStrategy.SYMLINK, + source_rel_path="apps/frontend/node_modules", + ) + ) + + return configs diff --git a/apps/backend/core/workspace/models.py b/apps/backend/core/workspace/models.py index b22381f3..b9092012 100644 --- a/apps/backend/core/workspace/models.py +++ b/apps/backend/core/workspace/models.py @@ -273,3 +273,31 @@ class SpecNumberLock: pass return max_num + + +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 = "symlink" # Create a symlink to the source (fast, works for node_modules) + RECREATE = "recreate" # Re-run the package manager to create a fresh copy + COPY = "copy" # Deep-copy the directory (slow but always correct) + SKIP = "skip" # Do nothing; let the agent handle it + + +@dataclass +class DependencyShareConfig: + """Configuration for how a specific dependency type should be shared.""" + + dep_type: str # e.g. "node_modules", "venv", ".venv" + strategy: DependencyStrategy + source_rel_path: str # Relative path from project root, e.g. "node_modules" + requirements_file: str | None = None # e.g. "requirements.txt", "pyproject.toml" + package_manager: str | None = None # e.g. "npm", "uv", "pip" diff --git a/apps/backend/core/workspace/setup.py b/apps/backend/core/workspace/setup.py index d7ca7ed4..b3ed57da 100644 --- a/apps/backend/core/workspace/setup.py +++ b/apps/backend/core/workspace/setup.py @@ -14,6 +14,7 @@ import sys from pathlib import Path from core.git_executable import run_git +from core.platform import is_windows from merge import FileTimelineTracker from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME from ui import ( @@ -28,8 +29,9 @@ from ui import ( ) from worktree import WorktreeManager +from .dependency_strategy import get_dependency_configs from .git_utils import has_uncommitted_changes -from .models import WorkspaceMode +from .models import DependencyShareConfig, DependencyStrategy, WorkspaceMode # Import debug utilities try: @@ -189,11 +191,13 @@ def symlink_node_modules_to_worktree( """ Symlink node_modules directories from project root to worktree. - This ensures the worktree has access to dependencies for TypeScript checks - and other tooling without requiring a separate npm install. + .. deprecated:: + Use :func:`setup_worktree_dependencies` instead, which handles all + dependency types (node_modules, venvs, vendor dirs, etc.) via + strategy-based dispatch. - Works with npm workspace hoisting where dependencies are hoisted to root - and workspace-specific dependencies remain in nested node_modules. + This is a thin backward-compatibility wrapper that delegates to + ``setup_worktree_dependencies()`` with no project index (fallback mode). Args: project_dir: The main project directory @@ -202,85 +206,11 @@ def symlink_node_modules_to_worktree( Returns: List of symlinked paths (relative to worktree) """ - symlinked = [] - - # Node modules locations to symlink for TypeScript and tooling support. - # These are the standard locations for this monorepo structure. - # - # Design rationale: - # - Hardcoded paths are intentional for simplicity and reliability - # - Dynamic discovery (reading workspaces from package.json) would add complexity - # and potential failure points without significant benefit - # - This monorepo uses npm workspaces with hoisting, so dependencies are primarily - # in root node_modules with workspace-specific deps in apps/frontend/node_modules - # - # To add new workspace locations: - # 1. Add (source_rel, target_rel) tuple below - # 2. Update the parallel TypeScript implementation in - # apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts - # 3. Update the pre-commit hook check in .husky/pre-commit if needed - node_modules_locations = [ - ("node_modules", "node_modules"), - ("apps/frontend/node_modules", "apps/frontend/node_modules"), - ] - - for source_rel, target_rel in node_modules_locations: - source_path = project_dir / source_rel - target_path = worktree_path / target_rel - - # Skip if source doesn't exist - if not source_path.exists(): - debug(MODULE, f"Skipping {source_rel} - source does not exist") - continue - - # Skip if target already exists (don't overwrite existing node_modules) - if target_path.exists(): - debug(MODULE, f"Skipping {target_rel} - target already exists") - continue - - # Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks) - if target_path.is_symlink(): - debug( - MODULE, - f"Skipping {target_rel} - symlink already exists (possibly broken)", - ) - continue - - # Ensure parent directory exists - target_path.parent.mkdir(parents=True, exist_ok=True) - - try: - if sys.platform == "win32": - # On Windows, use junctions instead of symlinks (no admin rights required) - # Junctions require absolute paths - result = subprocess.run( - ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise OSError(result.stderr or "mklink /J failed") - else: - # On macOS/Linux, use relative symlinks for portability - relative_source = os.path.relpath(source_path, target_path.parent) - os.symlink(relative_source, target_path) - symlinked.append(target_rel) - debug(MODULE, f"Symlinked {target_rel} -> {source_path}") - except OSError as e: - # Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem) - # Log warning but don't fail - worktree is still usable, just without - # TypeScript checking - debug_warning( - MODULE, - f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.", - ) - # Warn user - pre-commit hooks may fail without dependencies - print_status( - f"Warning: Could not link {target_rel} - TypeScript checks may fail", - "warning", - ) - - return symlinked + results = setup_worktree_dependencies( + project_dir, worktree_path, project_index=None + ) + # Flatten all processed paths for backward-compatible return value + return [path for paths in results.values() for path in paths] def symlink_claude_config_to_worktree( @@ -443,13 +373,26 @@ def setup_workspace( f"Environment files copied: {', '.join(copied_env_files)}", "success" ) - # Symlink node_modules to worktree for TypeScript and tooling support - # This allows pre-commit hooks to run typecheck without npm install in worktree - symlinked_modules = symlink_node_modules_to_worktree( - project_dir, worktree_info.path + # Set up dependencies in worktree using strategy-based dispatch + # Load project index if available for ecosystem-aware dependency handling + project_index = None + project_index_path = project_dir / ".auto-claude" / "project_index.json" + if project_index_path.is_file(): + try: + with open(project_index_path, encoding="utf-8") as f: + project_index = json.load(f) + debug(MODULE, "Loaded project_index.json for dependency setup") + except (OSError, json.JSONDecodeError) as e: + debug_warning(MODULE, f"Could not load project_index.json: {e}") + + dep_results = setup_worktree_dependencies( + project_dir, worktree_info.path, project_index=project_index ) - if symlinked_modules: - print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success") + for strategy_name, paths in dep_results.items(): + if paths: + print_status( + f"Dependencies ({strategy_name}): {', '.join(paths)}", "success" + ) # Symlink .claude/ config to worktree for Claude Code features (settings, commands, etc.) symlinked_claude = symlink_claude_config_to_worktree( @@ -650,6 +593,299 @@ def initialize_timeline_tracking( print(muted(f" Note: Timeline tracking could not be initialized: {e}")) +def setup_worktree_dependencies( + project_dir: Path, + worktree_path: Path, + project_index: dict | None = None, +) -> dict[str, list[str]]: + """ + Set up dependencies in a worktree using strategy-based dispatch. + + Reads dependency configs from the project index and applies the correct + strategy for each: symlink, recreate, copy, or skip. + + All operations are non-blocking — failures produce warnings but do not + prevent worktree creation. + + Args: + project_dir: The main project directory + worktree_path: Path to the worktree + project_index: Parsed project_index.json dict, or None + + Returns: + Dict mapping strategy names to lists of paths that were processed. + """ + configs = get_dependency_configs(project_index, project_dir=project_dir) + results: dict[str, list[str]] = {} + + for config in configs: + strategy_name = config.strategy.value + if strategy_name not in results: + results[strategy_name] = [] + + try: + performed = True + if config.strategy == DependencyStrategy.SYMLINK: + performed = _apply_symlink_strategy(project_dir, worktree_path, config) + elif config.strategy == DependencyStrategy.RECREATE: + performed = _apply_recreate_strategy(project_dir, worktree_path, config) + elif config.strategy == DependencyStrategy.COPY: + performed = _apply_copy_strategy(project_dir, worktree_path, config) + elif config.strategy == DependencyStrategy.SKIP: + _apply_skip_strategy(config) + # Don't record skipped entries — only report actual work + continue + if performed: + results[strategy_name].append(config.source_rel_path) + except Exception as e: + debug_warning( + MODULE, + f"Failed to apply {strategy_name} strategy for " + f"{config.source_rel_path}: {e}", + ) + + return results + + +def _apply_symlink_strategy( + project_dir: Path, + worktree_path: Path, + config: DependencyShareConfig, +) -> bool: + """Create a symlink (or Windows junction) from worktree to project source. + + Returns True if a symlink was created, False if skipped. + """ + source_path = project_dir / config.source_rel_path + target_path = worktree_path / config.source_rel_path + + if not source_path.exists(): + debug(MODULE, f"Skipping symlink {config.source_rel_path} - source missing") + return False + + if target_path.exists() or target_path.is_symlink(): + debug(MODULE, f"Skipping symlink {config.source_rel_path} - target exists") + return False + + target_path.parent.mkdir(parents=True, exist_ok=True) + + try: + if is_windows(): + # Windows: use directory junctions (no admin rights required). + # os.symlink creates a directory symlink that needs admin/DevMode, + # so we use mklink /J which creates a junction without privileges. + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise OSError(result.stderr or "mklink /J failed") + else: + # macOS/Linux: relative symlinks for portability + relative_source = os.path.relpath(source_path, target_path.parent) + os.symlink(relative_source, target_path) + debug(MODULE, f"Symlinked {config.source_rel_path} -> {source_path}") + return True + except subprocess.TimeoutExpired: + debug_warning( + MODULE, + f"Symlink creation timed out for {config.source_rel_path}", + ) + print_status( + f"Warning: Symlink creation timed out for {config.source_rel_path}", + "warning", + ) + return False + except OSError as e: + debug_warning( + MODULE, + f"Could not symlink {config.source_rel_path}: {e}", + ) + print_status(f"Warning: Could not link {config.source_rel_path}", "warning") + return False + + +def _apply_recreate_strategy( + project_dir: Path, + worktree_path: Path, + config: DependencyShareConfig, +) -> bool: + """Create a fresh virtual environment in the worktree and install deps. + + Returns True if the venv was successfully created, False if skipped or failed. + """ + venv_path = worktree_path / config.source_rel_path + + if venv_path.exists(): + debug(MODULE, f"Skipping recreate {config.source_rel_path} - already exists") + return False + + # Detect Python executable from the source venv or fall back to sys.executable + source_venv = project_dir / config.source_rel_path + python_exec = sys.executable + + if source_venv.exists(): + # Try to use the same Python version as the source venv + for candidate in ("bin/python", "Scripts/python.exe"): + candidate_path = source_venv / candidate + if candidate_path.exists(): + python_exec = str(candidate_path.resolve()) + break + + # Create the venv + try: + debug(MODULE, f"Creating venv at {venv_path}") + result = subprocess.run( + [python_exec, "-m", "venv", str(venv_path)], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + debug_warning(MODULE, f"venv creation failed: {result.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 + + # Install from requirements file if specified + req_file = config.requirements_file + if req_file: + req_path = project_dir / req_file + if req_path.is_file(): + # Determine pip executable inside the new venv + if is_windows(): + pip_exec = str(venv_path / "Scripts" / "pip.exe") + else: + pip_exec = str(venv_path / "bin" / "pip") + + # Build install command based on file type + req_basename = Path(req_file).name + if req_basename == "pyproject.toml": + # pyproject.toml: snapshot-install from the worktree copy. + # Non-editable so the venv doesn't symlink back to the source. + worktree_req = worktree_path / req_file + install_dir = str( + worktree_req.parent if worktree_req.is_file() else req_path.parent + ) + install_cmd = [pip_exec, "install", install_dir] + elif req_basename == "Pipfile": + # Pipfile: not directly installable via pip, skip + debug( + MODULE, + f"Skipping Pipfile-based install for {req_file} " + "(use pipenv in the worktree)", + ) + install_cmd = None + else: + # requirements.txt or similar: pip install -r + install_cmd = [pip_exec, "install", "-r", str(req_path)] + + if install_cmd: + try: + debug(MODULE, f"Installing deps from {req_file}") + pip_result = subprocess.run( + install_cmd, + capture_output=True, + text=True, + timeout=120, + ) + if pip_result.returncode != 0: + debug_warning( + MODULE, + f"pip install failed (exit {pip_result.returncode}): " + f"{pip_result.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 + + debug(MODULE, f"Recreated venv at {config.source_rel_path}") + return True + + +def _apply_copy_strategy( + project_dir: Path, + worktree_path: Path, + config: DependencyShareConfig, +) -> bool: + """Deep-copy a dependency directory from project to worktree. + + Returns True if the copy was performed, False if skipped. + """ + source_path = project_dir / config.source_rel_path + target_path = worktree_path / config.source_rel_path + + if not source_path.exists(): + debug(MODULE, f"Skipping copy {config.source_rel_path} - source missing") + return False + + if target_path.exists(): + debug(MODULE, f"Skipping copy {config.source_rel_path} - target exists") + return False + + target_path.parent.mkdir(parents=True, exist_ok=True) + + try: + if source_path.is_file(): + shutil.copy2(source_path, target_path) + else: + shutil.copytree(source_path, target_path) + debug(MODULE, f"Copied {config.source_rel_path} to worktree") + return True + except (OSError, shutil.Error) as e: + debug_warning(MODULE, f"Could not copy {config.source_rel_path}: {e}") + print_status(f"Warning: Could not copy {config.source_rel_path}", "warning") + return False + + +def _apply_skip_strategy(config: DependencyShareConfig) -> None: + """Skip — nothing to do for this dependency type.""" + debug( + MODULE, f"Skipping {config.dep_type} ({config.source_rel_path}) - skip strategy" + ) + + # Export private functions for backward compatibility _ensure_timeline_hook_installed = ensure_timeline_hook_installed _initialize_timeline_tracking = initialize_timeline_tracking 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 3baf737e..b241fba7 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 } from 'fs'; +import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync, copyFileSync, cpSync, statSync } from 'fs'; import { execFileSync, execFile } from 'child_process'; import { promisify } from 'util'; import { minimatch } from 'minimatch'; @@ -226,96 +226,358 @@ function getDefaultBranch(projectPath: string): string { } /** - * Symlink node_modules from project root to worktree for TypeScript and tooling support. - * This allows pre-commit hooks and IDE features to work without npm install in the worktree. - * - * @param projectPath - The main project directory - * @param worktreePath - Path to the worktree - * @returns Array of symlinked paths (relative to worktree) + * Configuration for a single dependency to be shared in a worktree. */ -function symlinkNodeModulesToWorktree(projectPath: string, worktreePath: string): string[] { - const symlinked: string[] = []; +interface DependencyConfig { + /** Dependency type identifier (e.g., 'node_modules', 'venv') */ + depType: string; + /** Strategy for sharing this dependency in worktrees */ + strategy: 'symlink' | 'recreate' | 'copy' | 'skip'; + /** Relative path from project root to the dependency directory */ + sourceRelPath: string; + /** Path to requirements file for recreate strategy (e.g., 'requirements.txt') */ + requirementsFile?: string; + /** Package manager used (e.g., 'npm', 'pip', 'uv') */ + packageManager?: string; +} - // Node modules locations to symlink for TypeScript and tooling support. - // These are the standard locations for this monorepo structure. - // - // Design rationale: - // - Hardcoded paths are intentional for simplicity and reliability - // - Dynamic discovery (reading workspaces from package.json) would add complexity - // and potential failure points without significant benefit - // - This monorepo uses npm workspaces with hoisting, so dependencies are primarily - // in root node_modules with workspace-specific deps in apps/frontend/node_modules - // - // To add new workspace locations: - // 1. Add [sourceRelPath, targetRelPath] tuple below - // 2. Update the parallel Python implementation in apps/backend/core/workspace/setup.py - // 3. Update the pre-commit hook check in .husky/pre-commit if needed - const nodeModulesLocations = [ - ['node_modules', 'node_modules'], - ['apps/frontend/node_modules', 'apps/frontend/node_modules'], - ]; +/** + * Default mapping from dependency type to sharing strategy. + * + * Data-driven — add new entries here rather than writing if/else branches. + * Mirrors the Python implementation in apps/backend/core/workspace/dependency_strategy.py. + */ +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', + // PHP — Composer vendor dir is safe to symlink + vendor_php: 'symlink', + // Ruby — Bundler vendor/bundle is safe to symlink + vendor_bundle: 'symlink', + // Rust — build output dir, skip (rebuilt per-worktree) + cargo_target: 'skip', + // Go — global module cache, nothing in-tree to share + go_modules: 'skip', +}; - for (const [sourceRel, targetRel] of nodeModulesLocations) { - const sourcePath = path.join(projectPath, sourceRel); - const targetPath = path.join(worktreePath, targetRel); +/** + * Load dependency configs from the project index, or fall back to hardcoded + * node_modules-only behavior for backward compatibility. + */ +function loadDependencyConfigs(projectPath: string): DependencyConfig[] { + const indexPath = path.join(projectPath, '.auto-claude', 'project_index.json'); - // Skip if source doesn't exist - if (!existsSync(sourcePath)) { - debugLog('[TerminalWorktree] Skipping symlink - source does not exist:', sourceRel); - continue; - } - - // Skip if target already exists (don't overwrite existing node_modules) - if (existsSync(targetPath)) { - debugLog('[TerminalWorktree] Skipping symlink - target already exists:', targetRel); - continue; - } - - // Also skip if target is a symlink (even if broken) + if (existsSync(indexPath)) { try { - lstatSync(targetPath); - debugLog('[TerminalWorktree] Skipping symlink - target exists (possibly broken symlink):', targetRel); - continue; - } catch { - // Target doesn't exist at all - good, we can create symlink - } + const index = JSON.parse(readFileSync(indexPath, 'utf-8')); + // Use the aggregated top-level dependency_locations which already + // contain project-relative paths (e.g. "apps/backend/.venv" instead + // of just ".venv"), avoiding a monorepo path resolution bug. + const depLocations = index?.dependency_locations; + if (Array.isArray(depLocations)) { + const configs: DependencyConfig[] = []; + const seen = new Set(); - // Ensure parent directory exists - const targetDir = path.dirname(targetPath); - if (!existsSync(targetDir)) { - mkdirSync(targetDir, { recursive: true }); - } + for (const dep of depLocations) { + if (!dep || typeof dep !== 'object') continue; + const depObj = dep as Record; + const depType = String(depObj.type || ''); + const relPath = String(depObj.path || ''); + if (!depType || !relPath || seen.has(relPath)) continue; - try { - // Platform-specific symlink creation: - // - Windows: Use 'junction' type which requires absolute paths (no admin rights required) - // - Unix (macOS/Linux): Use relative paths for portability (worktree can be moved) - if (isWindows()) { - symlinkSync(sourcePath, targetPath, 'junction'); - debugLog('[TerminalWorktree] Created junction (Windows):', targetRel, '->', sourcePath); - } else { - // On Unix, use relative symlinks for portability (matches Python implementation) - const relativePath = path.relative(path.dirname(targetPath), sourcePath); - symlinkSync(relativePath, targetPath); - debugLog('[TerminalWorktree] Created symlink (Unix):', targetRel, '->', relativePath); + // Path containment: reject absolute paths and traversals + if (path.isAbsolute(relPath)) continue; + if (relPath.split('/').includes('..') || relPath.split('\\').includes('..')) continue; + + // Defense-in-depth: verify resolved path stays within project + const resolved = path.resolve(projectPath, relPath); + if (!resolved.startsWith(path.resolve(projectPath) + path.sep)) continue; + + seen.add(relPath); + + const strategy = DEFAULT_STRATEGY_MAP[depType] ?? 'skip'; + + // Validate requirementsFile path containment + let reqFile: string | undefined; + if (depObj.requirements_file) { + const rf = String(depObj.requirements_file); + const rfParts = rf.split('/'); + const rfPartsWin = rf.split('\\'); + if (!path.isAbsolute(rf) && !rfParts.includes('..') && !rfPartsWin.includes('..')) { + // Defense-in-depth: resolved-path containment (matches relPath check) + const resolvedReq = path.resolve(projectPath, rf); + if (resolvedReq.startsWith(path.resolve(projectPath) + path.sep)) { + reqFile = rf; + } + } + } + + configs.push({ + depType, + strategy, + sourceRelPath: relPath, + requirementsFile: reqFile, + packageManager: depObj.package_manager ? String(depObj.package_manager) : undefined, + }); + } + + if (configs.length > 0) { + return configs; + } } - symlinked.push(targetRel); } catch (error) { - // Symlink creation can fail on some systems (e.g., FAT32 filesystem, or permission issues) - // Log warning but don't fail - worktree is still usable, just without TypeScript checking - // Note: This warning appears in dev console. Users may see TypeScript errors in pre-commit hooks. - debugError('[TerminalWorktree] Could not create symlink for', targetRel, ':', error); - console.warn(`[TerminalWorktree] Warning: Failed to link ${targetRel} - TypeScript checks may fail in this worktree`); + debugError('[TerminalWorktree] Failed to read project index:', error); } } - return symlinked; + // Fallback: hardcoded node_modules-only behavior (same as legacy) + return [ + { depType: 'node_modules', strategy: 'symlink', sourceRelPath: 'node_modules' }, + { depType: 'node_modules', strategy: 'symlink', sourceRelPath: 'apps/frontend/node_modules' }, + ]; +} + +/** + * Set up dependencies in a worktree using strategy-based dispatch. + * + * Reads dependency configs from the project index and applies the correct + * strategy for each: symlink, recreate, copy, or skip. + * + * All operations are non-blocking on failure — errors are logged but never thrown. + * + * @param projectPath - The main project directory + * @param worktreePath - Path to the worktree + * @returns Array of successfully processed dependency relative paths + */ +async function setupWorktreeDependencies(projectPath: string, worktreePath: string): Promise { + const configs = loadDependencyConfigs(projectPath); + const processed: string[] = []; + + for (const config of configs) { + try { + let performed = false; + switch (config.strategy) { + case 'symlink': + performed = applySymlinkStrategy(projectPath, worktreePath, config); + break; + case 'recreate': + performed = await applyRecreateStrategy(projectPath, worktreePath, config); + break; + case 'copy': + performed = applyCopyStrategy(projectPath, worktreePath, config); + break; + case 'skip': + debugLog('[TerminalWorktree] Skipping', config.depType, `(${config.sourceRelPath}) - skip strategy`); + continue; // Don't record skipped entries in processed list + } + if (performed) processed.push(config.sourceRelPath); + } catch (error) { + debugError('[TerminalWorktree] Failed to apply', config.strategy, 'strategy for', config.sourceRelPath, ':', error); + console.warn(`[TerminalWorktree] Warning: Failed to set up ${config.sourceRelPath}`); + } + } + + return processed; +} + +/** + * Apply symlink strategy: create a symlink (or Windows junction) from worktree to project source. + * Reuses the existing platform-specific symlink creation pattern. + */ +function applySymlinkStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): boolean { + const sourcePath = path.join(projectPath, config.sourceRelPath); + const targetPath = path.join(worktreePath, config.sourceRelPath); + + if (!existsSync(sourcePath)) { + debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- source missing'); + return false; + } + + if (existsSync(targetPath)) { + debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists'); + 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 + } + + const targetDir = path.dirname(targetPath); + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + + try { + if (isWindows()) { + symlinkSync(sourcePath, targetPath, 'junction'); + debugLog('[TerminalWorktree] Created junction (Windows):', config.sourceRelPath, '->', sourcePath); + } else { + const relativePath = path.relative(path.dirname(targetPath), sourcePath); + symlinkSync(relativePath, targetPath); + debugLog('[TerminalWorktree] Created symlink (Unix):', config.sourceRelPath, '->', relativePath); + } + return true; + } catch (error) { + debugError('[TerminalWorktree] Could not create symlink for', config.sourceRelPath, ':', error); + console.warn(`[TerminalWorktree] Warning: Failed to link ${config.sourceRelPath}`); + return false; + } +} + +/** + * 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. + */ +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; + } + + // Detect Python executable from the source venv or fall back to system Python + const sourceVenv = path.join(projectPath, config.sourceRelPath); + let pythonExec = isWindows() ? 'python' : 'python3'; + + if (existsSync(sourceVenv)) { + const unixCandidate = path.join(sourceVenv, 'bin', 'python'); + const winCandidate = path.join(sourceVenv, 'Scripts', 'python.exe'); + if (existsSync(unixCandidate)) { + pythonExec = unixCandidate; + } else if (existsSync(winCandidate)) { + pythonExec = winCandidate; + } + } + + // Create the venv + try { + debugLog('[TerminalWorktree] Creating venv at', config.sourceRelPath); + await execFileAsync(pythonExec, ['-m', 'venv', venvPath], { + encoding: 'utf-8', + timeout: 120000, + }); + } catch (error) { + if (isTimeoutError(error)) { + debugError('[TerminalWorktree] venv creation timed out for', config.sourceRelPath); + console.warn(`[TerminalWorktree] Warning: venv creation timed out for ${config.sourceRelPath}`); + } else { + debugError('[TerminalWorktree] venv creation failed for', config.sourceRelPath, ':', error); + console.warn(`[TerminalWorktree] Warning: Could not create venv at ${config.sourceRelPath}`); + } + // Clean up partial venv so retries aren't blocked + if (existsSync(venvPath)) { + try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ } + } + return false; + } + + // Install from requirements file if specified + if (config.requirementsFile) { + const reqPath = path.join(projectPath, config.requirementsFile); + if (existsSync(reqPath)) { + const pipExec = isWindows() + ? path.join(venvPath, 'Scripts', 'pip.exe') + : path.join(venvPath, 'bin', 'pip'); + + // Build install command based on file type + const reqBasename = path.basename(config.requirementsFile); + let installArgs: string[] | null; + if (reqBasename === 'pyproject.toml') { + // Snapshot-install from worktree copy (non-editable to avoid + // symlinking back to the main project source tree). + const worktreeReq = path.join(worktreePath, config.requirementsFile!); + const installDir = existsSync(worktreeReq) ? path.dirname(worktreeReq) : path.dirname(reqPath); + installArgs = ['install', installDir]; + } else if (reqBasename === 'Pipfile') { + debugLog('[TerminalWorktree] Skipping Pipfile-based install (use pipenv in worktree)'); + installArgs = null; + } else { + installArgs = ['install', '-r', reqPath]; + } + + if (installArgs) { + try { + debugLog('[TerminalWorktree] Installing deps from', config.requirementsFile); + await execFileAsync(pipExec, installArgs, { + encoding: 'utf-8', + timeout: 120000, + }); + } catch (error) { + if (isTimeoutError(error)) { + debugError('[TerminalWorktree] pip install timed out for', config.requirementsFile); + console.warn(`[TerminalWorktree] Warning: Dependency install timed out for ${config.requirementsFile}`); + } else { + debugError('[TerminalWorktree] pip install failed:', error); + } + // Clean up broken venv so retries aren't blocked + if (existsSync(venvPath)) { + try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ } + } + return false; + } + } + } + } + + debugLog('[TerminalWorktree] Recreated venv at', config.sourceRelPath); + return true; +} + +/** + * Apply copy strategy: copy a file or directory from project to worktree. + */ +function applyCopyStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): boolean { + const sourcePath = path.join(projectPath, config.sourceRelPath); + const targetPath = path.join(worktreePath, config.sourceRelPath); + + if (!existsSync(sourcePath)) { + debugLog('[TerminalWorktree] Skipping copy', config.sourceRelPath, '- source missing'); + return false; + } + + if (existsSync(targetPath)) { + debugLog('[TerminalWorktree] Skipping copy', config.sourceRelPath, '- target exists'); + return false; + } + + const targetDir = path.dirname(targetPath); + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + + try { + if (statSync(sourcePath).isDirectory()) { + cpSync(sourcePath, targetPath, { recursive: true }); + } else { + copyFileSync(sourcePath, targetPath); + } + debugLog('[TerminalWorktree] Copied', config.sourceRelPath, 'to worktree'); + return true; + } catch (error) { + debugError('[TerminalWorktree] Could not copy', config.sourceRelPath, ':', error); + console.warn(`[TerminalWorktree] Warning: Could not copy ${config.sourceRelPath}`); + return false; + } } /** * Symlink the project root's .claude/ directory into a terminal worktree. * This enables Claude Code features (settings, commands, memory) in worktree terminals. - * Follows the same pattern as symlinkNodeModulesToWorktree(). + * Follows the same pattern as setupWorktreeDependencies(). */ function symlinkClaudeConfigToWorktree(projectPath: string, worktreePath: string): string[] { const symlinked: string[] = []; @@ -572,11 +834,11 @@ async function createTerminalWorktree( debugLog('[TerminalWorktree] Created worktree in detached HEAD mode from', baseRef); } - // Symlink node_modules for TypeScript and tooling support + // Set up dependencies (node_modules, venvs, etc.) for tooling support // This allows pre-commit hooks to run typecheck without npm install in worktree - const symlinkedModules = symlinkNodeModulesToWorktree(projectPath, worktreePath); - if (symlinkedModules.length > 0) { - debugLog('[TerminalWorktree] Symlinked dependencies:', symlinkedModules.join(', ')); + const setupDeps = await setupWorktreeDependencies(projectPath, worktreePath); + if (setupDeps.length > 0) { + debugLog('[TerminalWorktree] Set up worktree dependencies:', setupDeps.join(', ')); } // Symlink .claude/ config for Claude Code features (settings, commands, memory) diff --git a/tests/test_worktree_dependencies.py b/tests/test_worktree_dependencies.py new file mode 100644 index 00000000..4d5b6b89 --- /dev/null +++ b/tests/test_worktree_dependencies.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +""" +Tests for Worktree Dependency Strategy +======================================= + +Tests the dependency_strategy.py and models.py functionality including: +- DependencyStrategy enum values +- DependencyShareConfig dataclass +- DEFAULT_STRATEGY_MAP entries +- get_dependency_configs() with various inputs +- ServiceAnalyzer._detect_dependency_locations() +- setup_worktree_dependencies() strategy dispatch +- symlink_node_modules_to_worktree() backward compatibility +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from core.workspace.dependency_strategy import ( + DEFAULT_STRATEGY_MAP, + get_dependency_configs, +) +from core.workspace.models import DependencyShareConfig, DependencyStrategy + + +class TestDependencyStrategy: + """Tests for DependencyStrategy enum.""" + + def test_enum_has_symlink(self): + """SYMLINK strategy exists.""" + assert DependencyStrategy.SYMLINK.value == "symlink" + + def test_enum_has_recreate(self): + """RECREATE strategy exists.""" + assert DependencyStrategy.RECREATE.value == "recreate" + + def test_enum_has_copy(self): + """COPY strategy exists.""" + assert DependencyStrategy.COPY.value == "copy" + + def test_enum_has_skip(self): + """SKIP strategy exists.""" + assert DependencyStrategy.SKIP.value == "skip" + + def test_enum_has_exactly_four_members(self): + """Enum has exactly 4 strategies.""" + assert len(DependencyStrategy) == 4 + + +class TestDependencyShareConfig: + """Tests for DependencyShareConfig dataclass.""" + + def test_create_with_required_fields(self): + """Config creates with required fields only.""" + config = DependencyShareConfig( + dep_type="node_modules", + strategy=DependencyStrategy.SYMLINK, + source_rel_path="node_modules", + ) + assert config.dep_type == "node_modules" + assert config.strategy == DependencyStrategy.SYMLINK + assert config.source_rel_path == "node_modules" + assert config.requirements_file is None + assert config.package_manager is None + + def test_create_with_all_fields(self): + """Config creates with all fields populated.""" + config = DependencyShareConfig( + dep_type="venv", + strategy=DependencyStrategy.RECREATE, + source_rel_path=".venv", + requirements_file="requirements.txt", + package_manager="uv", + ) + assert config.dep_type == "venv" + assert config.strategy == DependencyStrategy.RECREATE + assert config.source_rel_path == ".venv" + assert config.requirements_file == "requirements.txt" + assert config.package_manager == "uv" + + +class TestDefaultStrategyMap: + """Tests for DEFAULT_STRATEGY_MAP.""" + + 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_dot_venv_is_recreate(self): + """.venv maps to RECREATE.""" + assert DEFAULT_STRATEGY_MAP[".venv"] == DependencyStrategy.RECREATE + + def test_vendor_php_is_symlink(self): + """vendor_php maps to SYMLINK.""" + assert DEFAULT_STRATEGY_MAP["vendor_php"] == DependencyStrategy.SYMLINK + + def test_vendor_bundle_is_symlink(self): + """vendor_bundle maps to SYMLINK.""" + assert DEFAULT_STRATEGY_MAP["vendor_bundle"] == DependencyStrategy.SYMLINK + + def test_cargo_target_is_skip(self): + """cargo_target maps to SKIP.""" + assert DEFAULT_STRATEGY_MAP["cargo_target"] == DependencyStrategy.SKIP + + def test_go_modules_is_skip(self): + """go_modules maps to SKIP.""" + assert DEFAULT_STRATEGY_MAP["go_modules"] == DependencyStrategy.SKIP + + +class TestGetDependencyConfigs: + """Tests for get_dependency_configs().""" + + def test_with_mock_project_index(self): + """Returns correct strategy per dependency type from project index.""" + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "node_modules", "service": "frontend"}, + { + "type": "venv", + "path": "apps/backend/.venv", + "requirements_file": "requirements.txt", + "package_manager": "uv", + "service": "backend", + }, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 2 + + 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"].source_rel_path == "apps/backend/.venv" + assert by_type["venv"].requirements_file == "requirements.txt" + assert by_type["venv"].package_manager == "uv" + + def test_none_returns_fallback(self): + """None project_index returns fallback node_modules configs.""" + configs = get_dependency_configs(None) + + assert len(configs) == 2 + assert configs[0].dep_type == "node_modules" + assert configs[0].strategy == DependencyStrategy.SYMLINK + assert configs[0].source_rel_path == "node_modules" + assert configs[1].dep_type == "node_modules" + assert configs[1].source_rel_path == "apps/frontend/node_modules" + + def test_missing_dependency_locations_returns_fallback(self): + """Project index without dependency_locations returns fallback.""" + project_index = { + "services": { + "frontend": { + "language": "typescript", + } + } + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 2 + assert configs[0].dep_type == "node_modules" + assert configs[0].strategy == DependencyStrategy.SYMLINK + + def test_empty_dependency_locations_returns_fallback(self): + """Project index with empty dependency_locations returns fallback.""" + configs = get_dependency_configs({"dependency_locations": []}) + + assert len(configs) == 2 + assert configs[0].dep_type == "node_modules" + + def test_unknown_dep_type_defaults_to_skip(self): + """Unknown dependency type defaults to SKIP strategy.""" + project_index = { + "dependency_locations": [ + {"type": "unknown_ecosystem", "path": "deps/", "service": "app"}, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].dep_type == "unknown_ecosystem" + assert configs[0].strategy == DependencyStrategy.SKIP + + def test_no_dependency_locations_returns_fallback(self): + """Project index with no dependency_locations falls back.""" + project_index = { + "services": { + "backend": { + "language": "python", + "dependency_locations": [], + } + } + } + + # No top-level dependency_locations means fallback + configs = get_dependency_configs(project_index) + + assert len(configs) == 2 + assert configs[0].dep_type == "node_modules" + + def test_multiple_python_services_own_venv_configs(self): + """Multiple Python services each get their own venv config with correct paths.""" + project_index = { + "dependency_locations": [ + { + "type": "venv", + "path": "services/api/.venv", + "requirements_file": "requirements.txt", + "package_manager": "pip", + "service": "api", + }, + { + "type": "venv", + "path": "services/worker/.venv", + "requirements_file": "pyproject.toml", + "package_manager": "uv", + "service": "worker", + }, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 2 + + paths = {c.source_rel_path: c for c in configs} + assert "services/api/.venv" in paths + assert "services/worker/.venv" in paths + + api_config = paths["services/api/.venv"] + assert api_config.strategy == DependencyStrategy.RECREATE + 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.package_manager == "uv" + assert worker_config.requirements_file == "pyproject.toml" + + def test_deduplicates_by_path(self): + """Duplicate paths are deduplicated.""" + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "node_modules", "service": "frontend"}, + {"type": "node_modules", "path": "node_modules", "service": "storybook"}, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].dep_type == "node_modules" + + def test_path_traversal_rejected(self): + """Paths with '..' components are rejected for containment safety.""" + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "../../etc/passwd", "service": "evil"}, + {"type": "node_modules", "path": "safe/node_modules", "service": "ok"}, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].source_rel_path == "safe/node_modules" + + def test_windows_backslash_traversal_rejected(self): + """Windows-style backslash traversals are rejected.""" + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "..\\..\\evil", "service": "evil"}, + {"type": "node_modules", "path": "safe/node_modules", "service": "ok"}, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].source_rel_path == "safe/node_modules" + + def test_absolute_posix_path_rejected(self): + """Absolute POSIX paths are rejected.""" + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "/etc/passwd", "service": "evil"}, + {"type": "node_modules", "path": "safe/node_modules", "service": "ok"}, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].source_rel_path == "safe/node_modules" + + def test_absolute_windows_path_rejected(self): + """Absolute Windows paths are rejected.""" + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "C:\\Windows", "service": "evil"}, + {"type": "node_modules", "path": "safe/node_modules", "service": "ok"}, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].source_rel_path == "safe/node_modules" + + def test_requirements_file_traversal_rejected(self): + """requirements_file with '..' traversal is nullified.""" + project_index = { + "dependency_locations": [ + { + "type": "venv", + "path": ".venv", + "requirements_file": "../../etc/passwd", + "service": "evil", + }, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].source_rel_path == ".venv" + assert configs[0].requirements_file is None + + def test_requirements_file_absolute_path_rejected(self): + """requirements_file with absolute path is nullified.""" + project_index = { + "dependency_locations": [ + { + "type": "venv", + "path": ".venv", + "requirements_file": "/etc/passwd", + "service": "evil", + }, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].requirements_file is None + + def test_requirements_file_valid_preserved(self): + """Valid requirements_file is preserved.""" + project_index = { + "dependency_locations": [ + { + "type": "venv", + "path": ".venv", + "requirements_file": "requirements.txt", + "package_manager": "pip", + "service": "backend", + }, + ] + } + + configs = get_dependency_configs(project_index) + + assert len(configs) == 1 + assert configs[0].requirements_file == "requirements.txt" + + def test_resolved_path_containment_with_project_dir(self, tmp_path): + """Resolved-path containment check rejects escaping paths when project_dir is set.""" + # Create a symlink inside tmp_path that points outside it + escape_dir = tmp_path / "escape" + escape_dir.mkdir() + outside = tmp_path.parent / "outside_target" + outside.mkdir(exist_ok=True) + (escape_dir / "node_modules").symlink_to(outside) + + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "escape/node_modules", "service": "evil"}, + {"type": "node_modules", "path": "safe_modules", "service": "ok"}, + ] + } + + configs = get_dependency_configs(project_index, project_dir=tmp_path) + + # escape/node_modules resolves outside project_dir, so it's rejected + assert len(configs) == 1 + assert configs[0].source_rel_path == "safe_modules" + + def test_resolved_path_valid_with_project_dir(self, tmp_path): + """Valid paths pass both syntactic and resolved-path checks with project_dir.""" + (tmp_path / "node_modules").mkdir() + + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "node_modules", "service": "frontend"}, + ] + } + + configs = get_dependency_configs(project_index, project_dir=tmp_path) + + assert len(configs) == 1 + assert configs[0].source_rel_path == "node_modules" + + def test_resolved_requirements_file_containment_with_project_dir(self, tmp_path): + """Resolved-path containment rejects requirements_file escaping project_dir.""" + # Create a symlink that escapes project_dir + escape_dir = tmp_path / "reqs" + escape_dir.mkdir() + outside = tmp_path.parent / "outside_reqs" + outside.mkdir(exist_ok=True) + (escape_dir / "requirements.txt").symlink_to(outside / "evil.txt") + + project_index = { + "dependency_locations": [ + { + "type": "venv", + "path": ".venv", + "requirements_file": "reqs/requirements.txt", + "service": "backend", + }, + ] + } + + configs = get_dependency_configs(project_index, project_dir=tmp_path) + + assert len(configs) == 1 + assert configs[0].requirements_file is None + + +class TestServiceAnalyzerDependencyLocations: + """Tests for ServiceAnalyzer._detect_dependency_locations().""" + + def test_detects_node_modules_when_package_json_exists(self, tmp_path: Path): + """Detects node_modules directory when package.json exists.""" + from analysis.analyzers.service_analyzer import ServiceAnalyzer + + (tmp_path / "package.json").write_text("{}") + (tmp_path / "node_modules").mkdir() + + analyzer = ServiceAnalyzer(tmp_path, "frontend") + analyzer._detect_dependency_locations() + + locations = analyzer.analysis["dependency_locations"] + node_entry = next(l for l in locations if l["type"] == "node_modules") + assert node_entry["exists"] is True + assert node_entry["path"] == "node_modules" + + def test_detects_venv_when_requirements_txt_exists(self, tmp_path: Path): + """Detects .venv directory when requirements.txt exists.""" + from analysis.analyzers.service_analyzer import ServiceAnalyzer + + (tmp_path / "requirements.txt").write_text("flask") + (tmp_path / ".venv").mkdir() + + analyzer = ServiceAnalyzer(tmp_path, "backend") + analyzer._detect_dependency_locations() + + locations = analyzer.analysis["dependency_locations"] + venv_entry = next(l for l in locations if l["type"] == "venv") + assert venv_entry["exists"] is True + assert venv_entry["path"] == ".venv" + assert venv_entry["requirements_file"] == "requirements.txt" + + def test_returns_no_local_deps_for_go_project(self, tmp_path: Path): + """Returns no dependency locations for Go project with no package.json.""" + from analysis.analyzers.service_analyzer import ServiceAnalyzer + + (tmp_path / "go.mod").write_text("module example.com/app") + + analyzer = ServiceAnalyzer(tmp_path, "goapp") + analyzer._detect_dependency_locations() + + locations = analyzer.analysis["dependency_locations"] + # No entries — node_modules only appears when package.json exists + assert len(locations) == 0 + + +class TestSetupWorktreeDependencies: + """Tests for setup_worktree_dependencies().""" + + def test_symlink_created_for_node_modules(self, tmp_path: Path): + """SYMLINK strategy creates symlink for node_modules.""" + from core.workspace.setup import setup_worktree_dependencies + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "node_modules").mkdir() + (project_dir / "node_modules" / "react").mkdir() + + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "node_modules", "service": "frontend"}, + ] + } + + results = setup_worktree_dependencies(project_dir, worktree_path, project_index) + + assert "symlink" in results + assert "node_modules" in results["symlink"] + target = worktree_path / "node_modules" + assert target.exists() or target.is_symlink() + + def test_none_project_index_uses_fallback(self, tmp_path: Path): + """None project_index uses fallback node_modules behavior.""" + from core.workspace.setup import setup_worktree_dependencies + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "node_modules").mkdir() + + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + results = setup_worktree_dependencies(project_dir, worktree_path, None) + + assert "symlink" in results + assert "node_modules" in results["symlink"] + + def test_source_missing_skipped_gracefully(self, tmp_path: Path): + """Source dependency that doesn't exist is skipped gracefully.""" + from core.workspace.setup import setup_worktree_dependencies + + project_dir = tmp_path / "project" + project_dir.mkdir() + # No node_modules directory created + + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "node_modules", "service": "frontend"}, + ] + } + + # Should not raise + results = setup_worktree_dependencies(project_dir, worktree_path, project_index) + + # Source missing → no work performed, so not recorded in results + symlink_results = results.get("symlink", []) + assert "node_modules" not in symlink_results + # No symlink was created + assert not (worktree_path / "node_modules").exists() + + def test_target_already_exists_skipped_gracefully(self, tmp_path: Path): + """Target that already exists is skipped gracefully.""" + from core.workspace.setup import setup_worktree_dependencies + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "node_modules").mkdir() + + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + # Pre-create target + (worktree_path / "node_modules").mkdir() + + project_index = { + "dependency_locations": [ + {"type": "node_modules", "path": "node_modules", "service": "frontend"}, + ] + } + + # Should not raise + results = setup_worktree_dependencies(project_dir, worktree_path, project_index) + + assert "symlink" in results + # Target is still a real directory, not a symlink + assert (worktree_path / "node_modules").is_dir() + assert not (worktree_path / "node_modules").is_symlink() + + +class TestSymlinkNodeModulesToWorktreeBackwardCompat: + """Tests for symlink_node_modules_to_worktree() backward compatibility.""" + + def test_wrapper_still_works(self, tmp_path: Path): + """symlink_node_modules_to_worktree() still works as a wrapper.""" + from core.workspace.setup import symlink_node_modules_to_worktree + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "node_modules").mkdir() + + worktree_path = tmp_path / "worktree" + worktree_path.mkdir() + + result = symlink_node_modules_to_worktree(project_dir, worktree_path) + + assert isinstance(result, list) + assert "node_modules" in result