diff --git a/apps/backend/agents/test_refactoring.py b/apps/backend/agents/test_refactoring.py
deleted file mode 100644
index 965dbc2d..00000000
--- a/apps/backend/agents/test_refactoring.py
+++ /dev/null
@@ -1,159 +0,0 @@
-#!/usr/bin/env python3
-"""
-Verification script for agent module refactoring.
-
-This script verifies that:
-1. All modules can be imported
-2. All public API functions are accessible
-3. Backwards compatibility is maintained
-"""
-
-import sys
-from pathlib import Path
-
-# Add parent directory to path
-sys.path.insert(0, str(Path(__file__).parent.parent))
-
-
-def test_imports():
- """Test that all modules can be imported."""
- print("Testing module imports...")
-
- # Test base module
- from agents import base
-
- assert hasattr(base, "AUTO_CONTINUE_DELAY_SECONDS")
- assert hasattr(base, "HUMAN_INTERVENTION_FILE")
- print(" ✓ agents.base")
-
- # Test utils module
- from agents import utils
-
- assert hasattr(utils, "get_latest_commit")
- assert hasattr(utils, "load_implementation_plan")
- print(" ✓ agents.utils")
-
- # Test memory module
- from agents import memory
-
- assert hasattr(memory, "save_session_memory")
- assert hasattr(memory, "get_graphiti_context")
- print(" ✓ agents.memory")
-
- # Test session module
- from agents import session
-
- assert hasattr(session, "run_agent_session")
- assert hasattr(session, "post_session_processing")
- print(" ✓ agents.session")
-
- # Test planner module
- from agents import planner
-
- assert hasattr(planner, "run_followup_planner")
- print(" ✓ agents.planner")
-
- # Test coder module
- from agents import coder
-
- assert hasattr(coder, "run_autonomous_agent")
- print(" ✓ agents.coder")
-
- print("\n✓ All module imports successful!\n")
-
-
-def test_public_api():
- """Test that the public API is accessible."""
- print("Testing public API...")
-
- # Test main agent module exports
- import agents
-
- required_functions = [
- "run_autonomous_agent",
- "run_followup_planner",
- "save_session_memory",
- "get_graphiti_context",
- "run_agent_session",
- "post_session_processing",
- "get_latest_commit",
- "load_implementation_plan",
- ]
-
- for func_name in required_functions:
- assert hasattr(agents, func_name), f"Missing function: {func_name}"
- print(f" ✓ agents.{func_name}")
-
- print("\n✓ All public API functions accessible!\n")
-
-
-def test_backwards_compatibility():
- """Test that the old agent.py facade maintains backwards compatibility."""
- print("Testing backwards compatibility...")
-
- # Test that agent.py can be imported
- import agent
-
- required_functions = [
- "run_autonomous_agent",
- "run_followup_planner",
- "save_session_memory",
- "save_session_to_graphiti",
- "run_agent_session",
- "post_session_processing",
- ]
-
- for func_name in required_functions:
- assert hasattr(agent, func_name), (
- f"Missing function in agent module: {func_name}"
- )
- print(f" ✓ agent.{func_name}")
-
- print("\n✓ Backwards compatibility maintained!\n")
-
-
-def test_module_structure():
- """Test that the module structure is correct."""
- print("Testing module structure...")
-
- from pathlib import Path
-
- agents_dir = Path(__file__).parent
-
- required_files = [
- "__init__.py",
- "base.py",
- "utils.py",
- "memory.py",
- "session.py",
- "planner.py",
- "coder.py",
- ]
-
- for filename in required_files:
- filepath = agents_dir / filename
- assert filepath.exists(), f"Missing file: {filename}"
- print(f" ✓ agents/{filename}")
-
- print("\n✓ Module structure correct!\n")
-
-
-if __name__ == "__main__":
- try:
- test_module_structure()
- test_imports()
- test_public_api()
- test_backwards_compatibility()
-
- print("=" * 60)
- print("✓ ALL TESTS PASSED - Refactoring verified!")
- print("=" * 60)
-
- except AssertionError as e:
- print(f"\n✗ TEST FAILED: {e}")
- sys.exit(1)
- except ImportError as e:
- print(f"\n✗ IMPORT ERROR: {e}")
- print("Note: Some imports may fail due to missing dependencies.")
- print("This is expected in test environments.")
- sys.exit(0) # Don't fail on import errors (expected in test env)
diff --git a/apps/backend/analysis/__init__.py b/apps/backend/analysis/__init__.py
index 49d59ee5..5cc83c1f 100644
--- a/apps/backend/analysis/__init__.py
+++ b/apps/backend/analysis/__init__.py
@@ -23,7 +23,8 @@ from .ci_discovery import CIDiscovery
from .project_analyzer import ProjectAnalyzer
from .risk_classifier import RiskClassifier
from .security_scanner import SecurityScanner
-from .test_discovery import TestDiscovery
+
+# TestDiscovery was removed - tests are now co-located in their respective modules
# insight_extractor is a module with functions, not a class, so don't import it here
# Import it directly when needed: from analysis import insight_extractor
@@ -37,5 +38,5 @@ __all__ = [
"RiskClassifier",
"SecurityScanner",
"CIDiscovery",
- "TestDiscovery",
+ # "TestDiscovery", # Removed - tests now co-located in their modules
]
diff --git a/apps/backend/analysis/test_discovery.py b/apps/backend/analysis/test_discovery.py
deleted file mode 100644
index 0ebafa55..00000000
--- a/apps/backend/analysis/test_discovery.py
+++ /dev/null
@@ -1,690 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test Discovery Module
-=====================
-
-Detects test frameworks, test commands, and test directories in a project.
-This module analyzes project configuration files to discover how tests
-should be run.
-
-The test discovery results are used by:
-- QA Agent: To determine what test commands to run
-- Test Creator: To know what framework to use when creating tests
-- Planner: To include correct test commands in verification strategy
-
-Usage:
- from test_discovery import TestDiscovery
-
- discovery = TestDiscovery()
- result = discovery.discover(project_dir)
-
- print(f"Test frameworks: {result['frameworks']}")
- print(f"Test command: {result['test_command']}")
-"""
-
-from __future__ import annotations
-
-import json
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any
-
-# =============================================================================
-# DATA CLASSES
-# =============================================================================
-
-
-@dataclass
-class TestFramework:
- """
- Represents a detected test framework.
-
- Attributes:
- name: Name of the framework (e.g., "pytest", "jest", "vitest")
- type: Type of testing (unit, integration, e2e, all)
- command: Command to run tests
- config_file: Configuration file if found
- version: Version if detected
- coverage_command: Command for coverage if available
- """
-
- __test__ = False # Prevent pytest from collecting this as a test class
-
- name: str
- type: str # unit, integration, e2e, all
- command: str
- config_file: str | None = None
- version: str | None = None
- coverage_command: str | None = None
-
-
-@dataclass
-class TestDiscoveryResult:
- """
- Result of test framework discovery.
-
- Attributes:
- frameworks: List of detected test frameworks
- test_command: Primary test command to run
- test_directories: Discovered test directories
- package_manager: Detected package manager
- has_tests: Whether any test files were found
- coverage_command: Command for coverage if available
- """
-
- __test__ = False # Prevent pytest from collecting this as a test class
-
- frameworks: list[TestFramework] = field(default_factory=list)
- test_command: str = ""
- test_directories: list[str] = field(default_factory=list)
- package_manager: str = ""
- has_tests: bool = False
- coverage_command: str | None = None
-
-
-# =============================================================================
-# FRAMEWORK DETECTORS
-# =============================================================================
-
-
-# Pattern-based framework detection
-FRAMEWORK_PATTERNS = {
- # JavaScript/TypeScript
- "jest": {
- "config_files": [
- "jest.config.js",
- "jest.config.ts",
- "jest.config.mjs",
- "jest.config.cjs",
- ],
- "package_key": "jest",
- "type": "unit",
- "command": "npx jest",
- "coverage_command": "npx jest --coverage",
- },
- "vitest": {
- "config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
- "package_key": "vitest",
- "type": "unit",
- "command": "npx vitest run",
- "coverage_command": "npx vitest run --coverage",
- },
- "mocha": {
- "config_files": [
- ".mocharc.js",
- ".mocharc.json",
- ".mocharc.yaml",
- ".mocharc.yml",
- ],
- "package_key": "mocha",
- "type": "unit",
- "command": "npx mocha",
- "coverage_command": "npx nyc mocha",
- },
- "playwright": {
- "config_files": ["playwright.config.js", "playwright.config.ts"],
- "package_key": "@playwright/test",
- "type": "e2e",
- "command": "npx playwright test",
- "coverage_command": None,
- },
- "cypress": {
- "config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"],
- "package_key": "cypress",
- "type": "e2e",
- "command": "npx cypress run",
- "coverage_command": None,
- },
- # Python
- "pytest": {
- "config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"],
- "pyproject_key": "pytest",
- "requirements_key": "pytest",
- "type": "all",
- "command": "pytest",
- "coverage_command": "pytest --cov",
- },
- "unittest": {
- "config_files": [],
- "type": "unit",
- "command": "python -m unittest discover",
- "coverage_command": "coverage run -m unittest discover",
- },
- # Rust
- "cargo_test": {
- "config_files": ["Cargo.toml"],
- "type": "all",
- "command": "cargo test",
- "coverage_command": "cargo tarpaulin",
- },
- # Go
- "go_test": {
- "config_files": ["go.mod"],
- "type": "all",
- "command": "go test ./...",
- "coverage_command": "go test -cover ./...",
- },
- # Ruby
- "rspec": {
- "config_files": [".rspec", "spec/spec_helper.rb"],
- "gemfile_key": "rspec",
- "type": "all",
- "command": "bundle exec rspec",
- "coverage_command": "bundle exec rspec --format documentation",
- },
- "minitest": {
- "config_files": [],
- "gemfile_key": "minitest",
- "type": "unit",
- "command": "bundle exec rake test",
- "coverage_command": None,
- },
-}
-
-
-# =============================================================================
-# TEST DISCOVERY
-# =============================================================================
-
-
-class TestDiscovery:
- """
- Discovers test frameworks and configurations in a project.
-
- Analyzes:
- - Package files (package.json, pyproject.toml, Cargo.toml, etc.)
- - Configuration files (jest.config.js, pytest.ini, etc.)
- - Directory structure (tests/, spec/, __tests__/)
- """
-
- __test__ = False # Prevent pytest from collecting this as a test class
-
- def __init__(self) -> None:
- """Initialize the test discovery."""
- self._cache: dict[str, TestDiscoveryResult] = {}
-
- def discover(self, project_dir: Path) -> TestDiscoveryResult:
- """
- Discover test frameworks and configuration in the project.
-
- Args:
- project_dir: Path to the project root
-
- Returns:
- TestDiscoveryResult with detected frameworks and commands
- """
- project_dir = Path(project_dir)
- cache_key = str(project_dir.resolve())
-
- if cache_key in self._cache:
- return self._cache[cache_key]
-
- result = TestDiscoveryResult()
-
- # Detect package manager
- result.package_manager = self._detect_package_manager(project_dir)
-
- # Discover frameworks based on project type
- if (project_dir / "package.json").exists():
- self._discover_js_frameworks(project_dir, result)
-
- # Check for Python project indicators
- python_indicators = [
- project_dir / "pyproject.toml",
- project_dir / "requirements.txt",
- project_dir / "setup.py",
- project_dir / "pytest.ini",
- project_dir / "conftest.py",
- project_dir / "tests" / "conftest.py",
- ]
- if any(p.exists() for p in python_indicators):
- self._discover_python_frameworks(project_dir, result)
-
- if (project_dir / "Cargo.toml").exists():
- self._discover_rust_frameworks(project_dir, result)
- if (project_dir / "go.mod").exists():
- self._discover_go_frameworks(project_dir, result)
- if (project_dir / "Gemfile").exists():
- self._discover_ruby_frameworks(project_dir, result)
-
- # Find test directories
- result.test_directories = self._find_test_directories(project_dir)
-
- # Check if tests exist
- result.has_tests = self._has_test_files(project_dir, result.test_directories)
-
- # Set primary test command
- if result.frameworks:
- result.test_command = result.frameworks[0].command
-
- # Set coverage command from first framework that has one
- if not result.coverage_command:
- for framework in result.frameworks:
- if framework.coverage_command:
- result.coverage_command = framework.coverage_command
- break
-
- self._cache[cache_key] = result
- return result
-
- def _detect_package_manager(self, project_dir: Path) -> str:
- """Detect the package manager used by the project."""
- if (project_dir / "pnpm-lock.yaml").exists():
- return "pnpm"
- if (project_dir / "yarn.lock").exists():
- return "yarn"
- if (project_dir / "package-lock.json").exists():
- return "npm"
- if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
- return "bun"
- if (project_dir / "uv.lock").exists():
- return "uv"
- if (project_dir / "poetry.lock").exists():
- return "poetry"
- if (project_dir / "Pipfile.lock").exists():
- return "pipenv"
- if (project_dir / "Cargo.lock").exists():
- return "cargo"
- if (project_dir / "go.sum").exists():
- return "go"
- if (project_dir / "Gemfile.lock").exists():
- return "bundler"
- return ""
-
- def _discover_js_frameworks(
- self, project_dir: Path, result: TestDiscoveryResult
- ) -> None:
- """Discover JavaScript/TypeScript test frameworks."""
- package_json = project_dir / "package.json"
- if not package_json.exists():
- return
-
- try:
- with open(package_json, encoding="utf-8") as f:
- pkg = json.load(f)
- except (OSError, json.JSONDecodeError, UnicodeDecodeError):
- return
-
- deps = pkg.get("dependencies", {})
- dev_deps = pkg.get("devDependencies", {})
- all_deps = {**deps, **dev_deps}
- scripts = pkg.get("scripts", {})
-
- # Check for test frameworks in dependencies
- for name, pattern in FRAMEWORK_PATTERNS.items():
- if "package_key" not in pattern:
- continue
-
- if pattern["package_key"] in all_deps:
- # Check for config file
- config_file = None
- for cf in pattern.get("config_files", []):
- if (project_dir / cf).exists():
- config_file = cf
- break
-
- # Get version
- version = all_deps.get(pattern["package_key"], "")
- if version.startswith("^") or version.startswith("~"):
- version = version[1:]
-
- # Determine command - prefer npm scripts if available
- command = pattern["command"]
- if "test" in scripts and pattern["package_key"] in scripts.get(
- "test", ""
- ):
- command = f"{result.package_manager or 'npm'} test"
-
- result.frameworks.append(
- TestFramework(
- name=name,
- type=pattern["type"],
- command=command,
- config_file=config_file,
- version=version,
- coverage_command=pattern.get("coverage_command"),
- )
- )
-
- # Check npm scripts for test commands
- if not result.frameworks and "test" in scripts:
- test_script = scripts["test"]
- if (
- test_script
- and test_script != 'echo "Error: no test specified" && exit 1'
- ):
- # Try to infer framework from script
- framework_name = "npm_test"
- framework_type = "unit"
-
- if "jest" in test_script:
- framework_name = "jest"
- elif "vitest" in test_script:
- framework_name = "vitest"
- elif "mocha" in test_script:
- framework_name = "mocha"
- elif "playwright" in test_script:
- framework_name = "playwright"
- framework_type = "e2e"
- elif "cypress" in test_script:
- framework_name = "cypress"
- framework_type = "e2e"
-
- result.frameworks.append(
- TestFramework(
- name=framework_name,
- type=framework_type,
- command=f"{result.package_manager or 'npm'} test",
- config_file=None,
- )
- )
-
- def _discover_python_frameworks(
- self, project_dir: Path, result: TestDiscoveryResult
- ) -> None:
- """Discover Python test frameworks."""
- # Check for pytest.ini first (explicit pytest config)
- if (project_dir / "pytest.ini").exists():
- if not any(f.name == "pytest" for f in result.frameworks):
- result.frameworks.append(
- TestFramework(
- name="pytest",
- type="all",
- command="pytest",
- config_file="pytest.ini",
- )
- )
-
- # Check pyproject.toml
- pyproject = project_dir / "pyproject.toml"
- if pyproject.exists():
- content = pyproject.read_text(encoding="utf-8")
-
- # Check for pytest
- if "pytest" in content:
- if not any(f.name == "pytest" for f in result.frameworks):
- config_file = (
- "pyproject.toml" if "[tool.pytest" in content else None
- )
- result.frameworks.append(
- TestFramework(
- name="pytest",
- type="all",
- command="pytest",
- config_file=config_file,
- )
- )
-
- # Check requirements.txt
- requirements = project_dir / "requirements.txt"
- if requirements.exists():
- content = requirements.read_text(encoding="utf-8").lower()
- if "pytest" in content and not any(
- f.name == "pytest" for f in result.frameworks
- ):
- result.frameworks.append(
- TestFramework(
- name="pytest",
- type="all",
- command="pytest",
- config_file=None,
- )
- )
-
- # Check for conftest.py (pytest marker)
- conftest_root = project_dir / "conftest.py"
- conftest_tests = project_dir / "tests" / "conftest.py"
- if conftest_root.exists() or conftest_tests.exists():
- if not any(f.name == "pytest" for f in result.frameworks):
- result.frameworks.append(
- TestFramework(
- name="pytest",
- type="all",
- command="pytest",
- config_file="conftest.py",
- )
- )
-
- # Fall back to unittest if test files exist but no framework detected
- if not result.frameworks:
- test_dirs = self._find_test_directories(project_dir)
- if test_dirs:
- result.frameworks.append(
- TestFramework(
- name="unittest",
- type="unit",
- command="python -m unittest discover",
- config_file=None,
- )
- )
-
- def _discover_rust_frameworks(
- self, project_dir: Path, result: TestDiscoveryResult
- ) -> None:
- """Discover Rust test frameworks."""
- cargo_toml = project_dir / "Cargo.toml"
- if cargo_toml.exists():
- result.frameworks.append(
- TestFramework(
- name="cargo_test",
- type="all",
- command="cargo test",
- config_file="Cargo.toml",
- )
- )
-
- def _discover_go_frameworks(
- self, project_dir: Path, result: TestDiscoveryResult
- ) -> None:
- """Discover Go test frameworks."""
- go_mod = project_dir / "go.mod"
- if go_mod.exists():
- result.frameworks.append(
- TestFramework(
- name="go_test",
- type="all",
- command="go test ./...",
- config_file="go.mod",
- )
- )
-
- def _discover_ruby_frameworks(
- self, project_dir: Path, result: TestDiscoveryResult
- ) -> None:
- """Discover Ruby test frameworks."""
- gemfile = project_dir / "Gemfile"
- if not gemfile.exists():
- return
-
- content = gemfile.read_text(encoding="utf-8").lower()
-
- if "rspec" in content or (project_dir / ".rspec").exists():
- result.frameworks.append(
- TestFramework(
- name="rspec",
- type="all",
- command="bundle exec rspec",
- config_file=".rspec" if (project_dir / ".rspec").exists() else None,
- )
- )
- elif "minitest" in content:
- result.frameworks.append(
- TestFramework(
- name="minitest",
- type="unit",
- command="bundle exec rake test",
- config_file=None,
- )
- )
-
- def _find_test_directories(self, project_dir: Path) -> list[str]:
- """Find test directories in the project."""
- test_dir_patterns = [
- "tests",
- "test",
- "spec",
- "__tests__",
- "specs",
- "test_*",
- ]
-
- found_dirs = []
- for pattern in test_dir_patterns:
- if pattern.endswith("*"):
- # Glob pattern
- for d in project_dir.glob(pattern):
- if d.is_dir():
- found_dirs.append(str(d.relative_to(project_dir)))
- else:
- # Exact name
- test_dir = project_dir / pattern
- if test_dir.is_dir():
- found_dirs.append(pattern)
-
- return found_dirs
-
- def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool:
- """Check if any test files exist."""
- test_file_patterns = [
- "**/test_*.py",
- "**/*_test.py",
- "**/*.test.js",
- "**/*.test.ts",
- "**/*.test.tsx",
- "**/*.spec.js",
- "**/*.spec.ts",
- "**/*.spec.tsx",
- "**/test_*.go",
- "**/*_test.go",
- "**/*_test.rs",
- "**/spec/**/*_spec.rb",
- ]
-
- # Check in test directories
- for test_dir in test_directories:
- test_path = project_dir / test_dir
- if test_path.exists():
- for pattern in test_file_patterns:
- if list(test_path.glob(pattern.replace("**/", ""))):
- return True
-
- # Check project-wide
- for pattern in test_file_patterns:
- if list(project_dir.glob(pattern)):
- return True
-
- return False
-
- def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]:
- """Convert result to dictionary for JSON serialization."""
- return {
- "frameworks": [
- {
- "name": f.name,
- "type": f.type,
- "command": f.command,
- "config_file": f.config_file,
- "version": f.version,
- "coverage_command": f.coverage_command,
- }
- for f in result.frameworks
- ],
- "test_command": result.test_command,
- "test_directories": result.test_directories,
- "package_manager": result.package_manager,
- "has_tests": result.has_tests,
- "coverage_command": result.coverage_command,
- }
-
- def clear_cache(self) -> None:
- """Clear the internal cache."""
- self._cache.clear()
-
-
-# =============================================================================
-# CONVENIENCE FUNCTIONS
-# =============================================================================
-
-
-def discover_tests(project_dir: Path) -> TestDiscoveryResult:
- """
- Convenience function to discover tests in a project.
-
- Args:
- project_dir: Path to project root
-
- Returns:
- TestDiscoveryResult with detected frameworks
- """
- discovery = TestDiscovery()
- return discovery.discover(project_dir)
-
-
-def get_test_command(project_dir: Path) -> str:
- """
- Get the primary test command for a project.
-
- Args:
- project_dir: Path to project root
-
- Returns:
- Test command string, or empty string if not found
- """
- discovery = TestDiscovery()
- result = discovery.discover(project_dir)
- return result.test_command
-
-
-def get_test_frameworks(project_dir: Path) -> list[str]:
- """
- Get list of test framework names in a project.
-
- Args:
- project_dir: Path to project root
-
- Returns:
- List of framework names
- """
- discovery = TestDiscovery()
- result = discovery.discover(project_dir)
- return [f.name for f in result.frameworks]
-
-
-# =============================================================================
-# CLI
-# =============================================================================
-
-
-def main() -> None:
- """CLI entry point for testing."""
- import argparse
-
- parser = argparse.ArgumentParser(description="Discover test frameworks")
- parser.add_argument("project_dir", type=Path, help="Path to project root")
- parser.add_argument("--json", action="store_true", help="Output as JSON")
-
- args = parser.parse_args()
-
- discovery = TestDiscovery()
- result = discovery.discover(args.project_dir)
-
- if args.json:
- print(json.dumps(discovery.to_dict(result), indent=2))
- else:
- print(f"Package Manager: {result.package_manager or 'unknown'}")
- print(f"Has Tests: {result.has_tests}")
- print(f"Test Command: {result.test_command or 'none'}")
- print(f"Test Directories: {', '.join(result.test_directories) or 'none'}")
- print(f"Coverage Command: {result.coverage_command or 'none'}")
- print(f"\nFrameworks ({len(result.frameworks)}):")
- for f in result.frameworks:
- print(f" - {f.name} ({f.type})")
- print(f" Command: {f.command}")
- if f.config_file:
- print(f" Config: {f.config_file}")
- if f.version:
- print(f" Version: {f.version}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/apps/backend/core/workspace/__init__.py b/apps/backend/core/workspace/__init__.py
index 5a185ba5..852fb45f 100644
--- a/apps/backend/core/workspace/__init__.py
+++ b/apps/backend/core/workspace/__init__.py
@@ -17,7 +17,6 @@ Public API exported from sub-modules.
"""
import importlib.util
-import sys
from pathlib import Path
# Import merge functions from workspace.py (which coexists with this package)
@@ -28,10 +27,17 @@ _workspace_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_workspace_module)
merge_existing_build = _workspace_module.merge_existing_build
_run_parallel_merges = _workspace_module._run_parallel_merges
+_resolve_git_conflicts_with_ai = _workspace_module._resolve_git_conflicts_with_ai
AI_MERGE_SYSTEM_PROMPT = _workspace_module.AI_MERGE_SYSTEM_PROMPT
_build_merge_prompt = _workspace_module._build_merge_prompt
_check_git_conflicts = _workspace_module._check_git_conflicts
_rebase_spec_branch = _workspace_module._rebase_spec_branch
+_create_merge_progress_callback = _workspace_module._create_merge_progress_callback
+_infer_language_from_path = _workspace_module._infer_language_from_path
+_strip_code_fences = _workspace_module._strip_code_fences
+_try_simple_3way_merge = _workspace_module._try_simple_3way_merge
+_attempt_ai_merge = _workspace_module._attempt_ai_merge
+_merge_file_with_ai_async = _workspace_module._merge_file_with_ai_async
# Models and Enums
# Display Functions
@@ -74,7 +80,9 @@ from .git_utils import (
# Export private names for backward compatibility
_is_process_running,
_validate_merged_syntax,
+ apply_path_mapping,
create_conflict_file_with_git,
+ detect_file_renames,
get_binary_file_content_from_ref,
get_changed_files_from_branch,
get_current_branch,
@@ -91,6 +99,8 @@ from .models import (
MergeLockError,
ParallelMergeResult,
ParallelMergeTask,
+ SpecNumberLock,
+ SpecNumberLockError,
WorkspaceChoice,
WorkspaceMode,
)
@@ -110,11 +120,9 @@ from .setup import (
__all__ = [
# Merge Operations (from workspace.py)
"merge_existing_build",
- "_run_parallel_merges", # Private but used internally
- "AI_MERGE_SYSTEM_PROMPT", # System prompt for AI merge (ACS-194)
- "_build_merge_prompt", # Internal prompt builder (ACS-194)
- "_check_git_conflicts", # Internal git conflict detection (ACS-224)
- "_rebase_spec_branch", # Internal rebase function (ACS-224)
+ # Note: Private functions (_run_parallel_merges, _resolve_git_conflicts_with_ai, etc.)
+ # are kept as module-level assignments for internal use but not exported in __all__
+ # to maintain the underscore convention for private/internal APIs
# Models
"WorkspaceMode",
"WorkspaceChoice",
@@ -122,6 +130,8 @@ __all__ = [
"ParallelMergeResult",
"MergeLock",
"MergeLockError",
+ "SpecNumberLock",
+ "SpecNumberLockError",
# Git Utils
"has_uncommitted_changes",
"get_current_branch",
@@ -131,8 +141,11 @@ __all__ = [
"get_changed_files_from_branch",
"is_process_running",
"is_binary_file",
+ "is_lock_file",
"validate_merged_syntax",
"create_conflict_file_with_git",
+ "detect_file_renames", # File rename detection
+ "apply_path_mapping", # Path mapping for renamed files
# Setup
"choose_workspace",
"copy_spec_to_worktree",
diff --git a/apps/backend/core/workspace/tests/conftest.py b/apps/backend/core/workspace/tests/conftest.py
new file mode 100644
index 00000000..7c80d19f
--- /dev/null
+++ b/apps/backend/core/workspace/tests/conftest.py
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+"""
+Pytest Configuration and Shared Fixtures for Workspace Tests
+==============================================================
+
+Provides test fixtures for the workspace module tests.
+"""
+
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+# =============================================================================
+# MODULE MOCK CLEANUP - Prevents test isolation issues
+# =============================================================================
+
+# List of modules that might be mocked by test files
+_POTENTIALLY_MOCKED_MODULES = [
+ "claude_code_sdk",
+ "claude_code_sdk.types",
+ "claude_agent_sdk",
+ "claude_agent_sdk.types",
+]
+
+# Store original module references at import time (BEFORE pre-mocking)
+_original_module_state = {}
+for _name in _POTENTIALLY_MOCKED_MODULES:
+ if _name in sys.modules:
+ _original_module_state[_name] = sys.modules[_name]
+
+
+# =============================================================================
+# PRE-MOCK EXTERNAL SDK MODULES - Must happen BEFORE adding auto-claude to path
+# =============================================================================
+# These SDK modules may not be installed, so we mock them before any imports
+# that might trigger loading code that depends on them.
+
+
+def _create_sdk_mock():
+ """Create a comprehensive mock for SDK modules."""
+ mock = MagicMock()
+ mock.ClaudeAgentOptions = MagicMock
+ mock.ClaudeSDKClient = MagicMock
+ mock.HookMatcher = MagicMock
+ return mock
+
+
+# Pre-mock claude_agent_sdk if not installed
+if "claude_agent_sdk" not in sys.modules:
+ sys.modules["claude_agent_sdk"] = _create_sdk_mock()
+ sys.modules["claude_agent_sdk.types"] = MagicMock()
+
+# Pre-mock claude_code_sdk if not installed
+if "claude_code_sdk" not in sys.modules:
+ sys.modules["claude_code_sdk"] = _create_sdk_mock()
+ sys.modules["claude_code_sdk.types"] = MagicMock()
+
+# Add backend directory to path for imports
+# When co-located at workspace/tests/, go up to backend directory
+# workspace/tests -> workspace -> core -> backend (4 levels up)
+_backend = Path(__file__).resolve().parent.parent.parent.parent
+sys.path.insert(0, str(_backend))
+
+# Add repo root to sys.path for test_fixtures import fallback
+_repo_root = _backend.parent.parent
+sys.path.insert(0, str(_repo_root))
+
+
+def _cleanup_mocked_modules():
+ """Remove any MagicMock modules from sys.modules."""
+ for name in _POTENTIALLY_MOCKED_MODULES:
+ if name in sys.modules:
+ module = sys.modules[name]
+ if isinstance(module, MagicMock):
+ if name in _original_module_state:
+ sys.modules[name] = _original_module_state[name]
+ else:
+ del sys.modules[name]
+
+
+def pytest_sessionstart(session):
+ """Clean up any mocked modules before the test session starts."""
+ _cleanup_mocked_modules()
+
+
+# =============================================================================
+# DIRECTORY FIXTURES
+# =============================================================================
+
+
+@pytest.fixture
+def temp_dir() -> Generator[Path, None, None]:
+ """Create a temporary directory that's cleaned up after the test."""
+ temp_path = Path(tempfile.mkdtemp())
+ yield temp_path
+ shutil.rmtree(temp_path, ignore_errors=True)
+
+
+@pytest.fixture
+def temp_git_repo(temp_dir: Path) -> Generator[Path, None, None]:
+ """Create a temporary git repository with initial commit.
+
+ IMPORTANT: This fixture properly isolates git operations by clearing
+ git environment variables that may be set by pre-commit hooks. Without
+ this isolation, git operations could affect the parent repository when
+ tests run inside a git worktree (e.g., during pre-commit validation).
+
+ See: https://git-scm.com/docs/git#_environment_variables
+ """
+ # Save original environment values to restore later
+ orig_env = {}
+
+ # These git env vars may be set by pre-commit hooks and MUST be cleared
+ # to avoid git operations affecting the parent repository instead of
+ # our isolated test repo. This is critical when running inside worktrees.
+ git_vars_to_clear = [
+ "GIT_DIR",
+ "GIT_WORK_TREE",
+ "GIT_INDEX_FILE",
+ "GIT_OBJECT_DIRECTORY",
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
+ ]
+
+ # Clear interfering git environment variables
+ for key in git_vars_to_clear:
+ orig_env[key] = os.environ.get(key)
+ if key in os.environ:
+ del os.environ[key]
+
+ # Set GIT_CEILING_DIRECTORIES to prevent git from discovering parent .git
+ # directories. This is critical for test isolation when running inside
+ # another git repo (like during pre-commit hooks in worktrees).
+ orig_env["GIT_CEILING_DIRECTORIES"] = os.environ.get("GIT_CEILING_DIRECTORIES")
+ os.environ["GIT_CEILING_DIRECTORIES"] = str(temp_dir.parent)
+
+ try:
+ # Initialize git repo
+ subprocess.run(["git", "init"], cwd=temp_dir, capture_output=True, check=True)
+ subprocess.run(
+ ["git", "config", "user.email", "test@example.com"],
+ cwd=temp_dir,
+ capture_output=True,
+ )
+ subprocess.run(
+ ["git", "config", "user.name", "Test User"],
+ cwd=temp_dir,
+ capture_output=True,
+ )
+
+ # Create initial commit
+ test_file = temp_dir / "README.md"
+ test_file.write_text("# Test Project\n", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_dir, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Initial commit"], cwd=temp_dir, capture_output=True
+ )
+
+ # Ensure branch is named 'main' (some git configs default to 'master')
+ subprocess.run(
+ ["git", "branch", "-M", "main"], cwd=temp_dir, capture_output=True
+ )
+
+ yield temp_dir
+ finally:
+ # Restore original environment variables
+ for key, value in orig_env.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+
+
+@pytest.fixture
+def spec_dir(temp_dir: Path) -> Path:
+ """Create a spec directory inside temp_dir."""
+ spec_path = temp_dir / "spec"
+ spec_path.mkdir(parents=True)
+ return spec_path
+
+
+@pytest.fixture
+def project_dir(temp_dir: Path) -> Path:
+ """Create a project directory inside temp_dir."""
+ project_path = temp_dir / "project"
+ project_path.mkdir(parents=True)
+ return project_path
+
+
+@pytest.fixture
+def make_commit(temp_git_repo: Path):
+ """Fixture to make commits in the test git repo.
+
+ Usage:
+ def test_something(make_commit):
+ make_commit("message", files={"file.txt": "content"})
+ """
+
+ def _make_commit(message: str, files: dict[str, str] | None = None):
+ """Create a commit with the given message and files.
+
+ Args:
+ message: Commit message
+ files: Optional dict of {filepath: content} to create before committing
+ """
+ if files:
+ for file_path, content in files.items():
+ full_path = temp_git_repo / file_path
+ full_path.parent.mkdir(parents=True, exist_ok=True)
+ full_path.write_text(content, encoding="utf-8")
+
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", message],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ return _make_commit
+
+
+@pytest.fixture
+def stage_files(temp_git_repo: Path):
+ """Fixture to stage files in the test git repo.
+
+ Usage:
+ def test_something(stage_files):
+ stage_files({"file.txt": "content"})
+ """
+
+ def _stage_files(files: dict[str, str]):
+ """Stage files for commit.
+
+ Args:
+ files: Dict of {filepath: content} to create and stage
+ """
+ for file_path, content in files.items():
+ full_path = temp_git_repo / file_path
+ full_path.parent.mkdir(parents=True, exist_ok=True)
+ full_path.write_text(content, encoding="utf-8")
+
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+
+ return _stage_files
diff --git a/apps/backend/core/workspace/tests/pytest.ini b/apps/backend/core/workspace/tests/pytest.ini
new file mode 100644
index 00000000..351998b3
--- /dev/null
+++ b/apps/backend/core/workspace/tests/pytest.ini
@@ -0,0 +1,10 @@
+[pytest]
+# Pytest configuration for workspace module tests
+
+# Async test mode
+asyncio_mode = auto
+
+# Register custom markers
+markers =
+ slow: marks tests as slow (deselect with '-m "not slow"')
+ integration: marks tests as integration tests (deselect with '-m "not integration"')
diff --git a/apps/backend/core/workspace/tests/test_display.py b/apps/backend/core/workspace/tests/test_display.py
new file mode 100644
index 00000000..40e7c4a2
--- /dev/null
+++ b/apps/backend/core/workspace/tests/test_display.py
@@ -0,0 +1,856 @@
+#!/usr/bin/env python3
+"""
+Tests for Workspace Display Functions
+======================================
+
+Tests the display.py module functionality including:
+- Build summary display
+- Changed files display
+- Merge success printing
+- Conflict info display
+- Environment file operations
+- Node modules symlink operations
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+# Test constant - in the new per-spec architecture, each spec has its own worktree
+# named after the spec itself. This constant is used for test assertions.
+TEST_SPEC_NAME = "test-spec"
+
+
+class TestShowBuildSummary:
+ """Tests for show_build_summary display function."""
+
+ def test_show_build_summary_no_changes(self, capsys):
+ """show_build_summary prints info message when no changes."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_build_summary
+
+ mock_manager = MagicMock()
+ mock_manager.get_change_summary.return_value = {
+ "new_files": 0,
+ "modified_files": 0,
+ "deleted_files": 0,
+ }
+ mock_manager.get_changed_files.return_value = []
+
+ show_build_summary(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "No changes were made" in captured.out
+
+ def test_show_build_summary_with_new_files(self, capsys):
+ """show_build_summary displays new files count correctly."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_build_summary
+
+ mock_manager = MagicMock()
+ mock_manager.get_change_summary.return_value = {
+ "new_files": 3,
+ "modified_files": 0,
+ "deleted_files": 0,
+ }
+ mock_manager.get_changed_files.return_value = [
+ ("A", "file1.py"),
+ ("A", "file2.py"),
+ ("A", "file3.py"),
+ ]
+
+ show_build_summary(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "What was built" in captured.out
+ assert "+ 3 new files" in captured.out
+
+ def test_show_build_summary_singular_new_file(self, capsys):
+ """show_build_summary uses singular form for one new file."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_build_summary
+
+ mock_manager = MagicMock()
+ mock_manager.get_change_summary.return_value = {
+ "new_files": 1,
+ "modified_files": 0,
+ "deleted_files": 0,
+ }
+ mock_manager.get_changed_files.return_value = [("A", "file1.py")]
+
+ show_build_summary(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "+ 1 new file" in captured.out
+ assert "files" not in captured.out.split("new file")[1].split("\n")[0]
+
+ def test_show_build_summary_with_modified_files(self, capsys):
+ """show_build_summary displays modified files count correctly."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_build_summary
+
+ mock_manager = MagicMock()
+ mock_manager.get_change_summary.return_value = {
+ "new_files": 0,
+ "modified_files": 2,
+ "deleted_files": 0,
+ }
+ mock_manager.get_changed_files.return_value = [
+ ("M", "file1.py"),
+ ("M", "file2.py"),
+ ]
+
+ show_build_summary(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "~ 2 modified files" in captured.out
+
+ def test_show_build_summary_with_deleted_files(self, capsys):
+ """show_build_summary displays deleted files count correctly."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_build_summary
+
+ mock_manager = MagicMock()
+ mock_manager.get_change_summary.return_value = {
+ "new_files": 0,
+ "modified_files": 0,
+ "deleted_files": 1,
+ }
+ mock_manager.get_changed_files.return_value = [("D", "old.py")]
+
+ show_build_summary(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "- 1 deleted file" in captured.out
+
+ def test_show_build_summary_mixed_changes(self, capsys):
+ """show_build_summary displays all change types together."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_build_summary
+
+ mock_manager = MagicMock()
+ mock_manager.get_change_summary.return_value = {
+ "new_files": 2,
+ "modified_files": 3,
+ "deleted_files": 1,
+ }
+ mock_manager.get_changed_files.return_value = [
+ ("A", "new1.py"),
+ ("A", "new2.py"),
+ ("M", "mod1.py"),
+ ("M", "mod2.py"),
+ ("M", "mod3.py"),
+ ("D", "old.py"),
+ ]
+
+ show_build_summary(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "+ 2 new files" in captured.out
+ assert "~ 3 modified files" in captured.out
+ assert "- 1 deleted file" in captured.out
+
+
+class TestShowChangedFiles:
+ """Tests for show_changed_files display function."""
+
+ def test_show_changed_files_empty_list(self, capsys):
+ """show_changed_files prints info message when no files changed."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_changed_files
+
+ mock_manager = MagicMock()
+ mock_manager.get_changed_files.return_value = []
+
+ show_changed_files(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "No changes" in captured.out
+
+ def test_show_changed_files_with_added_file(self, capsys):
+ """show_changed_files displays added file with + prefix."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_changed_files
+
+ mock_manager = MagicMock()
+ mock_manager.get_changed_files.return_value = [("A", "new_file.py")]
+
+ show_changed_files(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "Changed files" in captured.out
+ assert "+ new_file.py" in captured.out
+
+ def test_show_changed_files_with_modified_file(self, capsys):
+ """show_changed_files displays modified file with ~ prefix."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_changed_files
+
+ mock_manager = MagicMock()
+ mock_manager.get_changed_files.return_value = [("M", "changed.py")]
+
+ show_changed_files(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "~ changed.py" in captured.out
+
+ def test_show_changed_files_with_deleted_file(self, capsys):
+ """show_changed_files displays deleted file with - prefix."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_changed_files
+
+ mock_manager = MagicMock()
+ mock_manager.get_changed_files.return_value = [("D", "removed.py")]
+
+ show_changed_files(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "- removed.py" in captured.out
+
+ def test_show_changed_files_with_unknown_status(self, capsys):
+ """show_changed_files displays unknown status code without decoration."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_changed_files
+
+ mock_manager = MagicMock()
+ mock_manager.get_changed_files.return_value = [("R", "renamed.py")]
+
+ show_changed_files(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "R renamed.py" in captured.out
+
+ def test_show_changed_files_multiple_files(self, capsys):
+ """show_changed_files displays all changed files."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.display import show_changed_files
+
+ mock_manager = MagicMock()
+ mock_manager.get_changed_files.return_value = [
+ ("A", "new.py"),
+ ("M", "modified.py"),
+ ("D", "deleted.py"),
+ ("R", "renamed.py"),
+ ]
+
+ show_changed_files(mock_manager, "test-spec")
+
+ captured = capsys.readouterr()
+ assert "+ new.py" in captured.out
+ assert "~ modified.py" in captured.out
+ assert "- deleted.py" in captured.out
+ assert "R renamed.py" in captured.out
+
+
+class TestPrintMergeSuccess:
+ """Tests for print_merge_success display function."""
+
+ def test_print_merge_success_no_commit_basic(self, capsys):
+ """print_merge_success with no_commit=True shows basic message."""
+ from core.workspace.display import print_merge_success
+
+ print_merge_success(no_commit=True)
+
+ captured = capsys.readouterr()
+ assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
+ assert "working directory" in captured.out
+ assert "Review the changes" in captured.out
+ assert "commit when ready" in captured.out
+
+ def test_print_merge_success_no_commit_with_lock_files(self, capsys):
+ """print_merge_success with lock_files_excluded shows lock file note."""
+ from core.workspace.display import print_merge_success
+
+ stats = {"lock_files_excluded": 2}
+ print_merge_success(no_commit=True, stats=stats)
+
+ captured = capsys.readouterr()
+ assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
+ assert "Lock files kept from main" in captured.out
+ assert "npm install" in captured.out
+
+ def test_print_merge_success_no_commit_with_keep_worktree(self, capsys):
+ """print_merge_success with keep_worktree shows discard command."""
+ from core.workspace.display import print_merge_success
+
+ print_merge_success(no_commit=True, spec_name="spec-001", keep_worktree=True)
+
+ captured = capsys.readouterr()
+ assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
+ assert "Worktree kept for testing" in captured.out
+ assert "python auto-claude/run.py --spec spec-001 --discard" in captured.out
+
+ def test_print_merge_success_no_commit_full_scenario(self, capsys):
+ """print_merge_success with all optional parameters."""
+ from core.workspace.display import print_merge_success
+
+ stats = {"lock_files_excluded": 1}
+ print_merge_success(
+ no_commit=True,
+ stats=stats,
+ spec_name="test-spec",
+ keep_worktree=True,
+ )
+
+ captured = capsys.readouterr()
+ assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
+ assert "Lock files kept from main" in captured.out
+ assert "Worktree kept for testing" in captured.out
+ assert "--spec test-spec --discard" in captured.out
+
+ def test_print_merge_success_with_commit_basic(self, capsys):
+ """print_merge_success with no_commit=False shows commit message."""
+ from core.workspace.display import print_merge_success
+
+ print_merge_success(no_commit=False)
+
+ captured = capsys.readouterr()
+ assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
+ assert "separate workspace has been cleaned up" in captured.out
+
+ def test_print_merge_success_with_commit_and_stats(self, capsys):
+ """print_merge_success with stats shows file counts."""
+ from core.workspace.display import print_merge_success
+
+ stats = {
+ "files_added": 5,
+ "files_modified": 3,
+ "files_deleted": 1,
+ }
+ print_merge_success(no_commit=False, stats=stats)
+
+ captured = capsys.readouterr()
+ assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
+ assert "What changed" in captured.out
+ assert "+ 5 files added" in captured.out
+ assert "~ 3 files modified" in captured.out
+ assert "- 1 file deleted" in captured.out
+
+ def test_print_merge_success_singular_file_counts(self, capsys):
+ """print_merge_success uses singular form for single file counts."""
+ from core.workspace.display import print_merge_success
+
+ stats = {
+ "files_added": 1,
+ "files_modified": 1,
+ "files_deleted": 1,
+ }
+ print_merge_success(no_commit=False, stats=stats)
+
+ captured = capsys.readouterr()
+ assert "+ 1 file added" in captured.out
+ assert "~ 1 file modified" in captured.out
+ assert "- 1 file deleted" in captured.out
+
+ def test_print_merge_success_with_keep_worktree(self, capsys):
+ """print_merge_success with keep_worktree shows discard command."""
+ from core.workspace.display import print_merge_success
+
+ print_merge_success(no_commit=False, keep_worktree=True, spec_name="my-spec")
+
+ captured = capsys.readouterr()
+ assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
+ assert "Worktree kept for testing" in captured.out
+ assert "--spec my-spec --discard" in captured.out
+ assert "separate workspace has been cleaned up" not in captured.out
+
+ def test_print_merge_success_zero_file_counts_not_shown(self, capsys):
+ """print_merge_success doesn't show file types with zero count."""
+ from core.workspace.display import print_merge_success
+
+ stats = {
+ "files_added": 2,
+ "files_modified": 0,
+ "files_deleted": 0,
+ }
+ print_merge_success(no_commit=False, stats=stats)
+
+ captured = capsys.readouterr()
+ assert "+ 2 files added" in captured.out
+ assert "files modified" not in captured.out
+ assert "files deleted" not in captured.out
+
+
+class TestPrintConflictInfoExtended:
+ """Extended tests for print_conflict_info display function."""
+
+ def test_print_conflict_info_empty_conflicts(self, capsys):
+ """print_conflict_info returns early with empty conflicts list."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": []}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+
+ def test_print_conflict_info_no_conflicts_key(self, capsys):
+ """print_conflict_info returns early when conflicts key missing."""
+ from core.workspace.display import print_conflict_info
+
+ result = {}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+
+ def test_print_conflict_info_critical_severity(self, capsys):
+ """print_conflict_info shows critical severity icon."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {
+ "file": "critical.py",
+ "reason": "Breaking change",
+ "severity": "critical",
+ }
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "critical.py" in captured.out
+ assert "⛔" in captured.out
+ assert "Breaking change" in captured.out
+
+ def test_print_conflict_info_high_severity(self, capsys):
+ """print_conflict_info shows high severity icon."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {"file": "high.py", "reason": "Major conflict", "severity": "high"}
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "high.py" in captured.out
+ assert "🔴" in captured.out
+ assert "Major conflict" in captured.out
+
+ def test_print_conflict_info_medium_severity(self, capsys):
+ """print_conflict_info shows medium severity icon."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {"file": "medium.py", "reason": "Minor conflict", "severity": "medium"}
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "medium.py" in captured.out
+ assert "🟡" in captured.out
+ assert "Minor conflict" in captured.out
+
+ def test_print_conflict_info_low_severity_no_icon(self, capsys):
+ """print_conflict_info shows no icon for low severity."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {"file": "low.py", "reason": "Trivial issue", "severity": "low"}
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "low.py" in captured.out
+ assert "Trivial issue" in captured.out
+ assert "⛔" not in captured.out
+ assert "🔴" not in captured.out
+ assert "🟡" not in captured.out
+
+ def test_print_conflict_info_unknown_severity(self, capsys):
+ """print_conflict_info handles unknown severity gracefully."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {"file": "unknown.py", "reason": "Unknown", "severity": "unknown"}
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "unknown.py" in captured.out
+ assert "Unknown" in captured.out
+
+ def test_print_conflict_info_missing_file_key(self, capsys):
+ """print_conflict_info handles missing file key."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": [{"reason": "No file specified", "severity": "high"}]}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "unknown" in captured.out
+ assert "No file specified" in captured.out
+
+ def test_print_conflict_info_missing_reason_key(self, capsys):
+ """print_conflict_info handles missing reason key."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": [{"file": "noreason.py", "severity": "medium"}]}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "noreason.py" in captured.out
+
+ def test_print_conflict_info_dict_no_reason(self, capsys):
+ """print_conflict_info with dict missing reason."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": [{"file": "test.py", "severity": "high"}]}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "test.py" in captured.out
+ assert "🔴" in captured.out
+
+ def test_print_conflict_info_multiple_conflicts(self, capsys):
+ """print_conflict_info handles multiple conflicts."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {"file": "critical.py", "reason": "Critical", "severity": "critical"},
+ {"file": "high.py", "reason": "High", "severity": "high"},
+ {"file": "medium.py", "reason": "Medium", "severity": "medium"},
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "3 file" in captured.out
+ assert "⛔" in captured.out
+ assert "🔴" in captured.out
+ assert "🟡" in captured.out
+
+ def test_print_conflict_info_shows_marker_conflict_message(self, capsys):
+ """print_conflict_info shows marker conflict message for string conflicts."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": ["conflict.py"]}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "conflict markers" in captured.out
+ # Check that the conflict markers are mentioned in the message
+
+ def test_print_conflict_info_shows_ai_conflict_message(self, capsys):
+ """print_conflict_info shows AI conflict message for dict conflicts."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {
+ "file": "ai-conflict.py",
+ "reason": "AI merge failed",
+ "severity": "high",
+ }
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "could not be auto-merged" in captured.out
+
+ def test_print_conflict_info_shows_both_messages_mixed(self, capsys):
+ """print_conflict_info shows both messages for mixed conflicts."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ "marker.py",
+ {"file": "ai.py", "reason": "AI failed", "severity": "high"},
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "conflict markers" in captured.out
+ assert "could not be auto-merged" in captured.out
+
+ def test_print_conflict_info_shows_git_commands(self, capsys):
+ """print_conflict_info shows git add and commit commands."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": ["file1.py", "file2.py"]}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "git add" in captured.out
+ assert "git commit" in captured.out
+
+ def test_print_conflict_info_quotes_special_paths(self, capsys):
+ """print_conflict_info properly quotes file paths with special characters."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": ["file with spaces.py", "file'with'quotes.py"]}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ # shlex.quote should quote paths with spaces
+ assert "git add" in captured.out
+ assert "file with spaces.py" in captured.out
+
+ def test_print_conflict_info_deduplicates_files(self, capsys):
+ """print_conflict_info deduplicates file paths in git command."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ "file1.py",
+ {"file": "file1.py", "reason": "Also here", "severity": "medium"},
+ "file2.py",
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ # Count occurrences of file1.py
+ count = captured.out.count("file1.py")
+ assert count == 3 # Display shows it twice (string + dict), once in git add
+
+ def test_print_conflict_info_preserves_order(self, capsys):
+ """print_conflict_info preserves file order while deduplicating."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ "first.py",
+ {"file": "second.py", "severity": "high"},
+ "first.py", # Duplicate
+ {"file": "third.py", "severity": "medium"},
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ # First occurrence should be preserved
+ lines = captured.out.split("\n")
+ first_idx = None
+ second_idx = None
+ for i, line in enumerate(lines):
+ if "first.py" in line:
+ if first_idx is None:
+ first_idx = i
+ if "second.py" in line:
+ if second_idx is None:
+ second_idx = i
+ assert first_idx is not None
+ assert second_idx is not None
+
+
+class TestCopyEnvFilesToWorktree:
+ """Tests for copy_env_files_to_worktree function."""
+
+ def test_copies_all_env_files(self, temp_git_repo: Path):
+ """Copies all .env files when they exist in project dir."""
+ from core.workspace.setup import copy_env_files_to_worktree
+
+ # Create .env files in project
+ (temp_git_repo / ".env").write_text("FOO=bar", encoding="utf-8")
+ (temp_git_repo / ".env.local").write_text("LOCAL=1", encoding="utf-8")
+ (temp_git_repo / ".env.development").write_text("DEV=1", encoding="utf-8")
+
+ # Create worktree directory
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Copy env files
+ copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
+
+ # Check all files were copied
+ assert ".env" in copied
+ assert ".env.local" in copied
+ assert ".env.development" in copied
+ assert len(copied) == 3
+
+ # Verify files exist in worktree
+ assert (worktree_path / ".env").exists()
+ assert (worktree_path / ".env.local").exists()
+ assert (worktree_path / ".env.development").exists()
+
+ def test_skips_nonexistent_env_files(self, temp_git_repo: Path):
+ """Only copies env files that exist."""
+ from core.workspace.setup import copy_env_files_to_worktree
+
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
+
+ assert len(copied) == 0
+
+ def test_does_not_overwrite_existing_env_files(self, temp_git_repo: Path):
+ """Does not overwrite .env files that already exist in worktree."""
+ from core.workspace.setup import copy_env_files_to_worktree
+
+ # Create .env in project
+ (temp_git_repo / ".env").write_text("PROJECT=1", encoding="utf-8")
+
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Create existing .env in worktree with different content
+ (worktree_path / ".env").write_text("WORKTREE=1", encoding="utf-8")
+
+ copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
+
+ # .env should not be in copied list since it already existed
+ assert ".env" not in copied
+
+ # Worktree .env should keep its original content
+ assert (worktree_path / ".env").read_text(encoding="utf-8") == "WORKTREE=1"
+
+
+class TestSymlinkNodeModulesToWorktree:
+ """Tests for symlink_node_modules_to_worktree function."""
+
+ @pytest.mark.skipif(sys.platform != "linux", reason="Unix-specific test")
+ def test_symlinks_node_modules_on_unix(self, temp_git_repo: Path):
+ """Creates relative symlinks on Unix systems."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create node_modules in project
+ node_modules = temp_git_repo / "node_modules"
+ node_modules.mkdir()
+ (node_modules / "test.txt").write_text("test", encoding="utf-8")
+
+ # Create apps/frontend/node_modules
+ frontend_node_modules = temp_git_repo / "apps" / "frontend" / "node_modules"
+ frontend_node_modules.mkdir(parents=True)
+ (frontend_node_modules / "test2.txt").write_text("test2", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+ (worktree_path / "apps" / "frontend").mkdir(parents=True)
+
+ # Create symlinks
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ assert len(symlinked) == 2
+ assert "node_modules" in symlinked
+ assert "apps/frontend/node_modules" in symlinked
+
+ # Verify symlinks exist and point to correct location
+ assert (worktree_path / "node_modules").is_symlink()
+ assert (worktree_path / "apps" / "frontend" / "node_modules").is_symlink()
+
+ @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
+ def test_creates_junctions_on_windows(self, temp_git_repo: Path, monkeypatch):
+ """Creates junctions on Windows systems."""
+ from unittest.mock import patch
+
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create node_modules in project
+ node_modules = temp_git_repo / "node_modules"
+ node_modules.mkdir()
+ (node_modules / "test.txt").write_text("test", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock subprocess.run to simulate mklink /J success
+ def mock_subprocess_run(cmd, capture_output=False, text=False):
+ result = type("obj", (object,), {"returncode": 0, "stderr": ""})()
+ return result
+
+ with patch("subprocess.run", side_effect=mock_subprocess_run):
+ with monkeypatch.context() as m:
+ m.setattr("sys.platform", "win32")
+ symlinked = symlink_node_modules_to_worktree(
+ temp_git_repo, worktree_path
+ )
+
+ assert "node_modules" in symlinked
+
+ def test_skips_nonexistent_node_modules(self, temp_git_repo: Path):
+ """Skips node_modules that don't exist in project."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ assert len(symlinked) == 0
+
+ def test_skips_existing_symlinks(self, temp_git_repo: Path):
+ """Does not recreate symlinks that already exist."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create node_modules in project
+ node_modules = temp_git_repo / "node_modules"
+ node_modules.mkdir()
+ (node_modules / "test.txt").write_text("test", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Create existing symlink
+ if sys.platform != "win32":
+ os.symlink(temp_git_repo / "node_modules", worktree_path / "node_modules")
+
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # Should skip existing symlink
+ assert "node_modules" not in symlinked
diff --git a/apps/backend/core/workspace/tests/test_finalization.py b/apps/backend/core/workspace/tests/test_finalization.py
new file mode 100644
index 00000000..5e385f87
--- /dev/null
+++ b/apps/backend/core/workspace/tests/test_finalization.py
@@ -0,0 +1,805 @@
+#!/usr/bin/env python3
+"""
+Tests for Workspace Selection and Management
+=============================================
+
+Tests the workspace.py module functionality including:
+- Workspace mode selection (isolated vs direct)
+- Uncommitted changes detection
+- Workspace setup
+- Build finalization workflows
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+# Add parent directory to path so we can import the workspace module
+# When co-located at workspace/tests/, we need to add backend to path
+# workspace/tests -> workspace -> core -> backend (4 levels up)
+_backend = Path(__file__).resolve().parent.parent.parent.parent
+sys.path.insert(0, str(_backend))
+
+from core.workspace import (
+ WorkspaceChoice,
+ WorkspaceMode,
+ get_current_branch,
+ get_existing_build_worktree,
+ has_uncommitted_changes,
+ setup_workspace,
+)
+from worktree import WorktreeError, WorktreeManager
+
+# Test constant - in the new per-spec architecture, each spec has its own worktree
+# named after the spec itself. This constant is used for test assertions.
+TEST_SPEC_NAME = "test-spec"
+
+# =============================================================================
+# TESTS FOR finalization.py
+# =============================================================================
+
+
+class TestFinalizeWorkspace:
+ """Tests for finalize_workspace function."""
+
+ def test_direct_mode_returns_merge(self, temp_git_repo: Path, monkeypatch, capsys):
+ """Direct mode returns MERGE choice and shows completion message."""
+ from core.workspace.finalization import finalize_workspace
+
+ # Mock the UI functions
+ def mock_box(content, width=60, style="heavy"):
+ return content
+
+ monkeypatch.setattr("core.workspace.finalization.box", mock_box)
+
+ result = finalize_workspace(
+ temp_git_repo,
+ "test-spec",
+ manager=None,
+ auto_continue=False,
+ )
+
+ assert result == WorkspaceChoice.MERGE
+
+ captured = capsys.readouterr()
+ assert "BUILD COMPLETE" in captured.out
+ assert "directly to your project" in captured.out
+
+ def test_auto_continue_mode_returns_later(self, temp_git_repo: Path):
+ """Auto-continue mode returns LATER choice."""
+ from core.workspace.finalization import finalize_workspace
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree info
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ result = finalize_workspace(
+ temp_git_repo,
+ spec_name,
+ manager=manager,
+ auto_continue=True,
+ )
+
+ assert result == WorkspaceChoice.LATER
+
+ def test_isolated_mode_shows_menu(self, temp_git_repo: Path, monkeypatch):
+ """Isolated mode shows menu with test/review/merge/later options."""
+ from core.workspace.finalization import finalize_workspace
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock select_menu to return "test"
+ def mock_select_menu(title, options, allow_quit):
+ return "test"
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+
+ result = finalize_workspace(
+ temp_git_repo,
+ spec_name,
+ manager=manager,
+ auto_continue=False,
+ )
+
+ assert result == WorkspaceChoice.TEST
+
+
+class TestHandleWorkspaceChoice:
+ """Tests for handle_workspace_choice function."""
+
+ def test_choice_test_shows_instructions(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """TEST choice shows testing instructions."""
+ from core.workspace.finalization import handle_workspace_choice
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
+
+ captured = capsys.readouterr()
+ assert "TEST YOUR FEATURE" in captured.out
+ assert str(worktree_path) in captured.out
+
+ def test_choice_merge_calls_merge_worktree(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """MERGE choice calls manager.merge_worktree."""
+ from core.workspace.finalization import handle_workspace_choice
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree and commit something
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+ (worktree_path / "test.py").write_text("test", encoding="utf-8")
+
+ # Initialize git in worktree and commit
+ subprocess.run(["git", "init"], cwd=worktree_path, capture_output=True)
+ subprocess.run(
+ ["git", "config", "user.email", "test@example.com"],
+ cwd=worktree_path,
+ capture_output=True,
+ )
+ subprocess.run(
+ ["git", "config", "user.name", "Test"],
+ cwd=worktree_path,
+ capture_output=True,
+ )
+ subprocess.run(["git", "add", "."], cwd=worktree_path, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Test"], cwd=worktree_path, capture_output=True
+ )
+
+ handle_workspace_choice(
+ WorkspaceChoice.MERGE, temp_git_repo, spec_name, manager
+ )
+
+ captured = capsys.readouterr()
+ assert "Adding changes" in captured.out
+
+ def test_choice_review_shows_changed_files(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """REVIEW choice shows changed files."""
+ from core.workspace.finalization import handle_workspace_choice
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock show_changed_files
+ mock_shown = []
+
+ def mock_show_changed_files(manager, spec_name):
+ mock_shown.append(spec_name)
+
+ monkeypatch.setattr(
+ "core.workspace.finalization.show_changed_files", mock_show_changed_files
+ )
+
+ handle_workspace_choice(
+ WorkspaceChoice.REVIEW, temp_git_repo, spec_name, manager
+ )
+
+ assert len(mock_shown) == 1
+ assert mock_shown[0] == spec_name
+
+ captured = capsys.readouterr()
+ assert "To see full details" in captured.out
+
+ def test_choice_later_shows_deferred_message(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """LATER choice shows deferral message."""
+ from core.workspace.finalization import handle_workspace_choice
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ handle_workspace_choice(
+ WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
+ )
+
+ captured = capsys.readouterr()
+ assert "No problem!" in captured.out
+ assert "saved" in captured.out
+
+
+class TestReviewExistingBuild:
+ """Tests for review_existing_build function."""
+
+ def test_no_existing_build_shows_warning(self, temp_git_repo: Path, capsys):
+ """Shows warning when no existing build found."""
+ from core.workspace.finalization import review_existing_build
+
+ result = review_existing_build(temp_git_repo, "nonexistent-spec")
+
+ assert result is False
+
+ captured = capsys.readouterr()
+ assert "No existing build found" in captured.out
+
+ def test_shows_build_contents(self, temp_git_repo: Path, capsys):
+ """Shows build summary and changed files when build exists."""
+ from core.workspace.finalization import review_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ result = review_existing_build(temp_git_repo, spec_name)
+
+ assert result is True
+
+ captured = capsys.readouterr()
+ assert "BUILD CONTENTS" in captured.out
+
+
+class TestDiscardExistingBuild:
+ """Tests for discard_existing_build function."""
+
+ def test_no_existing_build_returns_false(self, temp_git_repo: Path, capsys):
+ """Returns False when no existing build found."""
+ from core.workspace.finalization import discard_existing_build
+
+ result = discard_existing_build(temp_git_repo, "nonexistent-spec")
+
+ assert result is False
+
+ captured = capsys.readouterr()
+ assert "No existing build found" in captured.out
+
+ def test_confirmation_deletes_build(self, temp_git_repo: Path, monkeypatch, capsys):
+ """Deletes build when user types 'delete' to confirm."""
+ from core.workspace.finalization import discard_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock input to return "delete"
+ monkeypatch.setattr("builtins.input", lambda: "delete")
+
+ result = discard_existing_build(temp_git_repo, spec_name)
+
+ assert result is True
+ captured = capsys.readouterr()
+ assert "Build deleted" in captured.out
+
+ def test_cancelled_confirmation_returns_false(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Returns False when user doesn't confirm."""
+ from core.workspace.finalization import discard_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock input to return "no"
+ monkeypatch.setattr("builtins.input", lambda: "no")
+
+ result = discard_existing_build(temp_git_repo, spec_name)
+
+ assert result is False
+ captured = capsys.readouterr()
+ assert "Cancelled" in captured.out
+
+
+class TestCheckExistingBuild:
+ """Tests for check_existing_build function."""
+
+ def test_no_existing_build_returns_false(self, temp_git_repo: Path):
+ """Returns False when no existing build."""
+ from core.workspace.finalization import check_existing_build
+
+ result = check_existing_build(temp_git_repo, "nonexistent-spec")
+
+ assert result is False
+
+ def test_shows_menu_for_existing_build(self, temp_git_repo: Path, monkeypatch):
+ """Shows menu when existing build found."""
+ from core.workspace.finalization import check_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock select_menu to return "continue"
+ def mock_select_menu(title, options, allow_quit):
+ return "continue"
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+
+ result = check_existing_build(temp_git_repo, spec_name)
+
+ assert result is True
+
+ def test_review_choice_reviews_and_continues(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """Review choice reviews build then continues."""
+ from core.workspace.finalization import check_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ review_called = []
+
+ def mock_review(project_dir, spec_name):
+ review_called.append(spec_name)
+ return True
+
+ def mock_select_menu(title, options, allow_quit):
+ return "review"
+
+ def mock_input(prompt):
+ return ""
+
+ monkeypatch.setattr(
+ "core.workspace.finalization.review_existing_build", mock_review
+ )
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+ monkeypatch.setattr("builtins.input", mock_input)
+
+ result = check_existing_build(temp_git_repo, spec_name)
+
+ assert result is True
+ assert spec_name in review_called
+
+
+class TestListAllWorktrees:
+ """Tests for list_all_worktrees function."""
+
+ def test_returns_empty_list_when_no_worktrees(self, temp_git_repo: Path):
+ """Returns empty list when no worktrees exist."""
+ from core.workspace.finalization import list_all_worktrees
+
+ result = list_all_worktrees(temp_git_repo)
+
+ assert result == []
+
+ def test_lists_existing_worktrees(self, temp_git_repo: Path):
+ """Returns list of existing worktrees."""
+ from core.workspace.finalization import list_all_worktrees
+
+ # Create worktrees
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ (worktrees_dir / "spec-001").mkdir()
+ (worktrees_dir / "spec-002").mkdir()
+
+ result = list_all_worktrees(temp_git_repo)
+
+ assert len(result) == 2
+ spec_names = {wt.spec_name for wt in result}
+ assert "spec-001" in spec_names
+ assert "spec-002" in spec_names
+
+
+class TestCleanupAllWorktrees:
+ """Tests for cleanup_all_worktrees function."""
+
+ def test_no_worktrees_returns_false(self, temp_git_repo: Path, capsys):
+ """Returns False when no worktrees found."""
+ from core.workspace.finalization import cleanup_all_worktrees
+
+ result = cleanup_all_worktrees(temp_git_repo, confirm=False)
+
+ assert result is False
+
+ captured = capsys.readouterr()
+ assert "No worktrees found" in captured.out
+
+ def test_cleanup_without_confirmation(self, temp_git_repo: Path):
+ """Cleans up worktrees when confirm=False."""
+ from core.workspace.finalization import cleanup_all_worktrees
+
+ # Create worktrees
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ spec1_path = worktrees_dir / "spec-001"
+ spec1_path.mkdir()
+ spec2_path = worktrees_dir / "spec-002"
+ spec2_path.mkdir()
+
+ result = cleanup_all_worktrees(temp_git_repo, confirm=False)
+
+ assert result is True
+ assert not spec1_path.exists()
+ assert not spec2_path.exists()
+
+ def test_cleanup_with_confirmation_yes(self, temp_git_repo: Path, monkeypatch):
+ """Cleans up worktrees when user confirms with 'yes'."""
+ from core.workspace.finalization import cleanup_all_worktrees
+
+ # Create worktrees
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ spec1_path = worktrees_dir / "spec-001"
+ spec1_path.mkdir()
+
+ # Mock input to return "yes"
+ monkeypatch.setattr("builtins.input", lambda: "yes")
+
+ result = cleanup_all_worktrees(temp_git_repo, confirm=True)
+
+ assert result is True
+ assert not spec1_path.exists()
+
+ def test_cleanup_with_confirmation_no(self, temp_git_repo: Path, monkeypatch):
+ """Cancels cleanup when user doesn't confirm."""
+ from core.workspace.finalization import cleanup_all_worktrees
+
+ # Create worktrees
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ spec1_path = worktrees_dir / "spec-001"
+ spec1_path.mkdir()
+
+ # Mock input to return "no"
+ monkeypatch.setattr("builtins.input", lambda: "no")
+
+ result = cleanup_all_worktrees(temp_git_repo, confirm=True)
+
+ assert result is False
+ assert spec1_path.exists() # Should still exist
+
+ def test_cleanup_with_confirmation_keyboard_interrupt(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Cancels cleanup when user presses Ctrl+C (KeyboardInterrupt)."""
+ from core.workspace.finalization import cleanup_all_worktrees
+
+ # Create worktrees
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ spec1_path = worktrees_dir / "spec-001"
+ spec1_path.mkdir()
+
+ # Mock input to raise KeyboardInterrupt
+ def mock_input(prompt=""):
+ raise KeyboardInterrupt()
+
+ monkeypatch.setattr("builtins.input", mock_input)
+
+ result = cleanup_all_worktrees(temp_git_repo, confirm=True)
+
+ assert result is False
+ assert spec1_path.exists() # Should still exist
+
+ captured = capsys.readouterr()
+ assert "Cancelled" in captured.out
+
+
+class TestFinalizeWorkspaceBranchCoverage:
+ """Additional tests for finalize_workspace to cover missing branches."""
+
+ def test_isolated_mode_merge_choice(self, temp_git_repo: Path, monkeypatch):
+ """Isolated mode returns MERGE when user selects merge."""
+ from core.workspace.finalization import finalize_workspace
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock select_menu to return "merge"
+ def mock_select_menu(title, options, allow_quit):
+ return "merge"
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+
+ result = finalize_workspace(
+ temp_git_repo,
+ spec_name,
+ manager=manager,
+ auto_continue=False,
+ )
+
+ assert result == WorkspaceChoice.MERGE
+
+ def test_isolated_mode_review_choice(self, temp_git_repo: Path, monkeypatch):
+ """Isolated mode returns REVIEW when user selects review."""
+ from core.workspace.finalization import finalize_workspace
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock select_menu to return "review"
+ def mock_select_menu(title, options, allow_quit):
+ return "review"
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+
+ result = finalize_workspace(
+ temp_git_repo,
+ spec_name,
+ manager=manager,
+ auto_continue=False,
+ )
+
+ assert result == WorkspaceChoice.REVIEW
+
+ def test_isolated_mode_later_choice(self, temp_git_repo: Path, monkeypatch):
+ """Isolated mode returns LATER when user selects later."""
+ from core.workspace.finalization import finalize_workspace
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock select_menu to return "later"
+ def mock_select_menu(title, options, allow_quit):
+ return "later"
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+
+ result = finalize_workspace(
+ temp_git_repo,
+ spec_name,
+ manager=manager,
+ auto_continue=False,
+ )
+
+ assert result == WorkspaceChoice.LATER
+
+
+class TestHandleWorkspaceChoiceBranchCoverage:
+ """Additional tests for handle_workspace_choice to cover missing branches."""
+
+ def test_choice_test_without_staging_path(self, temp_git_repo: Path, capsys):
+ """TEST choice shows fallback instructions when staging_path is None."""
+ from core.workspace.finalization import handle_workspace_choice
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree directory (but not through manager, so no staging_path)
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
+
+ captured = capsys.readouterr()
+ assert "TEST YOUR FEATURE" in captured.out
+ # Should show the fallback path
+ assert (
+ str(worktree_path) in captured.out
+ or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
+ )
+
+ def test_choice_merge_success(self, temp_git_repo: Path, capsys):
+ """MERGE choice shows success message when merge succeeds."""
+ from core.workspace.finalization import handle_workspace_choice
+ from worktree import WorktreeManager
+
+ # Setup a proper isolated workspace with git worktree
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Make changes and commit
+ (working_dir / "test.py").write_text("test content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add test"], cwd=working_dir, capture_output=True
+ )
+
+ handle_workspace_choice(
+ WorkspaceChoice.MERGE, temp_git_repo, "test-spec", manager
+ )
+
+ captured = capsys.readouterr()
+ assert "Your feature has been added" in captured.out
+
+ def test_choice_later_without_staging_path(self, temp_git_repo: Path, capsys):
+ """LATER choice shows fallback path when staging_path is None."""
+ from core.workspace.finalization import handle_workspace_choice
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create worktree directory (but not through manager, so no staging_path)
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ handle_workspace_choice(
+ WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
+ )
+
+ captured = capsys.readouterr()
+ assert "No problem!" in captured.out
+ # Should show the fallback path
+ assert (
+ str(worktree_path) in captured.out
+ or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
+ )
+
+
+class TestDiscardExistingBuildBranchCoverage:
+ """Additional tests for discard_existing_build to cover missing branches."""
+
+ def test_keyboard_interrupt_cancels_discard(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """KeyboardInterrupt during confirmation returns False."""
+ from core.workspace.finalization import discard_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock input to raise KeyboardInterrupt
+ def mock_input(prompt=""):
+ raise KeyboardInterrupt()
+
+ monkeypatch.setattr("builtins.input", mock_input)
+
+ result = discard_existing_build(temp_git_repo, spec_name)
+
+ assert result is False
+
+ captured = capsys.readouterr()
+ assert "Cancelled" in captured.out
+
+
+class TestCheckExistingBuildBranchCoverage:
+ """Additional tests for check_existing_build to cover missing branches."""
+
+ def test_none_choice_exits(self, temp_git_repo: Path, monkeypatch):
+ """None choice (quit) calls sys.exit(0)."""
+ import sys
+
+ from core.workspace.finalization import check_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ # Mock select_menu to return None (quit)
+ def mock_select_menu(title, options, allow_quit):
+ return None
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+
+ # Should raise SystemExit
+ with pytest.raises(SystemExit) as exc_info:
+ check_existing_build(temp_git_repo, spec_name)
+
+ assert exc_info.value.code == 0
+
+ def test_merge_choice_merges_and_returns_false(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """Merge choice calls merge_existing_build and returns False."""
+ from unittest.mock import MagicMock
+
+ from core.workspace.finalization import check_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ merge_called = []
+
+ def mock_merge_existing_build(project_dir, spec_name):
+ merge_called.append(spec_name)
+
+ def mock_select_menu(title, options, allow_quit):
+ return "merge"
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+
+ # Mock the workspace module import
+ import workspace as ws
+
+ original_merge = getattr(ws, "merge_existing_build", None)
+ ws.merge_existing_build = mock_merge_existing_build
+
+ try:
+ result = check_existing_build(temp_git_repo, spec_name)
+ assert result is False
+ assert spec_name in merge_called
+ finally:
+ if original_merge:
+ ws.merge_existing_build = original_merge
+
+ def test_fresh_choice_discards_and_returns_false(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """Fresh choice discards build and returns False (start fresh)."""
+ from core.workspace.finalization import check_existing_build
+
+ spec_name = "test-spec"
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_path = worktrees_dir / spec_name
+ worktree_path.mkdir(parents=True)
+
+ def mock_select_menu(title, options, allow_quit):
+ return "fresh"
+
+ monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
+ # Mock input to return "delete" for confirmation
+ monkeypatch.setattr("builtins.input", lambda: "delete")
+
+ result = check_existing_build(temp_git_repo, spec_name)
+ assert result is False, "Fresh choice should return False"
diff --git a/apps/backend/core/workspace/tests/test_git_utils.py b/apps/backend/core/workspace/tests/test_git_utils.py
new file mode 100644
index 00000000..f902c2ea
--- /dev/null
+++ b/apps/backend/core/workspace/tests/test_git_utils.py
@@ -0,0 +1,1665 @@
+#!/usr/bin/env python3
+"""
+Tests for Workspace Selection and Management
+=============================================
+
+Tests the workspace.py module functionality including:
+- Workspace mode selection (isolated vs direct)
+- Uncommitted changes detection
+- Workspace setup
+- Build finalization workflows
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+# Add parent directory to path so we can import the workspace module
+# When co-located at workspace/tests/, we need to add backend to path
+# workspace/tests -> workspace -> core -> backend (4 levels up)
+_backend = Path(__file__).resolve().parent.parent.parent.parent
+sys.path.insert(0, str(_backend))
+
+from core.workspace import (
+ WorkspaceChoice,
+ WorkspaceMode,
+ get_current_branch,
+ get_existing_build_worktree,
+ has_uncommitted_changes,
+ setup_workspace,
+)
+from worktree import WorktreeError, WorktreeManager
+
+# Test constant - in the new per-spec architecture, each spec has its own worktree
+# named after the spec itself. This constant is used for test assertions.
+TEST_SPEC_NAME = "test-spec"
+
+# =============================================================================
+# TESTS FOR git_utils.py
+# =============================================================================
+
+
+class TestDetectFileRenames:
+ def test_detects_single_file_rename(self, temp_git_repo: Path):
+ """Detects a single file rename between two refs."""
+ from core.workspace.git_utils import detect_file_renames
+
+ # Create and commit a file
+ (temp_git_repo / "old_name.txt").write_text("content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get the commit hash
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ old_commit = result.stdout.strip()
+
+ # Rename the file
+ (temp_git_repo / "old_name.txt").rename(temp_git_repo / "new_name.txt")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Rename file"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Detect renames
+ renames = detect_file_renames(temp_git_repo, old_commit, "HEAD")
+
+ assert len(renames) == 1
+ assert "old_name.txt" in renames
+ assert renames["old_name.txt"] == "new_name.txt"
+
+ def test_detects_multiple_file_renames(self, temp_git_repo: Path):
+ """Detects multiple file renames between two refs."""
+ from core.workspace.git_utils import detect_file_renames
+
+ # Create and commit files
+ (temp_git_repo / "file1.txt").write_text("content1", encoding="utf-8")
+ (temp_git_repo / "file2.txt").write_text("content2", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add files"], cwd=temp_git_repo, capture_output=True
+ )
+
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ old_commit = result.stdout.strip()
+
+ # Rename both files
+ (temp_git_repo / "file1.txt").rename(temp_git_repo / "renamed1.txt")
+ (temp_git_repo / "file2.txt").rename(temp_git_repo / "renamed2.txt")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Rename files"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Detect renames
+ renames = detect_file_renames(temp_git_repo, old_commit, "HEAD")
+
+ assert len(renames) == 2
+ assert "file1.txt" in renames
+ assert renames["file1.txt"] == "renamed1.txt"
+ assert "file2.txt" in renames
+ assert renames["file2.txt"] == "renamed2.txt"
+
+ def test_returns_empty_dict_when_no_renames(self, temp_git_repo: Path):
+ """Returns empty dict when no renames occurred."""
+ from core.workspace.git_utils import detect_file_renames
+
+ # Create and commit a file
+ (temp_git_repo / "test.txt").write_text("content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ old_commit = result.stdout.strip()
+
+ # Modify file (not rename)
+ (temp_git_repo / "test.txt").write_text("modified content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Modify file"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Detect renames
+ renames = detect_file_renames(temp_git_repo, old_commit, "HEAD")
+
+ assert len(renames) == 0
+
+ def test_returns_empty_dict_on_invalid_refs(self, temp_git_repo: Path):
+ """Returns empty dict when given invalid refs."""
+ from core.workspace.git_utils import detect_file_renames
+
+ renames = detect_file_renames(temp_git_repo, "invalid_ref", "HEAD")
+
+ assert renames == {}
+
+ def test_detects_renames_with_similarity(self, temp_git_repo: Path):
+ """Detects renames even when file content was slightly modified."""
+ from core.workspace.git_utils import detect_file_renames
+
+ # Create and commit a file
+ (temp_git_repo / "old.txt").write_text("line1\nline2\nline3", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ old_commit = result.stdout.strip()
+
+ # Rename and slightly modify
+ (temp_git_repo / "old.txt").rename(temp_git_repo / "new.txt")
+ (temp_git_repo / "new.txt").write_text(
+ "line1\nline2 modified\nline3", encoding="utf-8"
+ )
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Rename and modify"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Detect renames
+ renames = detect_file_renames(temp_git_repo, old_commit, "HEAD")
+
+ # Git may or may not detect rename with similarity threshold
+ # Just verify the function runs without error
+ assert isinstance(renames, dict)
+
+ def test_detects_directory_moves(self, temp_git_repo: Path):
+ """Detects files moved to different directories."""
+ from core.workspace.git_utils import detect_file_renames
+
+ # Create directory structure and commit
+ (temp_git_repo / "src").mkdir()
+ (temp_git_repo / "src" / "old.py").write_text(
+ "def foo(): pass", encoding="utf-8"
+ )
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ old_commit = result.stdout.strip()
+
+ # Create new directory and move file
+ (temp_git_repo / "lib").mkdir()
+ (temp_git_repo / "src" / "old.py").rename(temp_git_repo / "lib" / "new.py")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Move file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Detect renames
+ renames = detect_file_renames(temp_git_repo, old_commit, "HEAD")
+
+ assert len(renames) == 1
+ assert "src/old.py" in renames
+ assert renames["src/old.py"] == "lib/new.py"
+
+
+class TestApplyPathMapping:
+ """Tests for apply_path_mapping function."""
+
+ def test_returns_original_path_when_no_mapping(self):
+ """Returns original path when no mapping exists."""
+
+ mappings = {}
+ result = apply_path_mapping("src/file.py", mappings)
+
+ assert result == "src/file.py"
+
+ def test_returns_mapped_path_when_exact_match(self):
+ """Returns mapped path when exact match found."""
+
+ mappings = {"old/path.py": "new/path.py"}
+ result = apply_path_mapping("old/path.py", mappings)
+
+ assert result == "new/path.py"
+
+ def test_returns_original_path_when_not_in_mappings(self):
+ """Returns original path when path not in mappings."""
+
+ mappings = {"other/file.py": "mapped/file.py"}
+ result = apply_path_mapping("src/file.py", mappings)
+
+ assert result == "src/file.py"
+
+ def test_handles_multiple_mappings(self):
+ """Correctly applies one of many mappings."""
+
+ mappings = {
+ "src/old1.py": "src/new1.py",
+ "src/old2.py": "src/new2.py",
+ "src/old3.py": "src/new3.py",
+ }
+
+ assert apply_path_mapping("src/old1.py", mappings) == "src/new1.py"
+ assert apply_path_mapping("src/old2.py", mappings) == "src/new2.py"
+ assert apply_path_mapping("src/old3.py", mappings) == "src/new3.py"
+
+ def test_handles_empty_path(self):
+ """Handles empty string path."""
+
+ mappings = {"file.py": "mapped.py"}
+ result = apply_path_mapping("", mappings)
+
+ assert result == ""
+
+ def test_handles_path_with_special_characters(self):
+ """Handles paths with special characters."""
+
+ mappings = {"src/file-with-dashes.py": "src/file_with_underscores.py"}
+ result = apply_path_mapping("src/file-with-dashes.py", mappings)
+
+ assert result == "src/file_with_underscores.py"
+
+
+class TestGetMergeBase:
+ """Tests for get_merge_base function."""
+
+ def test_finds_merge_base_for_diverged_branches(self, temp_git_repo: Path):
+ """Finds merge-base commit for two diverged branches."""
+ from core.workspace.git_utils import get_merge_base
+
+ # Create a file on main
+ (temp_git_repo / "base.txt").write_text("base content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Base commit"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Create a feature branch
+ subprocess.run(
+ ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True
+ )
+ (temp_git_repo / "feature.txt").write_text("feature content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Feature commit"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Add a commit to main
+ subprocess.run(
+ ["git", "checkout", "main"], cwd=temp_git_repo, capture_output=True
+ )
+ (temp_git_repo / "main.txt").write_text("main content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Main commit"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Find merge base
+ merge_base = get_merge_base(temp_git_repo, "main", "feature")
+
+ assert merge_base is not None
+ assert len(merge_base) == 40 # SHA-1 hash length
+
+ def test_returns_none_for_invalid_ref(self, temp_git_repo: Path):
+ """Returns None when given invalid ref."""
+ from core.workspace.git_utils import get_merge_base
+
+ merge_base = get_merge_base(temp_git_repo, "main", "invalid_branch")
+
+ assert merge_base is None
+
+ def test_finds_merge_base_same_branch(self, temp_git_repo: Path):
+ """Returns current commit when refs are the same."""
+ from core.workspace.git_utils import get_merge_base
+
+ merge_base = get_merge_base(temp_git_repo, "HEAD", "HEAD")
+
+ assert merge_base is not None
+ assert len(merge_base) == 40
+
+ def test_finds_merge_base_for_ancestors(self, temp_git_repo: Path):
+ """Finds merge-base when one ref is ancestor of other."""
+ from core.workspace.git_utils import get_merge_base
+
+ # Create initial commit
+ (temp_git_repo / "base.txt").write_text("base", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True
+ )
+
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ base_commit = result.stdout.strip()
+
+ # Add commit on top
+ (temp_git_repo / "new.txt").write_text("new", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "New"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Merge base of HEAD and its ancestor should be the ancestor
+ merge_base = get_merge_base(temp_git_repo, "HEAD", base_commit)
+
+ assert merge_base == base_commit
+
+
+class TestGetFileContentFromRef:
+ """Tests for get_file_content_from_ref function."""
+
+ def test_gets_file_content_from_commit(self, temp_git_repo: Path):
+ """Gets file content from a specific commit."""
+ from core.workspace.git_utils import get_file_content_from_ref
+
+ # Create and commit a file
+ (temp_git_repo / "test.txt").write_text("file content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ text=True,
+ )
+ commit_hash = result.stdout.strip()
+
+ # Get file content
+ content = get_file_content_from_ref(temp_git_repo, commit_hash, "test.txt")
+
+ assert content == "file content"
+
+ def test_returns_none_for_nonexistent_file(self, temp_git_repo: Path):
+ """Returns None when file doesn't exist at ref."""
+ from core.workspace.git_utils import get_file_content_from_ref
+
+ content = get_file_content_from_ref(temp_git_repo, "HEAD", "nonexistent.txt")
+
+ assert content is None
+
+ def test_returns_none_for_invalid_ref(self, temp_git_repo: Path):
+ """Returns None when ref doesn't exist."""
+ from core.workspace.git_utils import get_file_content_from_ref
+
+ content = get_file_content_from_ref(temp_git_repo, "invalid_ref", "test.txt")
+
+ assert content is None
+
+ def test_gets_file_content_from_branch(self, temp_git_repo: Path):
+ """Gets file content from a branch name."""
+ from core.workspace.git_utils import get_file_content_from_ref
+
+ # Create and commit a file on main
+ (temp_git_repo / "branch_file.txt").write_text(
+ "branch content", encoding="utf-8"
+ )
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get file content from branch
+ content = get_file_content_from_ref(temp_git_repo, "main", "branch_file.txt")
+
+ assert content == "branch content"
+
+ def test_handles_multiline_file_content(self, temp_git_repo: Path):
+ """Handles multiline file content correctly."""
+ from core.workspace.git_utils import get_file_content_from_ref
+
+ # Create and commit a multiline file
+ content = "line1\nline2\nline3"
+ (temp_git_repo / "multiline.txt").write_text(content, encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get file content
+ result = get_file_content_from_ref(temp_git_repo, "HEAD", "multiline.txt")
+
+ assert result == content
+
+ def test_handles_empty_file(self, temp_git_repo: Path):
+ """Handles empty file correctly."""
+ from core.workspace.git_utils import get_file_content_from_ref
+
+ # Create and commit an empty file
+ (temp_git_repo / "empty.txt").write_text("", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add empty file"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Get file content
+ content = get_file_content_from_ref(temp_git_repo, "HEAD", "empty.txt")
+
+ assert content == ""
+
+
+class TestGetBinaryFileContentFromRef:
+ """Tests for get_binary_file_content_from_ref function."""
+
+ def test_gets_binary_file_content(self, temp_git_repo: Path):
+ """Gets binary file content from a ref."""
+ from core.workspace.git_utils import get_binary_file_content_from_ref
+
+ # Create and commit a binary file
+ binary_content = b"\x00\x01\x02\x03\x04\x05"
+ (temp_git_repo / "binary.bin").write_bytes(binary_content)
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add binary file"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Get binary content
+ content = get_binary_file_content_from_ref(temp_git_repo, "HEAD", "binary.bin")
+
+ assert content == binary_content
+
+ def test_returns_none_for_nonexistent_file(self, temp_git_repo: Path):
+ """Returns None when file doesn't exist."""
+ from core.workspace.git_utils import get_binary_file_content_from_ref
+
+ content = get_binary_file_content_from_ref(
+ temp_git_repo, "HEAD", "nonexistent.bin"
+ )
+
+ assert content is None
+
+ def test_returns_none_for_invalid_ref(self, temp_git_repo: Path):
+ """Returns None when ref doesn't exist."""
+ from core.workspace.git_utils import get_binary_file_content_from_ref
+
+ content = get_binary_file_content_from_ref(
+ temp_git_repo, "invalid_ref", "test.bin"
+ )
+
+ assert content is None
+
+ def test_handles_large_binary_file(self, temp_git_repo: Path):
+ """Handles larger binary files correctly."""
+ from core.workspace.git_utils import get_binary_file_content_from_ref
+
+ # Create and commit a larger binary file
+ binary_content = bytes(range(256)) * 100 # 25.6 KB
+ (temp_git_repo / "large.bin").write_bytes(binary_content)
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add large binary file"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Get binary content
+ content = get_binary_file_content_from_ref(temp_git_repo, "HEAD", "large.bin")
+
+ assert content == binary_content
+
+ def test_handles_zero_byte_file(self, temp_git_repo: Path):
+ """Handles zero-byte binary files."""
+ from core.workspace.git_utils import get_binary_file_content_from_ref
+
+ # Create and commit an empty file
+ (temp_git_repo / "empty.bin").write_bytes(b"")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add empty binary file"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Get binary content
+ content = get_binary_file_content_from_ref(temp_git_repo, "HEAD", "empty.bin")
+
+ assert content == b""
+
+
+class TestGetChangedFilesFromBranch:
+ """Tests for get_changed_files_from_branch function."""
+
+ def test_lists_changed_files(self, temp_git_repo: Path):
+ """Lists all changed files between branches."""
+ from core.workspace.git_utils import get_changed_files_from_branch
+
+ # Create a file on main
+ (temp_git_repo / "base.txt").write_text("base", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Create feature branch with changes
+ subprocess.run(
+ ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True
+ )
+ (temp_git_repo / "new_file.txt").write_text("new", encoding="utf-8")
+ (temp_git_repo / "modified.txt").write_text("modified", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Feature changes"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Get changed files
+ files = get_changed_files_from_branch(temp_git_repo, "main", "feature")
+
+ assert len(files) == 2
+ file_paths = [f[0] for f in files]
+ assert "new_file.txt" in file_paths
+ assert "modified.txt" in file_paths
+
+ def test_excludes_auto_claude_files_by_default(self, temp_git_repo: Path):
+ """Excludes .auto-claude directory files by default."""
+ from core.workspace.git_utils import get_changed_files_from_branch
+
+ # Create base
+ (temp_git_repo / "base.txt").write_text("base", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Create feature branch with .auto-claude files
+ subprocess.run(
+ ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True
+ )
+ (temp_git_repo / ".auto-claude").mkdir()
+ (temp_git_repo / ".auto-claude" / "spec.json").write_text(
+ "spec", encoding="utf-8"
+ )
+ (temp_git_repo / "normal.txt").write_text("normal", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Feature"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get changed files
+ files = get_changed_files_from_branch(temp_git_repo, "main", "feature")
+
+ file_paths = [f[0] for f in files]
+ assert ".auto-claude/spec.json" not in file_paths
+ assert "normal.txt" in file_paths
+
+ def test_includes_auto_claude_files_when_disabled(self, temp_git_repo: Path):
+ """Includes .auto-claude files when exclude_auto_claude=False."""
+ from core.workspace.git_utils import get_changed_files_from_branch
+
+ # Create base
+ (temp_git_repo / "base.txt").write_text("base", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Create feature branch
+ subprocess.run(
+ ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True
+ )
+ (temp_git_repo / ".auto-claude").mkdir()
+ (temp_git_repo / ".auto-claude" / "spec.json").write_text(
+ "spec", encoding="utf-8"
+ )
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Feature"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get changed files without exclusion
+ files = get_changed_files_from_branch(
+ temp_git_repo, "main", "feature", exclude_auto_claude=False
+ )
+
+ file_paths = [f[0] for f in files]
+ assert ".auto-claude/spec.json" in file_paths
+
+ def test_includes_file_status(self, temp_git_repo: Path):
+ """Includes file status (A, M, D) in results."""
+ from core.workspace.git_utils import get_changed_files_from_branch
+
+ # Create base
+ (temp_git_repo / "file.txt").write_text("original", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Create feature branch with additions
+ subprocess.run(
+ ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True
+ )
+ (temp_git_repo / "added.txt").write_text("added", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get changed files
+ files = get_changed_files_from_branch(temp_git_repo, "main", "feature")
+
+ assert len(files) == 1
+ # Status should be 'A' for added
+ assert files[0][1] in (
+ "A",
+ "M",
+ ) # Git may report as A or M depending on version
+
+ def test_returns_empty_list_when_no_changes(self, temp_git_repo: Path):
+ """Returns empty list when there are no changes."""
+ from core.workspace.git_utils import get_changed_files_from_branch
+
+ # Create commit on main
+ (temp_git_repo / "file.txt").write_text("content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Initial"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Create branch at same commit
+ subprocess.run(
+ ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get changed files
+ files = get_changed_files_from_branch(temp_git_repo, "main", "feature")
+
+ assert len(files) == 0
+
+ def test_excludes_legacy_auto_claude_spec_files(self, temp_git_repo: Path):
+ """Excludes auto-claude/specs directory files."""
+ from core.workspace.git_utils import get_changed_files_from_branch
+
+ # Create base
+ (temp_git_repo / "base.txt").write_text("base", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Create feature branch with legacy auto-claude/specs files
+ subprocess.run(
+ ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True
+ )
+ (temp_git_repo / "auto-claude").mkdir()
+ (temp_git_repo / "auto-claude" / "specs").mkdir()
+ (temp_git_repo / "auto-claude" / "specs" / "spec.md").write_text(
+ "spec", encoding="utf-8"
+ )
+ (temp_git_repo / "normal.txt").write_text("normal", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Feature"], cwd=temp_git_repo, capture_output=True
+ )
+
+ # Get changed files
+ files = get_changed_files_from_branch(temp_git_repo, "main", "feature")
+
+ file_paths = [f[0] for f in files]
+ assert "auto-claude/specs/spec.md" not in file_paths
+ assert "normal.txt" in file_paths
+
+
+class TestIsProcessRunning:
+ """Tests for is_process_running function."""
+
+ def test_returns_false_for_nonexistent_pid(self):
+ """Returns False for a non-existent PID."""
+ from core.workspace.git_utils import is_process_running
+
+ # Use a very high PID that's unlikely to exist
+ result = is_process_running(999999)
+
+ assert result is False
+
+ def test_returns_true_for_current_process(self):
+ """Returns True for the current process PID."""
+ import os
+
+ from core.workspace.git_utils import is_process_running
+
+ current_pid = os.getpid()
+ result = is_process_running(current_pid)
+
+ assert result is True
+
+
+class TestIsBinaryFile:
+ """Tests for is_binary_file function."""
+
+ def test_identifies_image_files(self):
+ """Identifies image files as binary."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("image.png") is True
+ assert is_binary_file("photo.jpg") is True
+ assert is_binary_file("picture.jpeg") is True
+ assert is_binary_file("graphic.gif") is True
+ assert is_binary_file("icon.ico") is True
+ assert is_binary_file("image.webp") is True
+ assert is_binary_file("image.bmp") is True
+ assert is_binary_file("image.svg") is True
+ assert is_binary_file("image.tiff") is True
+
+ def test_identifies_document_files(self):
+ """Identifies document files as binary."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("doc.pdf") is True
+ assert is_binary_file("doc.doc") is True
+ assert is_binary_file("doc.docx") is True
+ assert is_binary_file("sheet.xls") is True
+ assert is_binary_file("sheet.xlsx") is True
+
+ def test_identifies_archive_files(self):
+ """Identifies archive files as binary."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("archive.zip") is True
+ assert is_binary_file("archive.tar") is True
+ assert is_binary_file("archive.gz") is True
+ assert is_binary_file("archive.rar") is True
+ assert is_binary_file("archive.7z") is True
+ assert is_binary_file("archive.bz2") is True
+
+ def test_identifies_executable_files(self):
+ """Identifies executable files as binary."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("program.exe") is True
+ assert is_binary_file("library.dll") is True
+ assert is_binary_file("library.so") is True
+ assert is_binary_file("library.dylib") is True
+ assert is_binary_file("binary.bin") is True
+
+ def test_identifies_audio_files(self):
+ """Identifies audio files as binary."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("audio.mp3") is True
+ assert is_binary_file("audio.wav") is True
+ assert is_binary_file("audio.ogg") is True
+ assert is_binary_file("audio.flac") is True
+
+ def test_identifies_video_files(self):
+ """Identifies video files as binary."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("video.mp4") is True
+ assert is_binary_file("video.avi") is True
+ assert is_binary_file("video.mov") is True
+ assert is_binary_file("video.mkv") is True
+
+ def test_identifies_font_files(self):
+ """Identifies font files as binary."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("font.woff") is True
+ assert is_binary_file("font.woff2") is True
+ assert is_binary_file("font.ttf") is True
+ assert is_binary_file("font.otf") is True
+
+ def test_returns_false_for_text_files(self):
+ """Returns False for text files."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("file.txt") is False
+ assert is_binary_file("file.py") is False
+ assert is_binary_file("file.js") is False
+ assert is_binary_file("file.ts") is False
+ assert is_binary_file("file.md") is False
+ assert is_binary_file("file.json") is False
+ assert is_binary_file("file.xml") is False
+ assert is_binary_file("file.yaml") is False
+ assert is_binary_file("file.yml") is False
+
+ def test_case_insensitive_extension_check(self):
+ """Handles uppercase extensions correctly."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("image.PNG") is True
+ assert is_binary_file("image.JPG") is True
+ assert is_binary_file("document.PDF") is True
+
+ def test_handles_paths_with_directories(self):
+ """Handles file paths with directory components."""
+ from core.workspace.git_utils import is_binary_file
+
+ assert is_binary_file("path/to/image.png") is True
+ assert is_binary_file("src/lib/file.py") is False
+ assert is_binary_file("assets/logo.jpg") is True
+
+
+class TestIsLockFile:
+ """Tests for is_lock_file function."""
+
+ def test_identifies_npm_lock_file(self):
+ """Identifies package-lock.json as lock file."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("package-lock.json") is True
+
+ def test_identifies_pnpm_lock_file(self):
+ """Identifies pnpm-lock.yaml as lock file."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("pnpm-lock.yaml") is True
+
+ def test_identifies_yarn_lock_file(self):
+ """Identifies yarn.lock as lock file."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("yarn.lock") is True
+
+ def test_identifies_bun_lock_files(self):
+ """Identifies bun.lockb and bun.lock as lock files."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("bun.lockb") is True
+ assert is_lock_file("bun.lock") is True
+
+ def test_identifies_python_lock_files(self):
+ """Identifies Python lock files."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("Pipfile.lock") is True
+ assert is_lock_file("poetry.lock") is True
+ assert is_lock_file("uv.lock") is True
+
+ def test_identifies_rust_lock_file(self):
+ """Identifies Cargo.lock as lock file."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("Cargo.lock") is True
+
+ def test_identifies_ruby_lock_file(self):
+ """Identifies Gemfile.lock as lock file."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("Gemfile.lock") is True
+
+ def test_identifies_php_lock_file(self):
+ """Identifies composer.lock as lock file."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("composer.lock") is True
+
+ def test_identifies_go_lock_file(self):
+ """Identifies go.sum as lock file."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("go.sum") is True
+
+ def test_returns_false_for_non_lock_files(self):
+ """Returns False for non-lock files."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("package.json") is False
+ assert is_lock_file("pyproject.toml") is False
+ assert is_lock_file("Cargo.toml") is False
+ assert is_lock_file("Gemfile") is False
+ assert is_lock_file("file.txt") is False
+
+ def test_handles_paths_with_directories(self):
+ """Handles file paths with directory components."""
+ from core.workspace.git_utils import is_lock_file
+
+ assert is_lock_file("path/to/package-lock.json") is True
+ assert is_lock_file("src/pnpm-lock.yaml") is True
+ assert is_lock_file("deps/yarn.lock") is True
+
+
+class TestValidateMergedSyntax:
+ """Tests for validate_merged_syntax function."""
+
+ def test_validates_python_syntax_successfully(self, temp_dir: Path):
+ """Validates correct Python syntax successfully."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "def hello():\n return 'world'\n"
+ is_valid, error = validate_merged_syntax("test.py", code, temp_dir)
+
+ assert is_valid is True
+ assert error == ""
+
+ def test_detects_python_syntax_errors(self, temp_dir: Path):
+ """Detects Python syntax errors."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "def hello(\n return 'world'\n"
+ is_valid, error = validate_merged_syntax("test.py", code, temp_dir)
+
+ assert is_valid is False
+ assert "syntax error" in error.lower()
+
+ def test_validates_json_syntax_successfully(self, temp_dir: Path):
+ """Validates correct JSON syntax successfully."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = '{"key": "value", "number": 123}'
+ is_valid, error = validate_merged_syntax("test.json", code, temp_dir)
+
+ assert is_valid is True
+ assert error == ""
+
+ def test_detects_json_syntax_errors(self, temp_dir: Path):
+ """Detects JSON syntax errors."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = '{"key": "value", "number"'
+ is_valid, error = validate_merged_syntax("test.json", code, temp_dir)
+
+ assert is_valid is False
+ assert "json error" in error.lower() or "syntax" in error.lower()
+
+ def test_skips_validation_for_unknown_extensions(self, temp_dir: Path):
+ """Skips validation for unknown file types."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "some random content"
+ is_valid, error = validate_merged_syntax("file.unknown", code, temp_dir)
+
+ assert is_valid is True
+ assert error == ""
+
+ def test_validates_typescript_with_mocked_esbuild(self, temp_dir: Path):
+ """Validates TypeScript using esbuild (mocked)."""
+ from unittest.mock import MagicMock, patch
+
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "const x: number = 123;\n"
+
+ # Mock subprocess.run for esbuild
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = ""
+ mock_result.stderr = ""
+
+ with patch("subprocess.run", return_value=mock_result):
+ is_valid, error = validate_merged_syntax("test.ts", code, temp_dir)
+
+ # If esbuild is found, should validate
+ # If not found, should skip validation (return True)
+ assert is_valid is True
+
+ def test_detects_typescript_syntax_errors_with_mock(self, temp_dir: Path):
+ """Detects TypeScript syntax errors (mocked esbuild)."""
+ from unittest.mock import MagicMock, patch
+
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "const x: = 123;\n" # Invalid syntax
+
+ # Mock subprocess.run for esbuild to return error
+ mock_result = MagicMock()
+ mock_result.returncode = 1
+ mock_result.stdout = ""
+ mock_result.stderr = "✘ [ERROR] Expected expression but found '}'"
+
+ with patch("subprocess.run", return_value=mock_result):
+ is_valid, error = validate_merged_syntax("test.ts", code, temp_dir)
+
+ assert is_valid is False
+ assert "syntax error" in error.lower()
+
+ def test_skips_validation_when_esbuild_not_found(self, temp_dir: Path):
+ """Skips validation when esbuild is not available."""
+ from unittest.mock import patch
+
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "const x: number = 123;\n"
+
+ # Mock subprocess.run to raise FileNotFoundError
+ with patch("subprocess.run", side_effect=FileNotFoundError):
+ is_valid, error = validate_merged_syntax("test.ts", code, temp_dir)
+
+ assert is_valid is True
+ assert error == ""
+
+ def test_validates_javascript_with_mocked_esbuild(self, temp_dir: Path):
+ """Validates JavaScript using esbuild (mocked)."""
+ from unittest.mock import MagicMock, patch
+
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "const x = 123;\n"
+
+ # Mock subprocess.run for esbuild
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = ""
+ mock_result.stderr = ""
+
+ with patch("subprocess.run", return_value=mock_result):
+ is_valid, error = validate_merged_syntax("test.js", code, temp_dir)
+
+ assert is_valid is True
+
+ def test_validates_jsx_with_mocked_esbuild(self, temp_dir: Path):
+ """Validates JSX using esbuild (mocked)."""
+ from unittest.mock import MagicMock, patch
+
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "const App = () =>
Hello
;\n"
+
+ # Mock subprocess.run for esbuild
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = ""
+ mock_result.stderr = ""
+
+ with patch("subprocess.run", return_value=mock_result):
+ is_valid, error = validate_merged_syntax("test.jsx", code, temp_dir)
+
+ assert is_valid is True
+
+ def test_validates_tsx_with_mocked_esbuild(self, temp_dir: Path):
+ """Validates TSX using esbuild (mocked)."""
+ from unittest.mock import MagicMock, patch
+
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "const App: React.FC = () => Hello
;\n"
+
+ # Mock subprocess.run for esbuild
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = ""
+ mock_result.stderr = ""
+
+ with patch("subprocess.run", return_value=mock_result):
+ is_valid, error = validate_merged_syntax("test.tsx", code, temp_dir)
+
+ assert is_valid is True
+
+ def test_handles_python_indentation_errors(self, temp_dir: Path):
+ """Detects Python indentation errors."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "def hello():\n return 'world'\n return 'bad'\n"
+ is_valid, error = validate_merged_syntax("test.py", code, temp_dir)
+
+ assert is_valid is False
+ assert "syntax error" in error.lower() or "indentation" in error.lower()
+
+ def test_validates_empty_python_file(self, temp_dir: Path):
+ """Validates empty Python file."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = ""
+ is_valid, error = validate_merged_syntax("test.py", code, temp_dir)
+
+ assert is_valid is True
+
+ def test_validates_empty_json_file(self, temp_dir: Path):
+ """Validates empty JSON file."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "{}"
+ is_valid, error = validate_merged_syntax("test.json", code, temp_dir)
+
+ # Empty object is valid JSON
+ assert is_valid is True
+
+ def test_validates_complex_json(self, temp_dir: Path):
+ """Validates complex nested JSON."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = '{"nested": {"key": "value", "array": [1, 2, 3]}}'
+ is_valid, error = validate_merged_syntax("test.json", code, temp_dir)
+
+ assert is_valid is True
+
+ def test_detects_json_with_trailing_comma(self, temp_dir: Path):
+ """Detects JSON error with trailing comma."""
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = '{"key": "value",}'
+ is_valid, error = validate_merged_syntax("test.json", code, temp_dir)
+
+ assert is_valid is False
+
+ def test_handles_esbuild_timeout_gracefully(self, temp_dir: Path):
+ """Handles esbuild timeout by skipping validation."""
+ import subprocess
+ from unittest.mock import patch
+
+ from core.workspace.git_utils import validate_merged_syntax
+
+ code = "const x = 123;\n"
+
+ # Mock subprocess.run to raise TimeoutExpired
+ with patch(
+ "subprocess.run", side_effect=subprocess.TimeoutExpired("esbuild", 15)
+ ):
+ is_valid, error = validate_merged_syntax("test.ts", code, temp_dir)
+
+ assert is_valid is True
+ assert error == ""
+
+
+class TestCreateConflictFileWithGit:
+ """Tests for create_conflict_file_with_git function."""
+
+ def test_creates_clean_merge(self, temp_git_repo: Path):
+ """Creates merged content when there are no conflicts."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ main_content = "line1\nline2\nline3"
+ worktree_content = "line1\nline2\nline3"
+ base_content = "line1\nline2\nline3"
+
+ merged, had_conflicts = create_conflict_file_with_git(
+ main_content, worktree_content, base_content, temp_git_repo
+ )
+
+ assert had_conflicts is False
+ assert merged is not None
+ assert "line1" in merged
+
+ def test_detects_conflicts(self, temp_git_repo: Path):
+ """Detects conflicts and adds conflict markers."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ main_content = "line1\nmain version\nline3"
+ worktree_content = "line1\nworktree version\nline3"
+ base_content = "line1\nline2\nline3"
+
+ merged, had_conflicts = create_conflict_file_with_git(
+ main_content, worktree_content, base_content, temp_git_repo
+ )
+
+ assert had_conflicts is True
+ assert merged is not None
+ assert "<<<<<<<" in merged or "=======" in merged or ">>>>>>>" in merged
+
+ def test_handles_none_base_content(self, temp_git_repo: Path):
+ """Handles None as base content."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ main_content = "line1\nline2"
+ worktree_content = "line1\nline2"
+
+ merged, had_conflicts = create_conflict_file_with_git(
+ main_content, worktree_content, None, temp_git_repo
+ )
+
+ assert had_conflicts is False
+ assert merged is not None
+
+ def test_returns_none_on_error(self, temp_dir: Path):
+ """Returns (None, False) when git merge-file fails."""
+ from unittest.mock import patch
+
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ # Mock run_git to raise an exception
+ with patch(
+ "core.workspace.git_utils.run_git", side_effect=Exception("Git error")
+ ):
+ merged, had_conflicts = create_conflict_file_with_git(
+ "main", "worktree", "base", temp_dir
+ )
+
+ assert merged is None
+ assert had_conflicts is False
+
+ def test_auto_merges_when_only_main_changed(self, temp_git_repo: Path):
+ """Auto-merges when only main content changed from base."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ base_content = "original line"
+ main_content = "modified line"
+ worktree_content = "original line"
+
+ merged, had_conflicts = create_conflict_file_with_git(
+ main_content, worktree_content, base_content, temp_git_repo
+ )
+
+ assert had_conflicts is False
+ assert merged is not None
+ assert "modified line" in merged
+
+ def test_auto_merges_when_only_worktree_changed(self, temp_git_repo: Path):
+ """Auto-merges when only worktree content changed from base."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ base_content = "original line"
+ main_content = "original line"
+ worktree_content = "modified line"
+
+ merged, had_conflicts = create_conflict_file_with_git(
+ main_content, worktree_content, base_content, temp_git_repo
+ )
+
+ assert had_conflicts is False
+ assert merged is not None
+ assert "modified line" in merged
+
+ def test_handles_multiline_conflicts(self, temp_git_repo: Path):
+ """Handles conflicts in multiline content."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ main_content = "line1\nline2 main\nline3"
+ worktree_content = "line1\nline2 worktree\nline3"
+ base_content = "line1\nline2\nline3"
+
+ merged, had_conflicts = create_conflict_file_with_git(
+ main_content, worktree_content, base_content, temp_git_repo
+ )
+
+ assert had_conflicts is True
+ assert merged is not None
+
+ def test_handles_empty_contents(self, temp_git_repo: Path):
+ """Handles empty string contents."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ merged, had_conflicts = create_conflict_file_with_git("", "", "", temp_git_repo)
+
+ assert had_conflicts is False
+ assert merged is not None
+
+ def test_cleanup_temp_files(self, temp_git_repo: Path):
+ """Cleans up temporary files after merge."""
+ import tempfile
+ from pathlib import Path
+
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ # Count temp files before
+ temp_dir = tempfile.gettempdir()
+ # Run merge
+ create_conflict_file_with_git("content", "content", "content", temp_git_repo)
+
+ # Note: This is a weak test as other processes may create temp files
+ # The main assertion is that no exception is raised
+ assert True # If we got here without exception, cleanup worked
+
+ def test_preserves_newlines_in_merged_content(self, temp_git_repo: Path):
+ """Preserves newlines in merged content."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ content = "line1\nline2\nline3\n"
+ merged, had_conflicts = create_conflict_file_with_git(
+ content, content, content, temp_git_repo
+ )
+
+ assert had_conflicts is False
+ assert merged is not None
+ assert "\n" in merged
+
+ def test_handles_unicode_content(self, temp_git_repo: Path):
+ """Handles unicode characters in content."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ content = "# Comment with émoji 🎉\nline1\n"
+ merged, had_conflicts = create_conflict_file_with_git(
+ content, content, content, temp_git_repo
+ )
+
+ assert had_conflicts is False
+ assert merged is not None
+ assert "émoji" in merged or "🎉" in merged
+
+ def test_conflict_markers_format(self, temp_git_repo: Path):
+ """Verifies conflict marker format."""
+ from core.workspace.git_utils import create_conflict_file_with_git
+
+ main_content = "main version"
+ worktree_content = "worktree version"
+ base_content = "base version"
+
+ merged, had_conflicts = create_conflict_file_with_git(
+ main_content, worktree_content, base_content, temp_git_repo
+ )
+
+ if had_conflicts:
+ # Check for standard git conflict markers
+ assert "<<<<<<<" in merged
+ assert "=======" in merged
+ assert ">>>>>>>" in merged
+
+
+# =============================================================================
+# TESTS FOR MISSING COVERAGE IN git_utils.py AND models.py
+# =============================================================================
+
+from core.workspace.git_utils import (
+ apply_path_mapping,
+ detect_file_renames,
+ validate_merged_syntax,
+)
+
+
+class TestDetectFileRenamesErrorHandling:
+ """Tests for error handling in detect_file_renames (lines 214-215)."""
+
+ def test_detect_file_renames_handles_git_command_failure(self, temp_git_repo: Path):
+ """detect_file_renames returns empty dict when git command fails (line 214-215)."""
+ from unittest.mock import patch
+
+ with patch("core.workspace.git_utils.run_git") as mock_git:
+ # Simulate git command failure
+ mock_git.return_value = type(
+ "Result", (), {"returncode": 1, "stdout": ""}
+ )()
+
+ result = detect_file_renames(temp_git_repo, "main", "feature")
+
+ assert result == {}
+ mock_git.assert_called_once()
+
+ def test_detect_file_renames_handles_exception_during_parsing(
+ self, temp_git_repo: Path
+ ):
+ """detect_file_renames returns empty dict when exception occurs (line 214-215)."""
+ from unittest.mock import patch
+
+ with patch("core.workspace.git_utils.run_git") as mock_git:
+ # Simulate an exception during git command execution
+ mock_git.side_effect = Exception("Git command failed")
+
+ result = detect_file_renames(temp_git_repo, "main", "feature")
+
+ # Should return empty dict on error
+ assert result == {}
+
+ def test_detect_file_renames_handles_malformed_git_output(
+ self, temp_git_repo: Path
+ ):
+ """detect_file_renames handles malformed git output gracefully (line 214-215)."""
+ from unittest.mock import patch
+
+ with patch("core.workspace.git_utils.run_git") as mock_git:
+ # Return success but with malformed output
+ mock_git.return_value = type(
+ "Result", (), {"returncode": 0, "stdout": "R\tincomplete\n"}
+ )()
+
+ result = detect_file_renames(temp_git_repo, "main", "feature")
+
+ # Should handle gracefully and not crash
+ assert isinstance(result, dict)
+
+ def test_detect_file_renames_returns_empty_dict_on_invalid_refs(
+ self, temp_git_repo: Path
+ ):
+ """detect_file_renames returns empty dict for non-existent refs."""
+ result = detect_file_renames(
+ temp_git_repo, "nonexistent-ref-1", "nonexistent-ref-2"
+ )
+
+ # Should return empty dict when refs don't exist
+ assert result == {}
+
+
+class TestValidateMergedSyntaxErrorHandling:
+ """Tests for error handling in validate_merged_syntax (lines 450-469, 506-507)."""
+
+ def test_validate_merged_syntax_generic_exception_handling(
+ self, temp_git_repo: Path
+ ):
+ """validate_merged_syntax handles generic exceptions gracefully (lines 506-507)."""
+ from unittest.mock import patch
+
+ # Test with a TypeScript file that will trigger an exception
+ with patch("subprocess.run") as mock_run:
+ # Simulate a generic exception (not TimeoutExpired or FileNotFoundError)
+ mock_run.side_effect = RuntimeError("Unexpected error")
+
+ is_valid, error = validate_merged_syntax(
+ "test.ts", "const x: string = 'test';", temp_git_repo
+ )
+
+ # Should return True (skip validation) on generic exception
+ assert is_valid is True
+ assert error == ""
+
+ def test_validate_merged_syntax_handles_permission_error(self, temp_git_repo: Path):
+ """validate_merged_syntax handles permission errors during temp file creation."""
+ from unittest.mock import patch
+
+ with patch("tempfile.NamedTemporaryFile") as mock_tmp:
+ # Simulate permission error
+ mock_tmp.side_effect = PermissionError("Permission denied")
+
+ is_valid, error = validate_merged_syntax(
+ "test.ts", "const x: string = 'test';", temp_git_repo
+ )
+
+ # Should return True on permission error (skip validation)
+ assert is_valid is True
+ assert error == ""
+
+ def test_validate_merged_syntax_handles_os_error(self, temp_git_repo: Path):
+ """validate_merged_syntax handles OS errors gracefully."""
+ from unittest.mock import patch
+
+ with patch("tempfile.NamedTemporaryFile") as mock_tmp:
+ # Simulate OS error
+ mock_tmp.side_effect = OSError("OS error")
+
+ is_valid, error = validate_merged_syntax(
+ "test.ts", "const x: string = 'test';", temp_git_repo
+ )
+
+ # Should return True on OS error
+ assert is_valid is True
+ assert error == ""
+
+ @pytest.mark.slow
+ def test_validate_merged_syntax_finds_pnpm_esbuild(self, temp_git_repo: Path):
+ """validate_merged_syntax finds esbuild in pnpm structure (lines 450-455)."""
+ # Create pnpm-style node_modules structure
+ pnpm_dir = temp_git_repo / "node_modules" / ".pnpm"
+ esbuild_version_dir = (
+ pnpm_dir / "esbuild@0.19.0" / "node_modules" / "esbuild" / "bin"
+ )
+ esbuild_version_dir.mkdir(parents=True)
+
+ # Create a fake esbuild executable
+ esbuild_binary = esbuild_version_dir / "esbuild"
+ if os.name != "nt":
+ esbuild_binary.write_text(
+ "#!/bin/sh\necho 'esbuild found'\n", encoding="utf-8"
+ )
+ os.chmod(esbuild_binary, 0o700)
+ else:
+ esbuild_binary.write_text("echo esbuild found", encoding="utf-8")
+
+ # This test verifies the pnpm path search logic
+ # Note: Actual esbuild execution may still be skipped if not properly installed
+ is_valid, error = validate_merged_syntax(
+ "test.ts", "const x: string = 'test';", temp_git_repo
+ )
+
+ # Should not crash; result depends on whether esbuild actually runs
+ assert isinstance(is_valid, bool)
+ assert isinstance(error, str)
+
+ @pytest.mark.slow
+ def test_validate_merged_syntax_finds_npm_esbuild(self, temp_git_repo: Path):
+ """validate_merged_syntax finds esbuild in npm structure (lines 459-460)."""
+ # Create npm-style node_modules structure
+ npm_bin_dir = temp_git_repo / "node_modules" / ".bin"
+ npm_bin_dir.mkdir(parents=True)
+
+ # Create a fake esbuild executable
+ esbuild_binary = npm_bin_dir / "esbuild"
+ if os.name != "nt":
+ esbuild_binary.write_text(
+ "#!/bin/sh\necho 'esbuild found'\n", encoding="utf-8"
+ )
+ os.chmod(esbuild_binary, 0o700)
+ else:
+ esbuild_binary.write_text("echo esbuild found", encoding="utf-8")
+
+ # This test verifies the npm path search logic
+ is_valid, error = validate_merged_syntax(
+ "test.ts", "const x: string = 'test';", temp_git_repo
+ )
+
+ # Should not crash
+ assert isinstance(is_valid, bool)
+ assert isinstance(error, str)
+
+ @pytest.mark.slow
+ def test_validate_merged_syntax_searches_parent_directory(
+ self, temp_git_repo: Path
+ ):
+ """validate_merged_syntax searches parent directory for esbuild (line 462)."""
+ # Create esbuild in parent directory (apps/frontend sibling structure simulation)
+ # This simulates the monorepo structure where backend searches frontend's node_modules
+ parent_dir = temp_git_repo.parent
+ if parent_dir.exists():
+ npm_bin_dir = parent_dir / "node_modules" / ".bin"
+ npm_bin_dir.mkdir(parents=True, exist_ok=True)
+
+ esbuild_binary = npm_bin_dir / "esbuild"
+ if os.name != "nt":
+ esbuild_binary.write_text(
+ "#!/bin/sh\necho 'esbuild'\n", encoding="utf-8"
+ )
+ os.chmod(esbuild_binary, 0o700)
+ else:
+ esbuild_binary.write_text("echo esbuild", encoding="utf-8")
+
+ is_valid, error = validate_merged_syntax(
+ "test.ts", "const x: string = 'test';", temp_git_repo
+ )
+
+ assert isinstance(is_valid, bool)
+
+ def test_validate_merged_syntax_falls_back_to_npx(self, temp_git_repo: Path):
+ """validate_merged_syntax falls back to npx when esbuild not found (line 469)."""
+ # Ensure no local esbuild exists
+ npm_bin = temp_git_repo / "node_modules" / ".bin"
+ if npm_bin.exists():
+ import shutil
+
+ shutil.rmtree(npm_bin)
+
+ # Should fall back to npx and not crash
+ # Note: npx may or may not be available, but function should handle it
+ is_valid, error = validate_merged_syntax(
+ "test.ts", "const x: string = 'test';", temp_git_repo
+ )
+
+ # Should return True if npx not available (skip validation)
+ # or actual validation result if npx is available
+ assert isinstance(is_valid, bool)
+ assert isinstance(error, str)
+
+ def test_validate_merged_syntax_npx_fallback_with_mock(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """validate_merged_syntax uses npx fallback when esbuild binary not found (lines 466-467)."""
+ from unittest.mock import MagicMock, patch
+
+ # Mock Path.exists() to ensure no esbuild binary is found anywhere
+ original_exists = Path.exists
+
+ def mock_exists(self):
+ """Return False for any esbuild-related paths."""
+ path_str = str(self)
+ # Return False for esbuild binary paths to force npx fallback
+ if "esbuild" in path_str and (
+ "node_modules" in path_str or ".bin" in path_str
+ ):
+ return False
+ # Otherwise use original exists
+ return original_exists(self)
+
+ # Use Path object directly, not string path
+ monkeypatch.setattr(Path, "exists", mock_exists)
+
+ # Track the actual subprocess.run calls
+ run_calls = []
+
+ def mock_run(args, **kwargs):
+ """Mock that verifies npx fallback is used."""
+ run_calls.append((args, kwargs))
+ # Simulate successful npx esbuild execution
+
+ completed = MagicMock()
+ completed.returncode = 0
+ completed.stdout = b""
+ completed.stderr = b""
+ return completed
+
+ monkeypatch.setattr("subprocess.run", mock_run)
+
+ # Test file with valid TypeScript syntax
+ test_content = "const x: string = 'test';"
+ test_file = temp_git_repo / "test.ts"
+ test_file.write_text(test_content, encoding="utf-8")
+
+ # Call validate_merged_syntax
+ from core.workspace.git_utils import validate_merged_syntax
+
+ is_valid, error = validate_merged_syntax(
+ str(test_file), test_content, temp_git_repo
+ )
+
+ # Verify npx fallback was used
+ assert len(run_calls) > 0
+ npx_used = any("npx" in str(call[0]) for call in run_calls)
+ assert npx_used, "npx fallback should be used when esbuild binary not found"
+
+ # Should return True since syntax is valid
+ assert is_valid is True
diff --git a/apps/backend/core/workspace/tests/test_merge.py b/apps/backend/core/workspace/tests/test_merge.py
new file mode 100644
index 00000000..0e6dba93
--- /dev/null
+++ b/apps/backend/core/workspace/tests/test_merge.py
@@ -0,0 +1,1482 @@
+#!/usr/bin/env python3
+"""
+Tests for Workspace Merge Operations
+=====================================
+
+Tests the merge functionality including:
+- Language inference from file paths
+- Code fence stripping
+- Simple 3-way merge attempts
+- Merge prompt building
+- Merge progress callbacks
+- AI-assisted merge operations
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+# Test constant - in the new per-spec architecture, each spec has its own worktree
+# named after the spec itself. This constant is used for test assertions.
+TEST_SPEC_NAME = "test-spec"
+
+
+class TestInferLanguageFromPath:
+ def test_python_file(self):
+ """Correctly identifies Python files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("test.py") == "python"
+ assert _infer_language_from_path("src/app.py") == "python"
+
+ def test_javascript_file(self):
+ """Correctly identifies JavaScript files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("test.js") == "javascript"
+ assert _infer_language_from_path("src/app.js") == "javascript"
+
+ def test_jsx_file(self):
+ """Correctly identifies JSX files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("App.jsx") == "javascript"
+
+ def test_typescript_file(self):
+ """Correctly identifies TypeScript files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("test.ts") == "typescript"
+
+ def test_tsx_file(self):
+ """Correctly identifies TSX files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("App.tsx") == "typescript"
+
+ def test_rust_file(self):
+ """Correctly identifies Rust files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("main.rs") == "rust"
+
+ def test_go_file(self):
+ """Correctly identifies Go files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("main.go") == "go"
+
+ def test_java_file(self):
+ """Correctly identifies Java files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("Main.java") == "java"
+
+ def test_cpp_file(self):
+ """Correctly identifies C++ files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("main.cpp") == "cpp"
+
+ def test_c_file(self):
+ """Correctly identifies C files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("main.c") == "c"
+
+ def test_header_file(self):
+ """Correctly identifies C header files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("header.h") == "c"
+
+ def test_hpp_file(self):
+ """Correctly identifies C++ header files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("header.hpp") == "cpp"
+
+ def test_ruby_file(self):
+ """Correctly identifies Ruby files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("app.rb") == "ruby"
+
+ def test_php_file(self):
+ """Correctly identifies PHP files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("index.php") == "php"
+
+ def test_swift_file(self):
+ """Correctly identifies Swift files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("App.swift") == "swift"
+
+ def test_kotlin_file(self):
+ """Correctly identifies Kotlin files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("Main.kt") == "kotlin"
+
+ def test_scala_file(self):
+ """Correctly identifies Scala files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("Main.scala") == "scala"
+
+ def test_json_file(self):
+ """Correctly identifies JSON files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("config.json") == "json"
+
+ def test_yaml_file(self):
+ """Correctly identifies YAML files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("config.yaml") == "yaml"
+
+ def test_yml_file(self):
+ """Correctly identifies YML files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("config.yml") == "yaml"
+
+ def test_toml_file(self):
+ """Correctly identifies TOML files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("config.toml") == "toml"
+
+ def test_markdown_file(self):
+ """Correctly identifies Markdown files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("README.md") == "markdown"
+
+ def test_html_file(self):
+ """Correctly identifies HTML files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("index.html") == "html"
+
+ def test_css_file(self):
+ """Correctly identifies CSS files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("style.css") == "css"
+
+ def test_scss_file(self):
+ """Correctly identifies SCSS files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("style.scss") == "scss"
+
+ def test_sql_file(self):
+ """Correctly identifies SQL files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("query.sql") == "sql"
+
+ def test_unknown_extension(self):
+ """Defaults to 'text' for unknown extensions."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("file.unknown") == "text"
+
+ def test_no_extension(self):
+ """Defaults to 'text' for files without extension."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("Makefile") == "text"
+
+ def test_case_insensitive(self):
+ """Handles uppercase extensions correctly."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("test.PY") == "python"
+ assert _infer_language_from_path("test.JS") == "javascript"
+
+ def test_nested_path(self):
+ """Correctly infers language from nested paths."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("src/components/Button.tsx") == "typescript"
+
+ def test_dockerfile(self):
+ """Defaults to 'text' for Dockerfile without extension."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("Dockerfile") == "text"
+
+ def test_makefile(self):
+ """Defaults to 'text' for Makefile without extension."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("Makefile") == "text"
+
+ def test_gitignore(self):
+ """Defaults to 'text' for .gitignore without extension."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path(".gitignore") == "text"
+
+ def test_env_file(self):
+ """Defaults to 'text' for .env files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path(".env") == "text"
+
+ def test_config_yaml(self):
+ """Identifies YAML in config files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("app.config.yaml") == "yaml"
+
+ def test_sh_file(self):
+ """Defaults to 'text' for shell scripts."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("script.sh") == "text"
+
+ def test_txt_file(self):
+ """Defaults to 'text' for .txt files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("notes.txt") == "text"
+
+ def test_xml_file(self):
+ """Defaults to 'text' for .xml files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("config.xml") == "text"
+
+ def test_md_file_in_docs(self):
+ """Identifies markdown in documentation paths."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("docs/api.md") == "markdown"
+
+ def test_package_json(self):
+ """Identifies JSON in package files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("package.json") == "json"
+
+ def test_tsconfig_json(self):
+ """Identifies JSON in TypeScript config files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("tsconfig.json") == "json"
+
+ def test_python_init_file(self):
+ """Identifies Python in __init__ files."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("package/__init__.py") == "python"
+
+ def test_absolute_path(self):
+ """Handles absolute paths correctly."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("/usr/local/bin/script.py") == "python"
+
+ def test_windows_path(self):
+ """Handles Windows paths correctly."""
+ from core.workspace import _infer_language_from_path
+
+ assert _infer_language_from_path("C:\\Users\\test\\file.js") == "javascript"
+
+
+class TestStripCodeFences:
+ """Tests for _strip_code_fences function."""
+
+ def test_basic_code_fence(self):
+ """Removes basic markdown code fences."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\ndef hello():\n pass\n```"
+ result = _strip_code_fences(content)
+ assert result == "def hello():\n pass"
+
+ def test_code_fence_with_language(self):
+ """Removes code fence with language specified."""
+ from core.workspace import _strip_code_fences
+
+ content = "```javascript\nconst x = 1;\n```"
+ result = _strip_code_fences(content)
+ assert result == "const x = 1;"
+
+ def test_no_code_fence(self):
+ """Returns content unchanged when no code fence present."""
+ from core.workspace import _strip_code_fences
+
+ content = "just some text"
+ result = _strip_code_fences(content)
+ assert result == content
+
+ def test_code_fence_without_closing_fence(self):
+ """Handles opening fence without closing fence."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\ndef hello():\n pass"
+ result = _strip_code_fences(content)
+ assert result == "def hello():\n pass"
+
+ def test_multiple_lines_fence(self):
+ """Handles multi-line code with fences."""
+ from core.workspace import _strip_code_fences
+
+ content = "```\nline1\nline2\nline3\n```"
+ result = _strip_code_fences(content)
+ assert result == "line1\nline2\nline3"
+
+ def test_whitespace_around_fences(self):
+ """Handles whitespace around code fences."""
+ from core.workspace import _strip_code_fences
+
+ content = " ```python\ndef hello():\n ``` "
+ result = _strip_code_fences(content)
+ assert "def hello():" in result
+
+ def test_empty_fence(self):
+ """Handles empty code fence."""
+ from core.workspace import _strip_code_fences
+
+ content = "```\n```"
+ result = _strip_code_fences(content)
+ assert result == ""
+
+ def test_fence_with_no_language(self):
+ """Handles fence without language specifier."""
+ from core.workspace import _strip_code_fences
+
+ content = "```\ncode here\n```"
+ result = _strip_code_fences(content)
+ assert result == "code here"
+
+ def test_code_fence_with_spaces_in_fence_marker(self):
+ """Handles fence markers with extra spaces."""
+ from core.workspace import _strip_code_fences
+
+ content = "``` python\ndef hello():\n pass\n```"
+ result = _strip_code_fences(content)
+ assert "def hello():" in result
+
+ def test_nested_fences_not_supported(self):
+ """Doesn't handle nested fences (edge case)."""
+ from core.workspace import _strip_code_fences
+
+ content = "```\nouter ``` inner\ncode\n```"
+ result = _strip_code_fences(content)
+ # Should strip first fence
+ assert result.startswith("outer")
+
+ def test_only_fence_at_start(self):
+ """Only strips fence if at start of content."""
+ from core.workspace import _strip_code_fences
+
+ content = "text\n```python\ncode\n```"
+ result = _strip_code_fences(content)
+ assert result == content
+
+ def test_preserves_internal_markers(self):
+ """Preserves triple backticks that aren't fences."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\ncode with ``` in it\n```"
+ result = _strip_code_fences(content)
+ assert "code with ``` in it" in result
+
+ def test_multiple_fences_only_first(self):
+ """Only removes first fence pair."""
+ from core.workspace import _strip_code_fences
+
+ content = "```\ncode1\n```\n```\ncode2\n```"
+ result = _strip_code_fences(content)
+ # First fence removed, second preserved
+ assert result.startswith("code1")
+
+ def test_closing_fence_with_extra_text(self):
+ """Handles closing fence with text after."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\ncode\n``` extra"
+ result = _strip_code_fences(content)
+ assert result == "code\n``` extra"
+
+ def test_four_backticks(self):
+ """Handles four backticks (edge case)."""
+ from core.workspace import _strip_code_fences
+
+ content = "````python\ncode\n````"
+ result = _strip_code_fences(content)
+ # Should strip the fence
+ assert "code" in result
+
+ def test_unicode_in_code(self):
+ """Preserves unicode characters in code."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\n# Comment with émoji 🎉\n```"
+ result = _strip_code_fences(content)
+ assert "émoji" in result
+ assert "🎉" in result
+
+ def test_trailing_newlines_preserved(self):
+ """Preserves internal newlines in code content."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\ncode\n```"
+ result = _strip_code_fences(content)
+ assert result == "code"
+
+ def test_single_line_code(self):
+ """Handles single line code with fences."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\nx = 1\n```"
+ result = _strip_code_fences(content)
+ assert result == "x = 1"
+
+ def test_code_with_tabs(self):
+ """Preserves tabs in code content."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\n\tdef test():\n\t\tpass\n```"
+ result = _strip_code_fences(content)
+ assert "\t" in result
+
+ def test_mixed_line_endings(self):
+ """Handles mixed line endings."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\r\nline1\r\nline2\r\n```"
+ result = _strip_code_fences(content)
+ assert "line1" in result
+ assert "line2" in result
+
+ def test_fence_with_attributes(self):
+ """Handles fence with extra attributes."""
+ from core.workspace import _strip_code_fences
+
+ content = '```python title="test.py"\ncode\n```'
+ result = _strip_code_fences(content)
+ assert "code" in result
+
+ def test_leading_spaces_in_content(self):
+ """Preserves leading spaces in code."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\n indented code\n```"
+ result = _strip_code_fences(content)
+ assert " indented code" in result
+
+ def test_code_with_emoji(self):
+ """Preserves emoji in code content."""
+ from core.workspace import _strip_code_fences
+
+ content = "```python\n# 🎉 party time\n```"
+ result = _strip_code_fences(content)
+ assert "🎉" in result
+
+ def test_very_long_code_line(self):
+ """Handles very long code lines."""
+ from core.workspace import _strip_code_fences
+
+ long_line = "x" * 1000
+ content = f"```\n{long_line}\n```"
+ result = _strip_code_fences(content)
+ assert len(result) == 1000
+
+
+class TestTrySimple3wayMerge:
+ """Tests for _try_simple_3way_merge function."""
+
+ def test_both_sides_identical(self):
+ """Returns content when both sides are identical."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = "original"
+ ours = "modified"
+ theirs = "modified"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is True
+ assert result == "modified"
+
+ def test_only_ours_changed(self):
+ """Returns ours when only ours changed from base."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = "original"
+ ours = "ours modified"
+ theirs = "original"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is True
+ assert result == "ours modified"
+
+ def test_only_theirs_changed(self):
+ """Returns theirs when only theirs changed from base."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = "original"
+ ours = "original"
+ theirs = "theirs modified"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is True
+ assert result == "theirs modified"
+
+ def test_both_changed_differently(self):
+ """Returns False when both changed differently."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = "original"
+ ours = "ours change"
+ theirs = "theirs change"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is False
+ assert result is None
+
+ def test_none_base_identical_sides(self):
+ """Returns ours when base is None and both sides identical."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = None
+ ours = "same"
+ theirs = "same"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is True
+ assert result == "same"
+
+ def test_none_base_different_sides(self):
+ """Returns False when base is None and sides differ."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = None
+ ours = "ours"
+ theirs = "theirs"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is False
+ assert result is None
+
+ def test_empty_strings(self):
+ """Handles empty strings correctly."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = ""
+ ours = ""
+ theirs = ""
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is True
+ assert result == ""
+
+ def test_multiline_content(self):
+ """Handles multiline content correctly."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = "line1\nline2"
+ ours = "line1\nline2"
+ theirs = "line1\nline2\nline3"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ assert success is True
+ assert result == "line1\nline2\nline3"
+
+ def test_whitespace_differences(self):
+ """Treats whitespace differences as changes."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = "text"
+ ours = "text "
+ theirs = "text"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ # Different from base means ours is the change
+ assert success is True
+ assert result == "text "
+
+ def test_all_same(self):
+ """Returns True when all three are the same."""
+ from core.workspace import _try_simple_3way_merge
+
+ content = "same content"
+ success, result = _try_simple_3way_merge(content, content, content)
+ assert success is True
+ assert result == content
+
+ def test_newline_differences(self):
+ """Handles trailing newline differences."""
+ from core.workspace import _try_simple_3way_merge
+
+ base = "text"
+ ours = "text\n"
+ theirs = "text"
+
+ success, result = _try_simple_3way_merge(base, ours, theirs)
+ # Different from base means ours is the change
+ assert success is True
+ assert result == "text\n"
+
+
+class TestBuildMergePrompt:
+ """Tests for _build_merge_prompt function."""
+
+ def test_basic_prompt_structure(self):
+ """Creates prompt with all required sections."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "test.py",
+ "base content",
+ "main content",
+ "worktree content",
+ "spec-001",
+ )
+
+ assert "FILE: test.py" in prompt
+ assert "TASK: spec-001" in prompt
+ assert "OURS" in prompt
+ assert "THEIRS" in prompt
+ assert "main content" in prompt
+ assert "worktree content" in prompt
+
+ def test_includes_language_from_file(self):
+ """Infers and includes language in code fence."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "test.py",
+ "base",
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```python" in prompt
+
+ def test_with_base_content(self):
+ """Includes BASE section when base content provided."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.js",
+ "base content",
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "BASE (common ancestor" in prompt
+ assert "base content" in prompt
+
+ def test_without_base_content(self):
+ """Handles None base content gracefully."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.ts",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "BASE" not in prompt or "common ancestor" not in prompt
+
+ def test_truncates_large_base_content(self):
+ """Truncates base content over 10000 characters."""
+ from core.workspace import _build_merge_prompt
+
+ large_base = "x" * 15000
+ prompt = _build_merge_prompt(
+ "file.py",
+ large_base,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "(truncated)" in prompt
+ assert len(prompt) < len(large_base) + 1000
+
+ def test_truncates_large_main_content(self):
+ """Truncates main content over 15000 characters."""
+ from core.workspace import _build_merge_prompt
+
+ large_main = "y" * 20000
+ prompt = _build_merge_prompt(
+ "file.py",
+ "base",
+ large_main,
+ "worktree",
+ "spec",
+ )
+
+ assert "(truncated)" in prompt
+
+ def test_truncates_large_worktree_content(self):
+ """Truncates worktree content over 15000 characters."""
+ from core.workspace import _build_merge_prompt
+
+ large_worktree = "z" * 20000
+ prompt = _build_merge_prompt(
+ "file.py",
+ "base",
+ "main",
+ large_worktree,
+ "spec",
+ )
+
+ assert "(truncated)" in prompt
+
+ def test_typescript_language(self):
+ """Uses typescript for .ts files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.ts",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```typescript" in prompt
+
+ def test_javascript_language(self):
+ """Uses javascript for .js files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.js",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```javascript" in prompt
+
+ def test_json_language(self):
+ """Uses json for .json files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "config.json",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```json" in prompt
+
+ def test_spec_name_included(self):
+ """Includes spec name in prompt."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ "main",
+ "worktree",
+ "my-spec-name",
+ )
+
+ assert "TASK: my-spec-name" in prompt
+
+ def test_merge_instruction(self):
+ """Includes merge instruction."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "3-way code merge" in prompt or "combine changes" in prompt.lower()
+
+ def test_output_instruction(self):
+ """Includes instruction to output only code."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "OUTPUT THE MERGED CODE ONLY" in prompt or "no explanations" in prompt
+
+ def test_no_markdown_fences_instruction(self):
+ """Includes instruction about no markdown fences."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "no markdown fences" in prompt
+
+ def test_ours_section_description(self):
+ """Describes OURS correctly."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ "main content",
+ "worktree",
+ "spec",
+ )
+
+ assert "OURS (current main branch" in prompt
+
+ def test_theirs_section_description(self):
+ """Describes THEIRS correctly."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ "main",
+ "worktree content",
+ "spec",
+ )
+
+ assert "THEIRS (task worktree" in prompt
+
+ def test_special_characters_in_content(self):
+ """Handles special characters in content."""
+ from core.workspace import _build_merge_prompt
+
+ content = "code with 'quotes' and \"double quotes\" and \n newlines"
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ content,
+ content,
+ "spec",
+ )
+
+ assert "quotes" in prompt
+ assert "\n" in prompt or "newlines" in prompt
+
+ def test_empty_contents(self):
+ """Handles empty contents gracefully."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ "",
+ "",
+ "",
+ "spec",
+ )
+
+ # Should still have structure
+ assert "FILE:" in prompt
+ assert "OURS" in prompt
+ assert "THEIRS" in prompt
+
+ def test_markdown_language(self):
+ """Uses markdown for .md files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "README.md",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```markdown" in prompt
+
+ def test_yaml_language(self):
+ """Uses yaml for .yml files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "config.yml",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```yaml" in prompt
+
+ def test_cpp_language(self):
+ """Uses cpp for .cpp files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "main.cpp",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```cpp" in prompt
+
+ def test_rust_language(self):
+ """Uses rust for .rs files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "main.rs",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```rust" in prompt
+
+ def test_go_language(self):
+ """Uses go for .go files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "main.go",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```go" in prompt
+
+ def test_ruby_language(self):
+ """Uses ruby for .rb files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "app.rb",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```ruby" in prompt
+
+ def test_java_language(self):
+ """Uses java for .java files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "Main.java",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```java" in prompt
+
+ def test_sql_language(self):
+ """Uses sql for .sql files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "query.sql",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```sql" in prompt
+
+ def test_html_language(self):
+ """Uses html for .html files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "index.html",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```html" in prompt
+
+ def test_css_language(self):
+ """Uses css for .css files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "style.css",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```css" in prompt
+
+ def test_scss_language(self):
+ """Uses scss for .scss files."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "style.scss",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```scss" in prompt
+
+ def test_text_language_for_unknown(self):
+ """Uses text for unknown extensions."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.unknown",
+ None,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "```text" in prompt
+
+ def test_truncates_both_large_contents(self):
+ """Truncates both main and worktree when large."""
+ from core.workspace import _build_merge_prompt
+
+ large_main = "x" * 20000
+ large_worktree = "y" * 20000
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ large_main,
+ large_worktree,
+ "spec",
+ )
+
+ # Should have truncation markers
+ assert prompt.count("(truncated)") >= 2
+
+ def test_preserves_small_base_content(self):
+ """Does not truncate small base content."""
+ from core.workspace import _build_merge_prompt
+
+ base = "small base"
+ prompt = _build_merge_prompt(
+ "file.py",
+ base,
+ "main",
+ "worktree",
+ "spec",
+ )
+
+ assert "small base" in prompt
+ assert "(truncated)" not in prompt
+
+ def test_spec_name_with_special_chars(self):
+ """Handles spec names with special characters."""
+ from core.workspace import _build_merge_prompt
+
+ prompt = _build_merge_prompt(
+ "file.py",
+ None,
+ "main",
+ "worktree",
+ "spec-001_feature",
+ )
+
+ assert "spec-001_feature" in prompt
+
+
+class TestCreateMergeProgressCallback:
+ """Tests for _create_merge_progress_callback function."""
+
+ def test_returns_callable_when_piped(self, monkeypatch):
+ """Returns emit_progress when stdout is not a TTY."""
+ from core.workspace import _create_merge_progress_callback
+ from merge.progress import emit_progress
+
+ # Mock sys.stdout.isatty to return False
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
+
+ callback = _create_merge_progress_callback()
+ assert callback is not None
+ assert callback == emit_progress
+
+ def test_returns_none_when_tty(self, monkeypatch):
+ """Returns None when stdout is a TTY."""
+ from core.workspace import _create_merge_progress_callback
+
+ # Mock sys.stdout.isatty to return True
+ monkeypatch.setattr("sys.stdout.isatty", lambda: True)
+
+ callback = _create_merge_progress_callback()
+ assert callback is None
+
+ def test_callback_emits_progress_json(self, monkeypatch, capsys):
+ """Emits proper progress JSON when callback is used."""
+ from core.workspace import _create_merge_progress_callback
+ from merge.progress import MergeProgressStage
+
+ # Mock sys.stdout.isatty to return False
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
+
+ callback = _create_merge_progress_callback()
+ if callback:
+ callback(
+ MergeProgressStage.ANALYZING,
+ 50,
+ "Test message",
+ {"test_key": "test_value"},
+ )
+
+ captured = capsys.readouterr()
+ assert '"type": "progress"' in captured.out
+ assert '"percent": 50' in captured.out
+ assert '"message": "Test message"' in captured.out
+
+ def test_multiple_callbacks_different_stages(self, monkeypatch, capsys):
+ """Handles multiple callback calls with different stages."""
+ from core.workspace import _create_merge_progress_callback
+ from merge.progress import MergeProgressStage
+
+ # Mock sys.stdout.isatty to return False
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
+
+ callback = _create_merge_progress_callback()
+ if callback:
+ callback(MergeProgressStage.ANALYZING, 0, "Starting")
+ callback(MergeProgressStage.COMPLETE, 100, "Done")
+
+ captured = capsys.readouterr()
+ assert "Starting" in captured.out
+ assert "Done" in captured.out
+ assert '"percent": 0' in captured.out
+ assert '"percent": 100' in captured.out
+
+
+# Helper classes for AI merge tests
+class TextBlock:
+ """Mock TextBlock for testing AI merge responses."""
+
+ def __init__(self, text: str):
+ self.text = text
+ # Set __name__ for type checking
+ self.__class__.__name__ = "TextBlock"
+
+
+class AssistantMessage:
+ """Mock AssistantMessage for testing AI merge responses."""
+
+ def __init__(self, content: list):
+ self.content = content
+ # Set __name__ for type checking
+ self.__class__.__name__ = "AssistantMessage"
+
+
+class MockClientBase:
+ """Base mock client class that implements async context manager."""
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return None
+
+ async def query(self, prompt):
+ return None
+
+
+class TestAttemptAiMerge:
+ """Tests for _attempt_ai_merge function with extensive mocking."""
+
+ def test_successful_merge_returns_true_and_content(self, temp_git_repo: Path):
+ """Successful AI merge returns (True, merged_content, "")."""
+ import asyncio
+ from unittest.mock import patch
+
+ from core.workspace import ParallelMergeTask, _attempt_ai_merge
+
+ task = ParallelMergeTask(
+ file_path="test.py",
+ main_content="def foo():\n pass",
+ worktree_content="def bar():\n pass",
+ base_content=None,
+ spec_name="spec-001",
+ project_dir=temp_git_repo,
+ )
+
+ # Create a mock client class that properly implements async context manager
+ class MockClient(MockClientBase):
+ def __init__(self):
+ self.query_calls = []
+
+ async def query(self, prompt):
+ self.query_calls.append(prompt)
+ return None
+
+ async def receive_response(self):
+ mock_msg = AssistantMessage([TextBlock("def merged():\n pass")])
+ yield mock_msg
+
+ mock_client = MockClient()
+
+ with patch("core.simple_client.create_simple_client", return_value=mock_client):
+ with patch(
+ "core.workspace.git_utils.validate_merged_syntax",
+ return_value=(True, ""),
+ ):
+ result = asyncio.run(
+ _attempt_ai_merge(
+ task,
+ "test prompt",
+ model="claude-haiku-4-5-20251001",
+ max_thinking_tokens=1024,
+ )
+ )
+
+ assert result[0] is True
+ assert result[1] == "def merged():\n pass"
+ assert result[2] == ""
+
+ def test_ai_returns_natural_language_returns_error(self, temp_git_repo: Path):
+ """AI returning natural language instead of code returns error."""
+ import asyncio
+ from unittest.mock import patch
+
+ from core.workspace import ParallelMergeTask, _attempt_ai_merge
+
+ task = ParallelMergeTask(
+ file_path="test.py",
+ main_content="main",
+ worktree_content="worktree",
+ base_content=None,
+ spec_name="spec-001",
+ project_dir=temp_git_repo,
+ )
+
+ # Create a mock client that returns natural language
+ class MockClient(MockClientBase):
+ async def receive_response(self):
+ msg = AssistantMessage(
+ [TextBlock("I need to see more context to merge this properly.")]
+ )
+ yield msg
+
+ mock_client = MockClient()
+
+ with patch("core.simple_client.create_simple_client", return_value=mock_client):
+ result = asyncio.run(
+ _attempt_ai_merge(
+ task,
+ "test prompt",
+ model="claude-haiku-4-5-20251001",
+ max_thinking_tokens=1024,
+ )
+ )
+
+ assert result[0] is False
+ assert result[1] is None
+ assert "explanation instead of code" in result[2].lower()
+
+ def test_invalid_syntax_after_merge_returns_error(self, temp_git_repo: Path):
+ """Invalid syntax after merge returns (False, None, error)."""
+ import asyncio
+ from unittest.mock import patch
+
+ from core.workspace import ParallelMergeTask, _attempt_ai_merge
+
+ task = ParallelMergeTask(
+ file_path="test.py",
+ main_content="main",
+ worktree_content="worktree",
+ base_content=None,
+ spec_name="spec-001",
+ project_dir=temp_git_repo,
+ )
+
+ # Create a mock client that returns invalid Python
+ class MockClient(MockClientBase):
+ async def receive_response(self):
+ msg = AssistantMessage([TextBlock("def merged(:\n pass")])
+ yield msg
+
+ mock_client = MockClient()
+
+ with patch("core.simple_client.create_simple_client", return_value=mock_client):
+ result = asyncio.run(
+ _attempt_ai_merge(
+ task,
+ "test prompt",
+ model="claude-haiku-4-5-20251001",
+ max_thinking_tokens=1024,
+ )
+ )
+
+ assert result[0] is False
+ assert result[1] is None
+ assert "syntax" in result[2].lower()
+
+ def test_empty_ai_response_returns_error(self, temp_git_repo: Path):
+ """Empty AI response returns (False, None, error)."""
+ import asyncio
+ from unittest.mock import patch
+
+ from core.workspace import ParallelMergeTask, _attempt_ai_merge
+
+ task = ParallelMergeTask(
+ file_path="test.py",
+ main_content="main",
+ worktree_content="worktree",
+ base_content=None,
+ spec_name="spec-001",
+ project_dir=temp_git_repo,
+ )
+
+ # Create a mock client that returns empty response
+ class MockClient(MockClientBase):
+ response_text = ""
+
+ async def receive_response(self):
+ # Empty generator - yields nothing
+ return
+ yield
+
+ mock_client = MockClient()
+
+ with patch("core.simple_client.create_simple_client", return_value=mock_client):
+ result = asyncio.run(
+ _attempt_ai_merge(
+ task,
+ "test prompt",
+ model="claude-haiku-4-5-20251001",
+ max_thinking_tokens=1024,
+ )
+ )
+
+ assert result[0] is False
+ assert result[1] is None
+ assert "empty response" in result[2].lower()
+
+ def test_code_fence_stripping_is_applied(self, temp_git_repo: Path):
+ """Code fence stripping is applied to AI response."""
+ import asyncio
+ from unittest.mock import patch
+
+ from core.workspace import ParallelMergeTask, _attempt_ai_merge
+
+ task = ParallelMergeTask(
+ file_path="test.py",
+ main_content="main",
+ worktree_content="worktree",
+ base_content=None,
+ spec_name="spec-001",
+ project_dir=temp_git_repo,
+ )
+
+ # Create a mock client that returns code with fences
+ class MockClient(MockClientBase):
+ async def receive_response(self):
+ # Use markdown-style code fences (backticks)
+ block = TextBlock("```python\ndef merged():\n pass\n```")
+ msg = AssistantMessage([block])
+ yield msg
+
+ mock_client = MockClient()
+
+ with patch("core.simple_client.create_simple_client", return_value=mock_client):
+ with patch(
+ "core.workspace.git_utils.validate_merged_syntax",
+ return_value=(True, ""),
+ ):
+ result = asyncio.run(
+ _attempt_ai_merge(
+ task,
+ "test prompt",
+ model="claude-haiku-4-5-20251001",
+ max_thinking_tokens=1024,
+ )
+ )
+
+ assert result[0] is True
+ # Code fences should be stripped
+ assert not result[1].startswith("```")
+ assert "def merged():" in result[1]
+
+ def test_response_with_code_patterns_passes_natural_language_check(
+ self, temp_git_repo: Path
+ ):
+ """Response with code patterns passes natural language check."""
+ import asyncio
+ from unittest.mock import patch
+
+ from core.workspace import ParallelMergeTask, _attempt_ai_merge
+
+ task = ParallelMergeTask(
+ file_path="test.py",
+ main_content="main",
+ worktree_content="worktree",
+ base_content=None,
+ spec_name="spec-001",
+ project_dir=temp_git_repo,
+ )
+
+ # Create a mock client that returns valid code
+ class MockClient(MockClientBase):
+ async def receive_response(self):
+ # Response that has "i need to" but also has code patterns
+ block = TextBlock(
+ "# I need to handle edge cases\ndef merged():\n pass\n"
+ )
+ msg = AssistantMessage([block])
+ yield msg
+
+ mock_client = MockClient()
+
+ with patch("core.simple_client.create_simple_client", return_value=mock_client):
+ with patch(
+ "core.workspace.git_utils.validate_merged_syntax",
+ return_value=(True, ""),
+ ):
+ result = asyncio.run(
+ _attempt_ai_merge(
+ task,
+ "test prompt",
+ model="claude-haiku-4-5-20251001",
+ max_thinking_tokens=1024,
+ )
+ )
+
+ # Should pass because it has code patterns (def)
+ assert result[0] is True
+ assert "def merged():" in result[1]
diff --git a/apps/backend/core/workspace/tests/test_models.py b/apps/backend/core/workspace/tests/test_models.py
new file mode 100644
index 00000000..d11d79b6
--- /dev/null
+++ b/apps/backend/core/workspace/tests/test_models.py
@@ -0,0 +1,638 @@
+#!/usr/bin/env python3
+"""
+Tests for Workspace Models
+==========================
+
+Tests the workspace.py module models including:
+- WorkspaceMode enum
+- WorkspaceChoice enum
+- ParallelMergeTask
+- ParallelMergeResult
+- MergeLock and MergeLockError
+- SpecNumberLock and SpecNumberLockError
+"""
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+# Add parent directory to path so we can import the workspace module
+# When co-located at workspace/tests/, we need to add backend to path
+# workspace/tests -> workspace -> core -> backend (4 levels up)
+_backend = Path(__file__).resolve().parent.parent.parent.parent
+sys.path.insert(0, str(_backend))
+
+from core.workspace.models import (
+ MergeLock,
+ MergeLockError,
+ ParallelMergeResult,
+ ParallelMergeTask,
+ SpecNumberLock,
+ SpecNumberLockError,
+)
+from worktree import WorktreeError, WorktreeManager
+
+# Test constant - in the new per-spec architecture, each spec has its own worktree
+# named after the spec itself. This constant is used for test assertions.
+TEST_SPEC_NAME = "test-spec"
+
+
+class TestWorkspaceMode:
+ """Tests for WorkspaceMode enum."""
+
+ def test_isolated_mode(self):
+ """ISOLATED mode value is correct."""
+ from core.workspace.models import WorkspaceMode
+
+ assert WorkspaceMode.ISOLATED.value == "isolated"
+
+ def test_direct_mode(self):
+ """DIRECT mode value is correct."""
+ from core.workspace.models import WorkspaceMode
+
+ assert WorkspaceMode.DIRECT.value == "direct"
+
+
+class TestWorkspaceChoice:
+ """Tests for WorkspaceChoice enum."""
+
+ def test_merge_choice(self):
+ """MERGE choice value is correct."""
+ from core.workspace.models import WorkspaceChoice
+
+ assert WorkspaceChoice.MERGE.value == "merge"
+
+ def test_review_choice(self):
+ """REVIEW choice value is correct."""
+ from core.workspace.models import WorkspaceChoice
+
+ assert WorkspaceChoice.REVIEW.value == "review"
+
+ def test_test_choice(self):
+ """TEST choice value is correct."""
+ from core.workspace.models import WorkspaceChoice
+
+ assert WorkspaceChoice.TEST.value == "test"
+
+ def test_later_choice(self):
+ """LATER choice value is correct."""
+ from core.workspace.models import WorkspaceChoice
+
+ assert WorkspaceChoice.LATER.value == "later"
+
+
+class TestParallelMergeTask:
+ """Tests for ParallelMergeTask dataclass."""
+
+ def test_create_merge_task(self):
+ """ParallelMergeTask can be instantiated with all fields."""
+ task = ParallelMergeTask(
+ file_path="src/example.py",
+ main_content="main content",
+ worktree_content="worktree content",
+ base_content="base content",
+ spec_name="test-spec",
+ project_dir=Path("/project"),
+ )
+
+ assert task.file_path == "src/example.py"
+ assert task.main_content == "main content"
+ assert task.worktree_content == "worktree content"
+ assert task.base_content == "base content"
+ assert task.spec_name == "test-spec"
+ assert task.project_dir == Path("/project")
+
+ def test_merge_task_with_none_base(self):
+ """ParallelMergeTask can have None for base_content."""
+ task = ParallelMergeTask(
+ file_path="src/example.py",
+ main_content="main content",
+ worktree_content="worktree content",
+ base_content=None,
+ spec_name="test-spec",
+ project_dir=Path("/project"),
+ )
+
+ assert task.base_content is None
+
+ def test_merge_task_field_assignment(self):
+ """ParallelMergeTask fields can be reassigned."""
+ task = ParallelMergeTask(
+ file_path="src/example.py",
+ main_content="main",
+ worktree_content="worktree",
+ base_content=None,
+ spec_name="spec-1",
+ project_dir=Path("/project"),
+ )
+
+ task.file_path = "src/updated.py"
+ task.main_content = "updated main"
+ task.worktree_content = "updated worktree"
+ task.base_content = "updated base"
+ task.spec_name = "spec-2"
+ task.project_dir = Path("/updated")
+
+ assert task.file_path == "src/updated.py"
+ assert task.main_content == "updated main"
+ assert task.worktree_content == "updated worktree"
+ assert task.base_content == "updated base"
+ assert task.spec_name == "spec-2"
+ assert task.project_dir == Path("/updated")
+
+
+class TestParallelMergeResult:
+ """Tests for ParallelMergeResult dataclass."""
+
+ def test_create_successful_result(self):
+ """ParallelMergeResult can represent a successful merge."""
+ result = ParallelMergeResult(
+ file_path="src/example.py",
+ merged_content="merged content",
+ success=True,
+ error=None,
+ was_auto_merged=True,
+ )
+
+ assert result.file_path == "src/example.py"
+ assert result.merged_content == "merged content"
+ assert result.success is True
+ assert result.error is None
+ assert result.was_auto_merged is True
+
+ def test_create_failed_result(self):
+ """ParallelMergeResult can represent a failed merge."""
+ result = ParallelMergeResult(
+ file_path="src/example.py",
+ merged_content=None,
+ success=False,
+ error="Merge conflict occurred",
+ was_auto_merged=False,
+ )
+
+ assert result.file_path == "src/example.py"
+ assert result.merged_content is None
+ assert result.success is False
+ assert result.error == "Merge conflict occurred"
+ assert result.was_auto_merged is False
+
+ def test_result_default_values(self):
+ """ParallelMergeResult has correct default values."""
+ result = ParallelMergeResult(
+ file_path="src/example.py",
+ merged_content="content",
+ success=True,
+ )
+
+ assert result.error is None
+ assert result.was_auto_merged is False
+
+ def test_result_field_assignment(self):
+ """ParallelMergeResult fields can be reassigned."""
+ result = ParallelMergeResult(
+ file_path="src/example.py",
+ merged_content="merged",
+ success=True,
+ error=None,
+ was_auto_merged=False,
+ )
+
+ result.file_path = "src/updated.py"
+ result.merged_content = "updated merged"
+ result.success = False
+ result.error = "New error"
+ result.was_auto_merged = True
+
+ assert result.file_path == "src/updated.py"
+ assert result.merged_content == "updated merged"
+ assert result.success is False
+ assert result.error == "New error"
+ assert result.was_auto_merged is True
+
+
+class TestMergeLockError:
+ """Tests for MergeLockError exception."""
+
+ def test_merge_lock_error_creation(self):
+ """MergeLockError can be instantiated with a message."""
+ error = MergeLockError("Could not acquire lock")
+ assert str(error) == "Could not acquire lock"
+
+ def test_merge_lock_error_is_exception(self):
+ """MergeLockError is an Exception subclass."""
+ error = MergeLockError("test")
+ assert isinstance(error, Exception)
+ assert isinstance(error, MergeLockError)
+
+ def test_raise_merge_lock_error(self):
+ """MergeLockError can be raised and caught."""
+ with pytest.raises(MergeLockError) as exc_info:
+ raise MergeLockError("Lock timeout")
+ assert str(exc_info.value) == "Lock timeout"
+
+
+class TestMergeLock:
+ """Tests for MergeLock context manager."""
+
+ def test_merge_lock_initialization(self, temp_git_repo: Path):
+ """MergeLock initializes with correct paths."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ assert lock.project_dir == temp_git_repo
+ assert lock.spec_name == "test-spec"
+ assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
+ assert lock.lock_file == lock.lock_dir / "merge-test-spec.lock"
+ assert lock.acquired is False
+
+ def test_merge_lock_acquire_and_release(self, temp_git_repo: Path):
+ """MergeLock can be acquired and released."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ with lock:
+ assert lock.acquired is True
+ assert lock.lock_file.exists()
+
+ # After context, lock should be released
+ assert lock.lock_file.exists() is False
+
+ def test_merge_lock_creates_lock_dir(self, temp_git_repo: Path):
+ """MergeLock creates lock directory if it doesn't exist."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ # Remove lock dir if it exists
+ if lock.lock_dir.exists():
+ lock.lock_dir.rmdir()
+
+ with lock:
+ assert lock.lock_dir.exists()
+
+ def test_merge_lock_writes_pid(self, temp_git_repo: Path):
+ """MergeLock writes current PID to lock file."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ with lock:
+ pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
+ assert pid_content == str(os.getpid())
+
+ @pytest.mark.slow
+ def test_merge_lock_timeout_on_contention(self, temp_git_repo: Path):
+ """MergeLock raises MergeLockError when lock is held by another process."""
+ lock1 = MergeLock(temp_git_repo, "test-spec")
+
+ # Acquire first lock
+ lock1.__enter__()
+
+ try:
+ # Create a second lock for the same spec
+ lock2 = MergeLock(temp_git_repo, "test-spec")
+
+ # This should timeout because lock1 holds the lock
+ with pytest.raises(MergeLockError) as exc_info:
+ lock2.__enter__()
+
+ assert "Could not acquire merge lock" in str(exc_info.value)
+ assert "test-spec" in str(exc_info.value)
+ assert "after 30s" in str(exc_info.value)
+ finally:
+ lock1.__exit__(None, None, None)
+
+ def test_merge_lock_removes_stale_lock(self, temp_git_repo: Path):
+ """MergeLock removes stale lock from dead process."""
+ lock1 = MergeLock(temp_git_repo, "test-spec")
+
+ with lock1:
+ # Write a fake PID that doesn't exist
+ fake_pid = 999999
+ lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
+
+ # Create a new lock - it should remove the stale lock
+ lock2 = MergeLock(temp_git_repo, "test-spec")
+ with lock2:
+ assert lock2.acquired is True
+
+ def test_merge_lock_handles_invalid_pid(self, temp_git_repo: Path):
+ """MergeLock handles invalid PID in lock file."""
+ lock1 = MergeLock(temp_git_repo, "test-spec")
+
+ with lock1:
+ # Write invalid content to lock file
+ lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
+
+ # Create a new lock - it should remove the invalid lock
+ lock2 = MergeLock(temp_git_repo, "test-spec")
+ with lock2:
+ assert lock2.acquired is True
+
+ def test_merge_lock_cleanup_on_exception(self, temp_git_repo: Path):
+ """MergeLock releases lock even if exception occurs in context."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ try:
+ with lock:
+ assert lock.acquired is True
+ raise ValueError("Test exception")
+ except ValueError:
+ pass
+
+ # Lock should be released despite exception
+ assert lock.lock_file.exists() is False
+
+ def test_merge_lock_idempotent_release(self, temp_git_repo: Path):
+ """MergeLock __exit__ can be called multiple times safely."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ with lock:
+ pass
+
+ # Call __exit__ again - should not raise
+ lock.__exit__(None, None, None)
+ lock.__exit__(None, None, None)
+
+ def test_merge_lock_different_specs_dont_conflict(self, temp_git_repo: Path):
+ """MergeLock for different specs can be held simultaneously."""
+ lock1 = MergeLock(temp_git_repo, "spec-1")
+ lock2 = MergeLock(temp_git_repo, "spec-2")
+
+ with lock1:
+ with lock2:
+ assert lock1.acquired is True
+ assert lock2.acquired is True
+ assert lock1.lock_file != lock2.lock_file
+
+
+class TestSpecNumberLockError:
+ """Tests for SpecNumberLockError exception."""
+
+ def test_spec_number_lock_error_creation(self):
+ """SpecNumberLockError can be instantiated with a message."""
+ error = SpecNumberLockError("Could not acquire spec numbering lock")
+ assert str(error) == "Could not acquire spec numbering lock"
+
+ def test_spec_number_lock_error_is_exception(self):
+ """SpecNumberLockError is an Exception subclass."""
+ error = SpecNumberLockError("test")
+ assert isinstance(error, Exception)
+ assert isinstance(error, SpecNumberLockError)
+
+ def test_raise_spec_number_lock_error(self):
+ """SpecNumberLockError can be raised and caught."""
+ with pytest.raises(SpecNumberLockError) as exc_info:
+ raise SpecNumberLockError("Lock timeout")
+ assert str(exc_info.value) == "Lock timeout"
+
+
+class TestSpecNumberLock:
+ """Tests for SpecNumberLock context manager."""
+
+ def test_spec_number_lock_initialization(self, temp_git_repo: Path):
+ """SpecNumberLock initializes with correct paths."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ assert lock.project_dir == temp_git_repo
+ assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
+ assert lock.lock_file == lock.lock_dir / "spec-numbering.lock"
+ assert lock.acquired is False
+ assert lock._global_max is None
+
+ def test_spec_number_lock_acquire_and_release(self, temp_git_repo: Path):
+ """SpecNumberLock can be acquired and released."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ assert lock.acquired is True
+ assert lock.lock_file.exists()
+
+ # After context, lock should be released
+ assert lock.lock_file.exists() is False
+
+ def test_spec_number_lock_creates_lock_dir(self, temp_git_repo: Path):
+ """SpecNumberLock creates lock directory if it doesn't exist."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ # Remove lock dir if it exists
+ if lock.lock_dir.exists():
+ lock.lock_dir.rmdir()
+
+ with lock:
+ assert lock.lock_dir.exists()
+
+ def test_spec_number_lock_writes_pid(self, temp_git_repo: Path):
+ """SpecNumberLock writes current PID to lock file."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
+ assert pid_content == str(os.getpid())
+
+ def test_get_next_spec_number_no_existing_specs(self, temp_git_repo: Path):
+ """get_next_spec_number returns 1 when no specs exist."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ next_num = lock.get_next_spec_number()
+ assert next_num == 1
+
+ def test_get_next_spec_number_with_existing_specs(self, temp_git_repo: Path):
+ """get_next_spec_number returns max existing spec number + 1."""
+ # Create spec directories
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+ (specs_dir / "001-first").mkdir()
+ (specs_dir / "003-third").mkdir()
+
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ next_num = lock.get_next_spec_number()
+ assert next_num == 4
+
+ def test_get_next_spec_number_caches_result(self, temp_git_repo: Path):
+ """get_next_spec_number caches the global max."""
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+ (specs_dir / "005-test").mkdir()
+
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ next_num1 = lock.get_next_spec_number()
+ next_num2 = lock.get_next_spec_number()
+
+ # Should return the same value (cached)
+ assert next_num1 == next_num2 == 6
+ assert lock._global_max == 5
+
+ def test_get_next_spec_number_requires_lock(self, temp_git_repo: Path):
+ """get_next_spec_number raises SpecNumberLockError if lock not acquired."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with pytest.raises(SpecNumberLockError) as exc_info:
+ lock.get_next_spec_number()
+
+ assert "Lock must be acquired" in str(exc_info.value)
+
+ def test_get_next_spec_number_scans_worktrees(self, temp_git_repo: Path):
+ """get_next_spec_number scans all worktree spec directories."""
+ # Create main project specs
+ main_specs = temp_git_repo / ".auto-claude" / "specs"
+ main_specs.mkdir(parents=True)
+ (main_specs / "002-main").mkdir()
+
+ # Create worktree with specs
+ worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
+ worktrees_dir.mkdir(parents=True)
+ worktree_spec_dir = worktrees_dir / "test-worktree" / ".auto-claude" / "specs"
+ worktree_spec_dir.mkdir(parents=True)
+ (worktree_spec_dir / "005-worktree").mkdir()
+
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ next_num = lock.get_next_spec_number()
+ # Should find max of 2 and 5, return 6
+ assert next_num == 6
+
+ def test_scan_specs_dir_nonexistent(self, temp_git_repo: Path):
+ """_scan_specs_dir returns 0 for nonexistent directory."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ # Use a path inside temp_dir that doesn't exist
+ nonexistent = temp_git_repo / "this_does_not_exist_specs"
+ result = lock._scan_specs_dir(nonexistent)
+ assert result == 0
+
+ def test_scan_specs_dir_ignores_invalid_names(self, temp_git_repo: Path):
+ """_scan_specs_dir ignores directories with invalid spec names."""
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+ (specs_dir / "001-valid").mkdir()
+ (specs_dir / "invalid-name").mkdir()
+ (specs_dir / "abc").mkdir()
+ (specs_dir / "100-valid").mkdir()
+
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ result = lock._scan_specs_dir(specs_dir)
+ # Should only count 001 and 100
+ assert result == 100
+
+ @pytest.mark.slow
+ def test_spec_number_lock_timeout_on_contention(self, temp_git_repo: Path):
+ """SpecNumberLock raises SpecNumberLockError when lock is held."""
+ lock1 = SpecNumberLock(temp_git_repo)
+
+ # Acquire first lock
+ lock1.__enter__()
+
+ try:
+ # Create a second lock
+ lock2 = SpecNumberLock(temp_git_repo)
+
+ # This should timeout because lock1 holds the lock
+ with pytest.raises(SpecNumberLockError) as exc_info:
+ lock2.__enter__()
+
+ assert "Could not acquire spec numbering lock" in str(exc_info.value)
+ assert "after 30s" in str(exc_info.value)
+ finally:
+ lock1.__exit__(None, None, None)
+
+ def test_spec_number_lock_removes_stale_lock(self, temp_git_repo: Path):
+ """SpecNumberLock removes stale lock from dead process."""
+ lock1 = SpecNumberLock(temp_git_repo)
+
+ with lock1:
+ # Write a fake PID that doesn't exist
+ fake_pid = 999999
+ lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
+
+ # Create a new lock - it should remove the stale lock
+ lock2 = SpecNumberLock(temp_git_repo)
+ with lock2:
+ assert lock2.acquired is True
+
+ def test_spec_number_lock_handles_invalid_pid(self, temp_git_repo: Path):
+ """SpecNumberLock handles invalid PID in lock file."""
+ lock1 = SpecNumberLock(temp_git_repo)
+
+ with lock1:
+ # Write invalid content to lock file
+ lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
+
+ # Create a new lock - it should remove the invalid lock
+ lock2 = SpecNumberLock(temp_git_repo)
+ with lock2:
+ assert lock2.acquired is True
+
+ def test_spec_number_lock_cleanup_on_exception(self, temp_git_repo: Path):
+ """SpecNumberLock releases lock even if exception occurs in context."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ try:
+ with lock:
+ assert lock.acquired is True
+ raise ValueError("Test exception")
+ except ValueError:
+ pass
+
+ # Lock should be released despite exception
+ assert lock.lock_file.exists() is False
+
+ def test_spec_number_lock_idempotent_release(self, temp_git_repo: Path):
+ """SpecNumberLock __exit__ can be called multiple times safely."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ pass
+
+ # Call __exit__ again - should not raise
+ lock.__exit__(None, None, None)
+ lock.__exit__(None, None, None)
+
+ def test_spec_number_lock_returns_self(self, temp_git_repo: Path):
+ """SpecNumberLock __enter__ returns self."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock as entered_lock:
+ assert entered_lock is lock
+
+ def test_merge_success_returns_true(self, temp_git_repo: Path):
+ """Successful merge returns True (ACS-163 verification)."""
+ manager = WorktreeManager(temp_git_repo)
+ manager.setup()
+
+ # Create a worktree with non-conflicting changes
+ worker_info = manager.create_worktree("worker-spec")
+ (worker_info.path / "worker-file.txt").write_text(
+ "worker content", encoding="utf-8"
+ )
+ subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Worker commit"],
+ cwd=worker_info.path,
+ capture_output=True,
+ )
+
+ # Merge should succeed
+ result = manager.merge_worktree("worker-spec", delete_after=False)
+
+ assert result is True
+
+ # Verify the file was merged into base branch
+ subprocess.run(
+ ["git", "checkout", manager.base_branch],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+ assert (temp_git_repo / "worker-file.txt").exists(), (
+ "Merged file should exist in base branch"
+ )
+ merged_content = (temp_git_repo / "worker-file.txt").read_text(encoding="utf-8")
+ assert merged_content == "worker content", (
+ "Merged file should have worktree content"
+ )
diff --git a/tests/test_workspace.py b/apps/backend/core/workspace/tests/test_rebase.py
similarity index 51%
rename from tests/test_workspace.py
rename to apps/backend/core/workspace/tests/test_rebase.py
index a1291b78..dcddff6f 100644
--- a/tests/test_workspace.py
+++ b/apps/backend/core/workspace/tests/test_rebase.py
@@ -1,539 +1,31 @@
#!/usr/bin/env python3
"""
-Tests for Workspace Selection and Management
-=============================================
+Tests for Workspace Rebase Operations
+======================================
-Tests the workspace.py module functionality including:
-- Workspace mode selection (isolated vs direct)
-- Uncommitted changes detection
-- Workspace setup
-- Build finalization workflows
+Tests the rebase functionality including:
+- Rebase detection (_check_git_conflicts)
+- Spec branch rebase operations
+- Rebase integration tests
+- Rebase error handling
"""
+import json
+import os
+import shutil
import subprocess
+import sys
from pathlib import Path
import pytest
-from workspace import (
- WorkspaceChoice,
- WorkspaceMode,
- get_current_branch,
- get_existing_build_worktree,
- has_uncommitted_changes,
- setup_workspace,
-)
-from worktree import WorktreeManager
+from worktree import WorktreeError, WorktreeManager
# Test constant - in the new per-spec architecture, each spec has its own worktree
# named after the spec itself. This constant is used for test assertions.
TEST_SPEC_NAME = "test-spec"
-class TestWorkspaceMode:
- """Tests for WorkspaceMode enum."""
-
- def test_isolated_mode(self):
- """ISOLATED mode value is correct."""
- assert WorkspaceMode.ISOLATED.value == "isolated"
-
- def test_direct_mode(self):
- """DIRECT mode value is correct."""
- assert WorkspaceMode.DIRECT.value == "direct"
-
-
-class TestWorkspaceChoice:
- """Tests for WorkspaceChoice enum."""
-
- def test_merge_choice(self):
- """MERGE choice value is correct."""
- assert WorkspaceChoice.MERGE.value == "merge"
-
- def test_review_choice(self):
- """REVIEW choice value is correct."""
- assert WorkspaceChoice.REVIEW.value == "review"
-
- def test_test_choice(self):
- """TEST choice value is correct."""
- assert WorkspaceChoice.TEST.value == "test"
-
- def test_later_choice(self):
- """LATER choice value is correct."""
- assert WorkspaceChoice.LATER.value == "later"
-
-
-class TestHasUncommittedChanges:
- """Tests for uncommitted changes detection."""
-
- def test_clean_repo_no_changes(self, temp_git_repo: Path):
- """Clean repo returns False."""
- result = has_uncommitted_changes(temp_git_repo)
- assert result is False
-
- def test_untracked_file_has_changes(self, temp_git_repo: Path):
- """Untracked file counts as changes."""
- (temp_git_repo / "new_file.txt").write_text("content")
-
- result = has_uncommitted_changes(temp_git_repo)
- assert result is True
-
- def test_modified_file_has_changes(self, temp_git_repo: Path):
- """Modified tracked file counts as changes."""
- (temp_git_repo / "README.md").write_text("modified content")
-
- result = has_uncommitted_changes(temp_git_repo)
- assert result is True
-
- def test_staged_file_has_changes(self, temp_git_repo: Path):
- """Staged file counts as changes."""
- (temp_git_repo / "README.md").write_text("modified")
- subprocess.run(["git", "add", "README.md"], cwd=temp_git_repo, capture_output=True)
-
- result = has_uncommitted_changes(temp_git_repo)
- assert result is True
-
-
-class TestGetCurrentBranch:
- """Tests for current branch detection."""
-
- def test_gets_main_branch(self, temp_git_repo: Path):
- """Gets the main/master branch."""
- branch = get_current_branch(temp_git_repo)
-
- # Could be main or master depending on git config
- assert branch in ["main", "master"]
-
- def test_gets_feature_branch(self, temp_git_repo: Path):
- """Gets feature branch name."""
- subprocess.run(
- ["git", "checkout", "-b", "feature/test-branch"],
- cwd=temp_git_repo, capture_output=True
- )
-
- branch = get_current_branch(temp_git_repo)
- assert branch == "feature/test-branch"
-
-
-class TestGetExistingBuildWorktree:
- """Tests for existing build worktree detection."""
-
- def test_no_existing_worktree(self, temp_git_repo: Path):
- """Returns None when no worktree exists."""
- result = get_existing_build_worktree(temp_git_repo, "test-spec")
- assert result is None
-
- def test_existing_worktree(self, temp_git_repo: Path):
- """Returns path when worktree exists."""
- # Create the worktree directory structure (per-spec architecture)
- worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME
- worktree_path.mkdir(parents=True)
-
- result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME)
- assert result == worktree_path
-
-
-class TestSetupWorkspace:
- """Tests for workspace setup."""
-
- def test_setup_direct_mode(self, temp_git_repo: Path):
- """Direct mode returns project dir and no manager."""
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.DIRECT,
- )
-
- assert working_dir == temp_git_repo
- assert manager is None
-
- def test_setup_isolated_mode(self, temp_git_repo: Path):
- """Isolated mode creates worktree and returns manager."""
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- TEST_SPEC_NAME,
- WorkspaceMode.ISOLATED,
- )
-
- assert working_dir != temp_git_repo
- assert manager is not None
- assert working_dir.exists()
- # Per-spec architecture: worktree is named after the spec
- assert working_dir.name == TEST_SPEC_NAME
-
- def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path):
- """Isolated mode creates worktrees directory."""
- setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists()
-
-
-class TestWorkspaceUtilities:
- """Tests for workspace utility functions."""
-
- def test_per_spec_worktree_naming(self, temp_git_repo: Path):
- """Per-spec architecture uses spec name for worktree directory."""
- spec_name = "my-spec-001"
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- spec_name,
- WorkspaceMode.ISOLATED,
- )
-
- # Worktree should be named after the spec
- assert working_dir.name == spec_name
- # New path: .auto-claude/worktrees/tasks/{spec_name}
- assert working_dir.parent.name == "tasks"
-
-
-class TestWorkspaceIntegration:
- """Integration tests for workspace management."""
-
- def test_isolated_workflow(self, temp_git_repo: Path):
- """Full isolated workflow: setup -> work -> finalize."""
- # Setup isolated workspace
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- # Make changes in workspace
- (working_dir / "feature.py").write_text("# New feature\n")
-
- # Verify changes are in workspace
- assert (working_dir / "feature.py").exists()
-
- # Verify changes are NOT in main project
- assert not (temp_git_repo / "feature.py").exists()
-
- def test_direct_workflow(self, temp_git_repo: Path):
- """Full direct workflow: setup -> work."""
- # Setup direct workspace
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.DIRECT,
- )
-
- # Working dir is the project dir
- assert working_dir == temp_git_repo
-
- # Make changes directly
- (working_dir / "feature.py").write_text("# New feature\n")
-
- # Changes are in main project
- assert (temp_git_repo / "feature.py").exists()
-
- def test_isolated_merge(self, temp_git_repo: Path):
- """Can merge isolated workspace back to main."""
- # Setup
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- # Make changes and commit using git directly
- (working_dir / "feature.py").write_text("# New feature\n")
- subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Add feature"],
- cwd=working_dir, capture_output=True
- )
-
- # Merge back using merge_worktree
- result = manager.merge_worktree("test-spec", delete_after=False)
-
- assert result is True
-
- # Check changes are in main
- subprocess.run(
- ["git", "checkout", manager.base_branch],
- cwd=temp_git_repo, capture_output=True
- )
- assert (temp_git_repo / "feature.py").exists()
-
-
-class TestWorkspaceCleanup:
- """Tests for workspace cleanup."""
-
- def test_cleanup_after_merge(self, temp_git_repo: Path):
- """Workspace is cleaned up after merge with delete_after=True."""
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- # Commit changes using git directly
- (working_dir / "test.py").write_text("test")
- subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Test"],
- cwd=working_dir, capture_output=True
- )
-
- # Merge with cleanup
- manager.merge_worktree("test-spec", delete_after=True)
-
- # Workspace should be removed
- assert not working_dir.exists()
-
- def test_workspace_preserved_after_merge_no_delete(self, temp_git_repo: Path):
- """Workspace preserved after merge with delete_after=False."""
- working_dir, manager, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- # Commit changes using git directly
- (working_dir / "test.py").write_text("test")
- subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Test"],
- cwd=working_dir, capture_output=True
- )
-
- # Merge without cleanup
- manager.merge_worktree("test-spec", delete_after=False)
-
- # Workspace should still exist
- assert working_dir.exists()
-
-
-class TestWorkspaceReuse:
- """Tests for reusing existing workspaces."""
-
- def test_reuse_existing_workspace(self, temp_git_repo: Path):
- """Can reuse existing workspace on second setup."""
- # First setup
- working_dir1, manager1, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- # Add a marker file
- (working_dir1 / "marker.txt").write_text("marker")
-
- # Second setup (should reuse)
- working_dir2, manager2, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- # Should be the same directory
- assert working_dir1 == working_dir2
-
- # Marker should still exist
- assert (working_dir2 / "marker.txt").exists()
-
-
-class TestWorkspaceErrors:
- """Tests for workspace error handling."""
-
- def test_setup_non_git_directory(self, temp_dir: Path):
- """Handles non-git directories gracefully."""
- with pytest.raises(Exception):
- # This should fail because temp_dir is not a git repo
- setup_workspace(
- temp_dir,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
-
-class TestPerSpecWorktreeName:
- """Tests for per-spec worktree naming (new architecture)."""
-
- def test_worktree_named_after_spec(self, temp_git_repo: Path):
- """Worktree is named after the spec."""
- spec_name = "spec-1"
- working_dir, _, _ = setup_workspace(
- temp_git_repo,
- spec_name,
- WorkspaceMode.ISOLATED,
- )
-
- # Per-spec architecture: worktree directory matches spec name
- assert working_dir.name == spec_name
-
- def test_different_specs_get_different_worktrees(self, temp_git_repo: Path):
- """Different specs create separate worktrees."""
- working_dir1, _, _ = setup_workspace(
- temp_git_repo,
- "spec-1",
- WorkspaceMode.ISOLATED,
- )
-
- working_dir2, _, _ = setup_workspace(
- temp_git_repo,
- "spec-2",
- WorkspaceMode.ISOLATED,
- )
-
- # Each spec has its own worktree
- assert working_dir1.name == "spec-1"
- assert working_dir2.name == "spec-2"
- assert working_dir1 != working_dir2
-
- def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path):
- """Worktree is created in worktrees directory."""
- working_dir, _, _ = setup_workspace(
- temp_git_repo,
- "test-spec",
- WorkspaceMode.ISOLATED,
- )
-
- # New path: .auto-claude/worktrees/tasks/{spec_name}
- assert "worktrees" in str(working_dir)
- assert working_dir.parent.name == "tasks"
-
-
-class TestConflictInfoDisplay:
- """Tests for conflict info display function (ACS-179)."""
-
- def test_print_conflict_info_with_string_list(self, capsys):
- """print_conflict_info handles string list of file paths (ACS-179)."""
- from core.workspace.display import print_conflict_info
-
- result = {
- "conflicts": ["file1.txt", "file2.py", "file3.js"]
- }
-
- print_conflict_info(result)
-
- captured = capsys.readouterr()
- assert "3 file" in captured.out
- assert "file1.txt" in captured.out
- assert "file2.py" in captured.out
- assert "file3.js" in captured.out
- assert "git add" in captured.out
-
- def test_print_conflict_info_with_dict_list(self, capsys):
- """print_conflict_info handles dict list with file/reason/severity (ACS-179)."""
- from core.workspace.display import print_conflict_info
-
- result = {
- "conflicts": [
- {"file": "file1.txt", "reason": "Syntax error", "severity": "high"},
- {"file": "file2.py", "reason": "Merge conflict", "severity": "medium"},
- {"file": "file3.js", "reason": "Unknown error", "severity": "low"},
- ]
- }
-
- print_conflict_info(result)
-
- captured = capsys.readouterr()
- assert "3 file" in captured.out
- assert "file1.txt" in captured.out
- assert "file2.py" in captured.out
- assert "file3.js" in captured.out
- assert "Syntax error" in captured.out
- assert "Merge conflict" in captured.out
- # Verify severity emoji indicators
- assert "🔴" in captured.out # High severity
- assert "🟡" in captured.out # Medium severity
-
- def test_print_conflict_info_mixed_formats(self, capsys):
- """print_conflict_info handles mixed string and dict conflicts (ACS-179)."""
- from core.workspace.display import print_conflict_info
-
- result = {
- "conflicts": [
- "simple-file.txt",
- {"file": "complex-file.py", "reason": "AI merge failed", "severity": "high"},
- ]
- }
-
- print_conflict_info(result)
-
- captured = capsys.readouterr()
- assert "2 file" in captured.out
- assert "simple-file.txt" in captured.out
- assert "complex-file.py" in captured.out
- assert "AI merge failed" in captured.out
-
-
-class TestMergeErrorHandling:
- """Tests for merge error handling (ACS-163)."""
-
- def test_merge_failure_returns_false_immediately(self, temp_git_repo: Path):
- """Failed merge returns False without falling through (ACS-163)."""
- manager = WorktreeManager(temp_git_repo)
- manager.setup()
-
- # Create a worktree with changes
- worker_info = manager.create_worktree("worker-spec")
- (worker_info.path / "worker-file.txt").write_text("worker content")
- subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Worker commit"],
- cwd=worker_info.path, capture_output=True
- )
-
- # Create a conflicting change on main
- subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
- (temp_git_repo / "worker-file.txt").write_text("main content")
- subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Main commit"],
- cwd=temp_git_repo, capture_output=True
- )
-
- # Merge should fail (conflict) and return False
- # This tests the fix for ACS-163 where failed merge would fall through
- result = manager.merge_worktree("worker-spec", delete_after=False)
-
- # Should return False on merge conflict
- assert result is False
-
- # Verify side effects: base branch content is unchanged
- subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
- base_content = (temp_git_repo / "worker-file.txt").read_text()
- assert base_content == "main content", "Base branch should be unchanged after failed merge"
-
- # Verify worktree still exists (delete_after=False)
- assert worker_info.path.exists(), "Worktree should still exist after failed merge"
-
- # Verify worktree content is unchanged
- worktree_content = (worker_info.path / "worker-file.txt").read_text()
- assert worktree_content == "worker content", "Worktree content should be unchanged"
-
- def test_merge_success_returns_true(self, temp_git_repo: Path):
- """Successful merge returns True (ACS-163 verification)."""
- manager = WorktreeManager(temp_git_repo)
- manager.setup()
-
- # Create a worktree with non-conflicting changes
- worker_info = manager.create_worktree("worker-spec")
- (worker_info.path / "worker-file.txt").write_text("worker content")
- subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Worker commit"],
- cwd=worker_info.path, capture_output=True
- )
-
- # Merge should succeed
- result = manager.merge_worktree("worker-spec", delete_after=False)
-
- assert result is True
-
- # Verify the file was merged into base branch
- subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
- assert (temp_git_repo / "worker-file.txt").exists(), "Merged file should exist in base branch"
- merged_content = (temp_git_repo / "worker-file.txt").read_text()
- assert merged_content == "worker content", "Merged file should have worktree content"
-
-
class TestRebaseDetection:
- """Tests for automatic rebase detection (ACS-224)."""
-
def test_check_git_conflicts_detects_branch_behind(self, temp_git_repo: Path):
"""_check_git_conflicts detects when spec branch is behind base branch (ACS-224)."""
from core.workspace import _check_git_conflicts
@@ -547,7 +39,7 @@ class TestRebaseDetection:
)
# Add a commit to spec branch
- (temp_git_repo / "spec-file.txt").write_text("spec content")
+ (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -561,7 +53,7 @@ class TestRebaseDetection:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "main-file.txt").write_text("main content")
+ (temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main commit after spec"],
@@ -574,7 +66,9 @@ class TestRebaseDetection:
assert result is not None
assert result.get("needs_rebase") is True, "Should detect branch is behind"
- assert result.get("commits_behind") == 1, "Should count commits behind correctly"
+ assert result.get("commits_behind") == 1, (
+ "Should count commits behind correctly"
+ )
assert result.get("spec_branch") == spec_branch
def test_check_git_conflicts_no_commits_behind(self, temp_git_repo: Path):
@@ -588,7 +82,7 @@ class TestRebaseDetection:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "spec-file.txt").write_text("spec content")
+ (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -624,7 +118,7 @@ class TestRebaseDetection:
)
# Add a commit to spec branch
- (temp_git_repo / "spec-file.txt").write_text("spec content")
+ (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -639,7 +133,9 @@ class TestRebaseDetection:
capture_output=True,
)
for i in range(3):
- (temp_git_repo / f"main-file-{i}.txt").write_text(f"main content {i}")
+ (temp_git_repo / f"main-file-{i}.txt").write_text(
+ f"main content {i}", encoding="utf-8"
+ )
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"Main commit {i}"],
@@ -671,7 +167,7 @@ class TestRebaseSpecBranch:
)
# Add a commit to spec branch
- (temp_git_repo / "spec-file.txt").write_text("spec content")
+ (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -685,7 +181,7 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "main-file.txt").write_text("main content")
+ (temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main commit"],
@@ -726,7 +222,9 @@ class TestRebaseSpecBranch:
).stdout
assert "Main commit" in log, "Spec branch should have main commit after rebase"
- def test_rebase_spec_branch_with_conflicts_aborts_cleanly(self, temp_git_repo: Path):
+ def test_rebase_spec_branch_with_conflicts_aborts_cleanly(
+ self, temp_git_repo: Path
+ ):
"""_rebase_spec_branch handles conflicts by aborting and returning False (ACS-224)."""
from core.workspace import _rebase_spec_branch
@@ -739,7 +237,7 @@ class TestRebaseSpecBranch:
)
# Create a file that will conflict
- (temp_git_repo / "conflict.txt").write_text("spec version")
+ (temp_git_repo / "conflict.txt").write_text("spec version", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec conflict"],
@@ -753,7 +251,7 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "conflict.txt").write_text("main version")
+ (temp_git_repo / "conflict.txt").write_text("main version", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main conflict"],
@@ -823,7 +321,7 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "spec-file.txt").write_text("spec content")
+ (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -842,7 +340,9 @@ class TestRebaseSpecBranch:
# (branch already up-to-date is a success condition)
result = _rebase_spec_branch(temp_git_repo, "test-spec", "main")
- assert result is True, "Rebase should return True when branch is already up-to-date"
+ assert result is True, (
+ "Rebase should return True when branch is already up-to-date"
+ )
class TestRebaseIntegration:
@@ -859,7 +359,9 @@ class TestRebaseIntegration:
worker_info = manager.create_worktree("test-spec")
# Add a file in spec worktree and commit
- (worker_info.path / "spec-file.txt").write_text("spec content")
+ (worker_info.path / "spec-file.txt").write_text(
+ "spec content", encoding="utf-8"
+ )
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -874,7 +376,7 @@ class TestRebaseIntegration:
capture_output=True,
)
for i in range(2):
- (temp_git_repo / f"main-{i}.txt").write_text(f"main {i}")
+ (temp_git_repo / f"main-{i}.txt").write_text(f"main {i}", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"Main {i}"],
@@ -906,7 +408,7 @@ class TestRebaseIntegration:
)
# Add a commit to spec
- (temp_git_repo / "spec.txt").write_text("spec")
+ (temp_git_repo / "spec.txt").write_text("spec", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec"],
@@ -920,7 +422,7 @@ class TestRebaseIntegration:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "main.txt").write_text("main")
+ (temp_git_repo / "main.txt").write_text("main", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main"],
@@ -966,7 +468,7 @@ class TestRebaseErrorHandling:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "spec-file.txt").write_text("spec content")
+ (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -1007,7 +509,6 @@ class TestRebaseErrorHandling:
def test_check_git_conflicts_handles_corrupted_repo(self, temp_git_repo: Path):
"""_check_git_conflicts handles corrupted repo metadata gracefully (ACS-224)."""
- import shutil
from core.workspace import _check_git_conflicts
@@ -1018,7 +519,7 @@ class TestRebaseErrorHandling:
cwd=temp_git_repo,
capture_output=True,
)
- (temp_git_repo / "spec-file.txt").write_text("spec content")
+ (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
diff --git a/apps/backend/core/workspace/tests/test_setup.py b/apps/backend/core/workspace/tests/test_setup.py
new file mode 100644
index 00000000..b74556cb
--- /dev/null
+++ b/apps/backend/core/workspace/tests/test_setup.py
@@ -0,0 +1,293 @@
+#!/usr/bin/env python3
+"""
+Tests for Workspace Setup Operations
+=====================================
+
+Tests the setup functionality including:
+- Spec copy to workspace operations
+- Timeline hook installation
+- Timeline tracking initialization
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+# Test constant - in the new per-spec architecture, each spec has its own worktree
+# named after the spec itself. This constant is used for test assertions.
+TEST_SPEC_NAME = "test-spec"
+
+
+class TestCopySpecToWorktree:
+ """Tests for copy_spec_to_worktree function."""
+
+ def test_copies_spec_files_to_worktree(self, temp_git_repo: Path):
+ """Copies spec directory to worktree .auto-claude/specs/ location."""
+ from core.workspace.setup import copy_spec_to_worktree
+
+ # Create source spec directory
+ source_spec = temp_git_repo / "specs" / "test-spec"
+ source_spec.mkdir(parents=True)
+ (source_spec / "spec.md").write_text("# Test Spec", encoding="utf-8")
+ (source_spec / "requirements.json").write_text("{}", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Copy spec
+ result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
+
+ # Verify path is correct
+ expected = worktree_path / ".auto-claude" / "specs" / "test-spec"
+ assert result == expected
+
+ # Verify files were copied
+ assert (expected / "spec.md").exists()
+ assert (expected / "requirements.json").exists()
+ assert (expected / "spec.md").read_text(encoding="utf-8") == "# Test Spec"
+
+ def test_overwrites_existing_spec_in_worktree(self, temp_git_repo: Path):
+ """Overwrites spec files if they already exist in worktree."""
+ from core.workspace.setup import copy_spec_to_worktree
+
+ # Create source spec
+ source_spec = temp_git_repo / "specs" / "test-spec"
+ source_spec.mkdir(parents=True)
+ (source_spec / "spec.md").write_text("# New Spec", encoding="utf-8")
+
+ # Create worktree with existing spec
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+ existing_spec = worktree_path / ".auto-claude" / "specs" / "test-spec"
+ existing_spec.mkdir(parents=True)
+ (existing_spec / "spec.md").write_text("# Old Spec", encoding="utf-8")
+
+ # Copy spec
+ result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
+
+ # Verify new content was copied
+ assert (result / "spec.md").read_text(encoding="utf-8") == "# New Spec"
+
+ def test_creates_parent_directories(self, temp_git_repo: Path):
+ """Creates .auto-claude/specs directory if it doesn't exist."""
+ from core.workspace.setup import copy_spec_to_worktree
+
+ source_spec = temp_git_repo / "specs" / "test-spec"
+ source_spec.mkdir(parents=True)
+ (source_spec / "spec.md").write_text("# Test", encoding="utf-8")
+
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
+
+ # Parent directories should be created
+ assert result.exists()
+ assert (result.parent).exists()
+
+
+class TestEnsureTimelineHookInstalled:
+ """Tests for ensure_timeline_hook_installed function."""
+
+ def test_skips_if_not_git_repo(self, temp_dir: Path):
+ """Skips hook installation if directory is not a git repo."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Should not raise exception
+ ensure_timeline_hook_installed(temp_dir)
+
+ def test_skips_if_hook_already_installed(self, temp_git_repo: Path, monkeypatch):
+ """Skips if FileTimelineTracker hook is already installed."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create hooks directory
+ hooks_dir = temp_git_repo / ".git" / "hooks"
+ hooks_dir.mkdir(parents=True, exist_ok=True)
+
+ # Create hook with FileTimelineTracker marker
+ hook_file = hooks_dir / "post-commit"
+ hook_file.write_text(
+ "#!/bin/sh\n# FileTimelineTracker hook\necho 'tracked'", encoding="utf-8"
+ )
+
+ # Mock install_hook to track if it was called
+ install_called = []
+
+ def mock_install_hook(project_dir):
+ install_called.append(True)
+
+ monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
+
+ ensure_timeline_hook_installed(temp_git_repo)
+
+ # install_hook should not be called
+ assert len(install_called) == 0
+
+ def test_installs_hook_if_missing(self, temp_git_repo: Path):
+ """Installs hook if it doesn't exist."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create hooks directory but no hook file
+ hooks_dir = temp_git_repo / ".git" / "hooks"
+ hooks_dir.mkdir(parents=True, exist_ok=True)
+
+ # This test verifies the function runs without error
+ # The actual install_hook call is hard to mock because it's imported locally
+ # In production, the real install_hook would be called
+ ensure_timeline_hook_installed(temp_git_repo)
+
+ # Verify hooks directory exists (function ran)
+ assert hooks_dir.exists()
+
+
+class TestInitializeTimelineTracking:
+ """Tests for initialize_timeline_tracking function."""
+
+ def test_with_implementation_plan(self, temp_git_repo: Path, monkeypatch):
+ """Initializes tracking with files from implementation plan."""
+ from core.workspace.setup import initialize_timeline_tracking
+
+ # Create source spec with implementation plan
+ spec_name = "test-spec"
+ source_spec = temp_git_repo / ".auto-claude" / "specs" / spec_name
+ source_spec.mkdir(parents=True)
+
+ plan = {
+ "title": "Test Feature",
+ "description": "Test description",
+ "phases": [
+ {
+ "subtasks": [
+ {"files": ["app/main.py", "app/utils.py"]},
+ {"files": ["tests/test_main.py"]},
+ ]
+ }
+ ],
+ }
+ (source_spec / "implementation_plan.json").write_text(
+ json.dumps(plan), encoding="utf-8"
+ )
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock FileTimelineTracker
+ mock_tracker_calls = []
+
+ class MockTracker:
+ def __init__(self, project_dir):
+ pass
+
+ def on_task_start(
+ self,
+ task_id,
+ files_to_modify,
+ branch_point_commit,
+ task_intent,
+ task_title,
+ ):
+ mock_tracker_calls.append(
+ {
+ "task_id": task_id,
+ "files": files_to_modify,
+ "branch": branch_point_commit,
+ "intent": task_intent,
+ "title": task_title,
+ }
+ )
+
+ monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
+
+ initialize_timeline_tracking(
+ temp_git_repo, spec_name, worktree_path, source_spec
+ )
+
+ # Verify tracker was called with correct parameters
+ assert len(mock_tracker_calls) == 1
+ call = mock_tracker_calls[0]
+ assert call["task_id"] == spec_name
+ assert set(call["files"]) == {
+ "app/main.py",
+ "app/utils.py",
+ "tests/test_main.py",
+ }
+ assert call["title"] == "Test Feature"
+ assert call["intent"] == "Test description"
+
+ def test_without_implementation_plan(self, temp_git_repo: Path, monkeypatch):
+ """Initializes tracking retroactively from worktree if no plan."""
+ from core.workspace.setup import initialize_timeline_tracking
+
+ spec_name = "test-spec"
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock FileTimelineTracker
+ mock_calls = []
+
+ class MockTracker:
+ def __init__(self, project_dir):
+ pass
+
+ def initialize_from_worktree(
+ self, task_id, worktree_path, task_intent, task_title
+ ):
+ mock_calls.append(
+ {
+ "task_id": task_id,
+ "worktree": worktree_path,
+ "intent": task_intent,
+ "title": task_title,
+ }
+ )
+
+ monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
+
+ initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
+
+ # Should use retroactive initialization
+ assert len(mock_calls) == 1
+ assert mock_calls[0]["task_id"] == spec_name
+
+ def test_handles_exception_gracefully(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Logs warning but doesn't raise exception on error."""
+ from core.workspace.setup import initialize_timeline_tracking
+
+ spec_name = "test-spec"
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock FileTimelineTracker to raise exception
+ class FailingTracker:
+ def __init__(self, project_dir):
+ raise Exception("Tracker init failed")
+
+ monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", FailingTracker)
+
+ # Should not raise
+ initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
+
+ # Should print warning
+ captured = capsys.readouterr()
+ assert "Timeline tracking" in captured.out or "Note:" in captured.out
diff --git a/apps/backend/core/workspace/tests/test_workspace.py b/apps/backend/core/workspace/tests/test_workspace.py
new file mode 100644
index 00000000..d2d0e57e
--- /dev/null
+++ b/apps/backend/core/workspace/tests/test_workspace.py
@@ -0,0 +1,2293 @@
+#!/usr/bin/env python3
+"""
+Tests for Workspace Selection and Management
+=============================================
+
+Tests the workspace.py module functionality including:
+- Workspace mode selection (isolated vs direct)
+- Uncommitted changes detection
+- Workspace setup
+- Build finalization workflows
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+# Add parent directory to path so we can import the workspace module
+# When co-located at workspace/tests/, we need to add backend to path
+# workspace/tests -> workspace -> core -> backend (4 levels up)
+_backend = Path(__file__).resolve().parent.parent.parent.parent
+sys.path.insert(0, str(_backend))
+
+from core.workspace import (
+ WorkspaceChoice,
+ WorkspaceMode,
+ get_current_branch,
+ get_existing_build_worktree,
+ has_uncommitted_changes,
+ setup_workspace,
+)
+from core.workspace.models import (
+ MergeLock,
+ MergeLockError,
+ SpecNumberLock,
+ SpecNumberLockError,
+)
+from worktree import WorktreeError, WorktreeManager
+
+# Test constant - in the new per-spec architecture, each spec has its own worktree
+# named after the spec itself. This constant is used for test assertions.
+TEST_SPEC_NAME = "test-spec"
+
+
+class TestHasUncommittedChanges:
+ """Tests for uncommitted changes detection."""
+
+ def test_clean_repo_no_changes(self, temp_git_repo: Path):
+ """Clean repo returns False."""
+ result = has_uncommitted_changes(temp_git_repo)
+ assert result is False
+
+ def test_untracked_file_has_changes(self, temp_git_repo: Path):
+ """Untracked file counts as changes."""
+ (temp_git_repo / "new_file.txt").write_text("content", encoding="utf-8")
+
+ result = has_uncommitted_changes(temp_git_repo)
+ assert result is True
+
+ def test_modified_file_has_changes(self, temp_git_repo: Path):
+ """Modified tracked file counts as changes."""
+ (temp_git_repo / "README.md").write_text("modified content", encoding="utf-8")
+
+ result = has_uncommitted_changes(temp_git_repo)
+ assert result is True
+
+ def test_staged_file_has_changes(self, temp_git_repo: Path):
+ """Staged file counts as changes."""
+ (temp_git_repo / "README.md").write_text("modified", encoding="utf-8")
+ subprocess.run(
+ ["git", "add", "README.md"], cwd=temp_git_repo, capture_output=True
+ )
+
+ result = has_uncommitted_changes(temp_git_repo)
+ assert result is True
+
+
+class TestGetCurrentBranch:
+ """Tests for current branch detection."""
+
+ def test_gets_main_branch(self, temp_git_repo: Path):
+ """Gets the main/master branch."""
+ branch = get_current_branch(temp_git_repo)
+
+ # Could be main or master depending on git config
+ assert branch in ["main", "master"]
+
+ def test_gets_feature_branch(self, temp_git_repo: Path):
+ """Gets feature branch name."""
+ subprocess.run(
+ ["git", "checkout", "-b", "feature/test-branch"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ branch = get_current_branch(temp_git_repo)
+ assert branch == "feature/test-branch"
+
+
+class TestGetExistingBuildWorktree:
+ """Tests for existing build worktree detection."""
+
+ def test_no_existing_worktree(self, temp_git_repo: Path):
+ """Returns None when no worktree exists."""
+ result = get_existing_build_worktree(temp_git_repo, "test-spec")
+ assert result is None
+
+ def test_existing_worktree(self, temp_git_repo: Path):
+ """Returns path when worktree exists."""
+ # Create the worktree directory structure (per-spec architecture)
+ worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME
+ worktree_path.mkdir(parents=True)
+
+ result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME)
+ assert result == worktree_path
+
+
+class TestSetupWorkspace:
+ """Tests for workspace setup."""
+
+ def test_setup_direct_mode(self, temp_git_repo: Path):
+ """Direct mode returns project dir and no manager."""
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.DIRECT,
+ )
+
+ assert working_dir == temp_git_repo
+ assert manager is None
+
+ def test_setup_isolated_mode(self, temp_git_repo: Path):
+ """Isolated mode creates worktree and returns manager."""
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ TEST_SPEC_NAME,
+ WorkspaceMode.ISOLATED,
+ )
+
+ assert working_dir != temp_git_repo
+ assert manager is not None
+ assert working_dir.exists()
+ # Per-spec architecture: worktree is named after the spec
+ assert working_dir.name == TEST_SPEC_NAME
+
+ def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path):
+ """Isolated mode creates worktrees directory."""
+ setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists()
+
+
+class TestWorkspaceUtilities:
+ """Tests for workspace utility functions."""
+
+ def test_per_spec_worktree_naming(self, temp_git_repo: Path):
+ """Per-spec architecture uses spec name for worktree directory."""
+ spec_name = "my-spec-001"
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ spec_name,
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Worktree should be named after the spec
+ assert working_dir.name == spec_name
+ # New path: .auto-claude/worktrees/tasks/{spec_name}
+ assert working_dir.parent.name == "tasks"
+
+
+class TestWorkspaceIntegration:
+ """Integration tests for workspace management."""
+
+ def test_isolated_workflow(self, temp_git_repo: Path):
+ """Full isolated workflow: setup -> work -> finalize."""
+ # Setup isolated workspace
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Make changes in workspace
+ (working_dir / "feature.py").write_text("# New feature\n", encoding="utf-8")
+
+ # Verify changes are in workspace
+ assert (working_dir / "feature.py").exists()
+
+ # Verify changes are NOT in main project
+ assert not (temp_git_repo / "feature.py").exists()
+
+ def test_direct_workflow(self, temp_git_repo: Path):
+ """Full direct workflow: setup -> work."""
+ # Setup direct workspace
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.DIRECT,
+ )
+
+ # Working dir is the project dir
+ assert working_dir == temp_git_repo
+
+ # Make changes directly
+ (working_dir / "feature.py").write_text("# New feature\n", encoding="utf-8")
+
+ # Changes are in main project
+ assert (temp_git_repo / "feature.py").exists()
+
+ def test_isolated_merge(self, temp_git_repo: Path):
+ """Can merge isolated workspace back to main."""
+ # Setup
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Make changes and commit using git directly
+ (working_dir / "feature.py").write_text("# New feature\n", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add feature"], cwd=working_dir, capture_output=True
+ )
+
+ # Merge back using merge_worktree
+ result = manager.merge_worktree("test-spec", delete_after=False)
+
+ assert result is True
+
+ # Check changes are in main
+ subprocess.run(
+ ["git", "checkout", manager.base_branch],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+ assert (temp_git_repo / "feature.py").exists()
+
+
+class TestWorkspaceCleanup:
+ """Tests for workspace cleanup."""
+
+ def test_cleanup_after_merge(self, temp_git_repo: Path):
+ """Workspace is cleaned up after merge with delete_after=True."""
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Commit changes using git directly
+ (working_dir / "test.py").write_text("test", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Test"], cwd=working_dir, capture_output=True
+ )
+
+ # Merge with cleanup
+ manager.merge_worktree("test-spec", delete_after=True)
+
+ # Workspace should be removed
+ assert not working_dir.exists()
+
+ def test_workspace_preserved_after_merge_no_delete(self, temp_git_repo: Path):
+ """Workspace preserved after merge with delete_after=False."""
+ working_dir, manager, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Commit changes using git directly
+ (working_dir / "test.py").write_text("test", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Test"], cwd=working_dir, capture_output=True
+ )
+
+ # Merge without cleanup
+ manager.merge_worktree("test-spec", delete_after=False)
+
+ # Workspace should still exist
+ assert working_dir.exists()
+
+
+class TestWorkspaceReuse:
+ """Tests for reusing existing workspaces."""
+
+ def test_reuse_existing_workspace(self, temp_git_repo: Path):
+ """Can reuse existing workspace on second setup."""
+ # First setup
+ working_dir1, manager1, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Add a marker file
+ (working_dir1 / "marker.txt").write_text("marker", encoding="utf-8")
+
+ # Second setup (should reuse)
+ working_dir2, manager2, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Should be the same directory
+ assert working_dir1 == working_dir2
+
+ # Marker should still exist
+ assert (working_dir2 / "marker.txt").exists()
+
+
+class TestWorkspaceErrors:
+ """Tests for workspace error handling."""
+
+ def test_setup_non_git_directory(self, temp_dir: Path):
+ """Handles non-git directories gracefully."""
+ # This should fail because temp_dir is not a git repo
+ with pytest.raises(
+ (OSError, ValueError, subprocess.CalledProcessError, WorktreeError)
+ ):
+ setup_workspace(
+ temp_dir,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+
+class TestPerSpecWorktreeName:
+ """Tests for per-spec worktree naming (new architecture)."""
+
+ def test_worktree_named_after_spec(self, temp_git_repo: Path):
+ """Worktree is named after the spec."""
+ spec_name = "spec-1"
+ working_dir, _, _ = setup_workspace(
+ temp_git_repo,
+ spec_name,
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Per-spec architecture: worktree directory matches spec name
+ assert working_dir.name == spec_name
+
+ def test_different_specs_get_different_worktrees(self, temp_git_repo: Path):
+ """Different specs create separate worktrees."""
+ working_dir1, _, _ = setup_workspace(
+ temp_git_repo,
+ "spec-1",
+ WorkspaceMode.ISOLATED,
+ )
+
+ working_dir2, _, _ = setup_workspace(
+ temp_git_repo,
+ "spec-2",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Each spec has its own worktree
+ assert working_dir1.name == "spec-1"
+ assert working_dir2.name == "spec-2"
+ assert working_dir1 != working_dir2
+
+ def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path):
+ """Worktree is created in worktrees directory."""
+ working_dir, _, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # New path: .auto-claude/worktrees/tasks/{spec_name}
+ assert "worktrees" in str(working_dir)
+ assert working_dir.parent.name == "tasks"
+
+
+class TestConflictInfoDisplay:
+ """Tests for conflict info display function (ACS-179)."""
+
+ def test_print_conflict_info_with_string_list(self, capsys):
+ """print_conflict_info handles string list of file paths (ACS-179)."""
+ from core.workspace.display import print_conflict_info
+
+ result = {"conflicts": ["file1.txt", "file2.py", "file3.js"]}
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "3 file" in captured.out
+ assert "file1.txt" in captured.out
+ assert "file2.py" in captured.out
+ assert "file3.js" in captured.out
+ assert "git add" in captured.out
+
+ def test_print_conflict_info_with_dict_list(self, capsys):
+ """print_conflict_info handles dict list with file/reason/severity (ACS-179)."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ {"file": "file1.txt", "reason": "Syntax error", "severity": "high"},
+ {"file": "file2.py", "reason": "Merge conflict", "severity": "medium"},
+ {"file": "file3.js", "reason": "Unknown error", "severity": "low"},
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "3 file" in captured.out
+ assert "file1.txt" in captured.out
+ assert "file2.py" in captured.out
+ assert "file3.js" in captured.out
+ assert "Syntax error" in captured.out
+ assert "Merge conflict" in captured.out
+ # Verify severity emoji indicators
+ assert "🔴" in captured.out # High severity
+ assert "🟡" in captured.out # Medium severity
+
+ def test_print_conflict_info_mixed_formats(self, capsys):
+ """print_conflict_info handles mixed string and dict conflicts (ACS-179)."""
+ from core.workspace.display import print_conflict_info
+
+ result = {
+ "conflicts": [
+ "simple-file.txt",
+ {
+ "file": "complex-file.py",
+ "reason": "AI merge failed",
+ "severity": "high",
+ },
+ ]
+ }
+
+ print_conflict_info(result)
+
+ captured = capsys.readouterr()
+ assert "2 file" in captured.out
+ assert "simple-file.txt" in captured.out
+ assert "complex-file.py" in captured.out
+ assert "AI merge failed" in captured.out
+
+
+class TestMergeErrorHandling:
+ """Tests for merge error handling (ACS-163)."""
+
+ def test_merge_failure_returns_false_immediately(self, temp_git_repo: Path):
+ """Failed merge returns False without falling through (ACS-163)."""
+ manager = WorktreeManager(temp_git_repo)
+ manager.setup()
+
+ # Create a worktree with changes
+ worker_info = manager.create_worktree("worker-spec")
+ (worker_info.path / "worker-file.txt").write_text(
+ "worker content", encoding="utf-8"
+ )
+ subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Worker commit"],
+ cwd=worker_info.path,
+ capture_output=True,
+ )
+
+ # Create a conflicting change on main
+ subprocess.run(
+ ["git", "checkout", manager.base_branch],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+ (temp_git_repo / "worker-file.txt").write_text("main content", encoding="utf-8")
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Main commit"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Merge should fail (conflict) and return False
+ # This tests the fix for ACS-163 where failed merge would fall through
+ result = manager.merge_worktree("worker-spec", delete_after=False)
+
+ # Should return False on merge conflict
+ assert result is False
+
+ # Verify side effects: base branch content is unchanged
+ subprocess.run(
+ ["git", "checkout", manager.base_branch],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+ base_content = (temp_git_repo / "worker-file.txt").read_text(encoding="utf-8")
+ assert base_content == "main content", (
+ "Base branch should be unchanged after failed merge"
+ )
+
+
+class TestMergeLockExceptionHandling:
+ """Tests for exception handling in MergeLock.__exit__ (lines 136-137)."""
+
+ def test_merge_lock_exit_handles_already_deleted_lock(self, temp_git_repo: Path):
+ """MergeLock.__exit__ handles lock file already being deleted (lines 136-137)."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ with lock:
+ assert lock.acquired is True
+ # Delete the lock file manually before context exits
+ lock.lock_file.unlink()
+
+ # Should exit cleanly even though lock file was already deleted
+ assert lock.lock_file.exists() is False
+
+
+class TestSpecNumberLockExceptionHandling:
+ """Tests for exception handling in SpecNumberLock.__exit__ (lines 225-226)."""
+
+ def test_spec_number_lock_exit_handles_already_deleted_lock(
+ self, temp_git_repo: Path
+ ):
+ """SpecNumberLock.__exit__ handles lock file already being deleted (lines 225-226)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ with lock:
+ assert lock.acquired is True
+ # Delete the lock file manually before context exits
+ lock.lock_file.unlink()
+
+ # Should exit cleanly even though lock file was already deleted
+ assert lock.lock_file.exists() is False
+
+
+class TestScanSpecsDirValueErrorHandling:
+ """Tests for ValueError handling in _scan_specs_dir (lines 272-273)."""
+
+ def test_scan_specs_dir_handles_non_numeric_prefix(self, temp_git_repo: Path):
+ """_scan_specs_dir handles directories with non-numeric prefix (lines 272-273)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ # Create specs directory with invalid names
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Create directories with various invalid prefixes
+ (specs_dir / "abc-invalid").mkdir()
+ (specs_dir / "xyz-test").mkdir()
+ (specs_dir / "--bad").mkdir()
+
+ with lock:
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should ignore directories with non-numeric prefixes and return 0
+ assert result == 0
+
+ def test_scan_specs_dir_handles_partial_numeric_prefix(self, temp_git_repo: Path):
+ """_scan_specs_dir handles directories with partial numeric prefix (lines 272-273)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Create directories with partial numeric prefixes
+ (specs_dir / "12-invalid").mkdir() # Only 2 digits
+ (specs_dir / "1-bad").mkdir() # Only 1 digit
+ (specs_dir / "001-valid").mkdir() # Valid
+
+ with lock:
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should only count the valid 3-digit prefix
+ assert result == 1
+
+ def test_scan_specs_dir_handles_empty_directory_name(self, temp_git_repo: Path):
+ """_scan_specs_dir handles empty directory names gracefully (lines 272-273)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Create directory that's just dashes (would cause issues with [:3])
+ (specs_dir / "---").mkdir()
+
+ with lock:
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should handle gracefully without crashing
+ assert result == 0
+
+ def test_scan_specs_dir_handles_very_long_numeric_prefix(self, temp_git_repo: Path):
+ """_scan_specs_dir handles directories with long numeric strings (lines 272-273)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Create directory with high spec number (tests parsing first 3 digits)
+ # The glob pattern "[0-9][0-9][0-9]-*" matches exactly 3 digits, so use 999
+ (specs_dir / "999-high-spec").mkdir()
+
+ with lock:
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should parse the first 3 digits as number
+ assert result == 999
+
+ def test_scan_specs_dir_handles_mixed_valid_invalid(self, temp_git_repo: Path):
+ """_scan_specs_dir handles mix of valid and invalid spec directories (lines 272-273)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Mix of valid and invalid directories
+ (specs_dir / "001-first").mkdir()
+ (specs_dir / "invalid-name").mkdir()
+ (specs_dir / "005-second").mkdir()
+ (specs_dir / "abc").mkdir()
+ (specs_dir / "010-third").mkdir()
+
+ with lock:
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should only count valid directories and return max
+ assert result == 10
+
+
+# =============================================================================
+# TESTS FOR WORKSPACE SETUP (core.workspace.setup) - MISSING COVERAGE
+# =============================================================================
+
+
+class TestChooseWorkspace:
+ """Tests for choose_workspace function (lines 52-146)."""
+
+ def test_force_isolated_mode(self, temp_git_repo: Path, monkeypatch):
+ """Returns ISOLATED mode when force_isolated is True (lines 75-76)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to avoid its side effects
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: False
+ )
+
+ result = choose_workspace(
+ temp_git_repo,
+ "test-spec",
+ force_isolated=True,
+ )
+
+ assert result == WorkspaceMode.ISOLATED
+
+ def test_force_direct_mode(self, temp_git_repo: Path, monkeypatch):
+ """Returns DIRECT mode when force_direct is True (lines 77-78)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to avoid its side effects
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: False
+ )
+
+ result = choose_workspace(
+ temp_git_repo,
+ "test-spec",
+ force_direct=True,
+ )
+
+ assert result == WorkspaceMode.DIRECT
+
+ def test_auto_continue_defaults_to_isolated(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Auto-continue mode defaults to isolated for safety (lines 81-83)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to avoid its side effects
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: False
+ )
+
+ result = choose_workspace(
+ temp_git_repo,
+ "test-spec",
+ auto_continue=True,
+ )
+
+ assert result == WorkspaceMode.ISOLATED
+ captured = capsys.readouterr()
+ assert "Auto-continue" in captured.out
+
+ def test_unsaved_work_triggers_isolated(self, temp_git_repo: Path, monkeypatch):
+ """Uncommitted changes trigger isolated mode (lines 86-110)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to return True
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: True
+ )
+
+ # Mock input to simulate Enter key press
+ monkeypatch.setattr("builtins.input", lambda x: None)
+
+ result = choose_workspace(
+ temp_git_repo,
+ "test-spec",
+ )
+
+ assert result == WorkspaceMode.ISOLATED
+
+ def test_unsaved_work_with_keyboard_interrupt(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """KeyboardInterrupt during unsaved work prompt exits cleanly (lines 105-108)."""
+ import sys
+
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to return True
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: True
+ )
+
+ # Mock input to raise KeyboardInterrupt
+ def mock_input(prompt):
+ raise KeyboardInterrupt()
+
+ monkeypatch.setattr("builtins.input", mock_input)
+
+ # Should exit via sys.exit(0)
+ with pytest.raises(SystemExit) as exc_info:
+ choose_workspace(temp_git_repo, "test-spec")
+
+ assert exc_info.value.code == 0
+
+
+class TestDebugModuleFallback:
+ """Tests for debug module fallback functions (lines 35-43)."""
+
+ def test_fallback_debug_function(self, monkeypatch):
+ """Fallback debug function does nothing when module is unavailable."""
+ # Remove debug from sys.modules if present
+ import sys
+
+ debug_module = sys.modules.pop("debug", None)
+
+ try:
+ # Re-import setup.py to trigger the fallback
+ monkeypatch.setattr(sys, "modules", {**sys.modules})
+ if "core.workspace.setup" in sys.modules:
+ del sys.modules["core.workspace.setup"]
+
+ # Import fresh - should use fallback
+ import core.workspace.setup as setup_module
+
+ # Fallback debug functions should be no-ops
+ setup_module.debug("test", "message")
+ setup_module.debug_warning("test", "warning")
+
+ # Should not raise any exceptions
+ assert True
+ finally:
+ # Restore debug module if it existed
+ if debug_module is not None:
+ sys.modules["debug"] = debug_module
+
+ def test_fallback_debug_warning_function(self, monkeypatch):
+ """Fallback debug_warning function does nothing when module is unavailable."""
+ import sys
+
+ # Remove debug from sys.modules if present
+ debug_module = sys.modules.pop("debug", None)
+
+ try:
+ # Force reimport to use fallback
+ if "core.workspace.setup" in sys.modules:
+ del sys.modules["core.workspace.setup"]
+
+ from core.workspace.setup import debug_warning
+
+ # Fallback function should be a no-op
+ debug_warning("test_module", "test_warning")
+
+ # Should not raise any exceptions
+ assert True
+ finally:
+ if debug_module is not None:
+ sys.modules["debug"] = debug_module
+
+
+class TestSymlinkBrokenSymlinkDetection:
+ """Tests for broken symlink detection (lines 242-247)."""
+
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific symlink test")
+ def test_skips_broken_symlinks(self, temp_git_repo: Path):
+ """Skips creating symlink if broken symlink already exists (lines 242-247)."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create node_modules in project
+ node_modules = temp_git_repo / "node_modules"
+ node_modules.mkdir()
+ (node_modules / "test.txt").write_text("test", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Create a broken symlink (pointing to non-existent path)
+ non_existent_path = temp_git_repo / "non_existent_path"
+ os.symlink(
+ non_existent_path, worktree_path / "node_modules", target_is_directory=False
+ )
+
+ # Verify symlink is broken
+ assert (worktree_path / "node_modules").is_symlink()
+ assert not (worktree_path / "node_modules").exists()
+
+ # Should skip the broken symlink
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # node_modules should not be in symlinked list
+ assert "node_modules" not in symlinked
+
+
+class TestWindowsJunctionFailure:
+ """Tests for Windows junction creation failure (lines 256-262)."""
+
+ @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
+ def test_handles_mklink_failure(self, temp_git_repo: Path, monkeypatch, capsys):
+ """Handles mklink /J failure gracefully (lines 256-262)."""
+ from unittest.mock import patch
+
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create node_modules in project
+ node_modules = temp_git_repo / "node_modules"
+ node_modules.mkdir()
+ (node_modules / "test.txt").write_text("test", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock subprocess.run to simulate mklink failure
+ def mock_subprocess_run(cmd, capture_output=False, text=False):
+ result = type(
+ "obj", (object,), {"returncode": 1, "stderr": "Access denied"}
+ )()
+ return result
+
+ with patch("subprocess.run", side_effect=mock_subprocess_run):
+ with monkeypatch.context() as m:
+ m.setattr("sys.platform", "win32")
+ symlinked = symlink_node_modules_to_worktree(
+ temp_git_repo, worktree_path
+ )
+
+ # Should handle failure gracefully
+ assert "node_modules" not in symlinked
+
+
+class TestSymlinkOSErrorHandling:
+ """Tests for OSError handling in symlink creation (lines 269-278)."""
+
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
+ def test_handles_oserror_on_symlink_creation(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Handles OSError when symlink creation fails (lines 269-281)."""
+ from unittest.mock import patch
+
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create node_modules in project
+ node_modules = temp_git_repo / "node_modules"
+ node_modules.mkdir()
+ (node_modules / "test.txt").write_text("test", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock os.symlink to raise OSError
+ def mock_symlink(src, dst):
+ raise OSError("Filesystem does not support symlinks")
+
+ with patch("os.symlink", side_effect=mock_symlink):
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # Should handle error gracefully
+ assert "node_modules" not in symlinked
+
+ # Check warning message was printed
+ captured = capsys.readouterr()
+ assert "Warning" in captured.out or "node_modules" in captured.out
+
+
+class TestEnvFilesPrintStatus:
+ """Tests for env files copy print status (line 373)."""
+
+ def test_prints_status_when_env_files_copied(self, temp_git_repo: Path, capsys):
+ """Prints status message when env files are copied (line 373-375)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+
+ # Create .env file in project root
+ (temp_git_repo / ".env").write_text("TEST=1", encoding="utf-8")
+
+ # Setup isolated workspace - .env should be copied
+ setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ captured = capsys.readouterr()
+ assert "Environment files copied" in captured.out
+
+
+class TestSymlinkedModulesPrintStatus:
+ """Tests for symlinked modules print status (line 383)."""
+
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific symlink test")
+ def test_prints_status_when_modules_symlinked(self, temp_git_repo: Path, capsys):
+ """Prints status message when node_modules are symlinked (line 383)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+
+ # Create backend/.venv to trigger Python virtual environment detection
+ # This is a common pattern in this monorepo
+ backend_venv = temp_git_repo / "apps" / "backend" / ".venv"
+ backend_venv.mkdir(parents=True)
+ (backend_venv / "lib").mkdir()
+
+ # Create node_modules at root
+ node_modules = temp_git_repo / "node_modules"
+ node_modules.mkdir()
+ (node_modules / "package.json").write_text("{}", encoding="utf-8")
+
+ # Create apps/frontend/node_modules
+ frontend_node_modules = temp_git_repo / "apps" / "frontend" / "node_modules"
+ frontend_node_modules.mkdir(parents=True)
+ (frontend_node_modules / "react").mkdir()
+
+ # Setup isolated workspace - node_modules should be symlinked
+ setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ captured = capsys.readouterr()
+ assert "Dependencies linked" in captured.out
+
+
+class TestSecurityFilesCopy:
+ """Tests for security files copy with error handling (lines 395-407)."""
+
+ def test_copies_security_files(self, temp_git_repo: Path):
+ """Copies security configuration files to worktree (lines 389-406)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+ from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
+
+ # Create security files
+ allowlist_file = temp_git_repo / ALLOWLIST_FILENAME
+ allowlist_file.write_text("allowlist content", encoding="utf-8")
+
+ profile_file = temp_git_repo / PROFILE_FILENAME
+ profile_file.write_text('{"profile": "data"}', encoding="utf-8")
+
+ # Commit changes
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add security files"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Setup workspace
+ worktree_path, _, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Verify files were copied
+ assert (worktree_path / ALLOWLIST_FILENAME).exists()
+ assert (worktree_path / PROFILE_FILENAME).exists()
+ assert (worktree_path / ALLOWLIST_FILENAME).read_text(
+ encoding="utf-8"
+ ) == "allowlist content"
+
+ def test_handles_security_file_copy_error(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Handles OSError when copying security files (lines 399-406)."""
+ from unittest.mock import patch
+
+ from core.workspace.setup import copy_env_files_to_worktree
+ from security.constants import ALLOWLIST_FILENAME
+
+ # Create security file
+ allowlist_file = temp_git_repo / ALLOWLIST_FILENAME
+ allowlist_file.write_text("content", encoding="utf-8")
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock shutil.copy2 to raise PermissionError
+ def mock_copy2(src, dst):
+ if ALLOWLIST_FILENAME in str(src):
+ raise PermissionError("Access denied")
+ return shutil.copy2(src, dst)
+
+ with patch("shutil.copy2", side_effect=mock_copy2):
+ # This should handle the error gracefully
+ copy_env_files_to_worktree(temp_git_repo, worktree_path)
+
+ # Function should complete without raising
+ assert True
+
+
+class TestSecurityProfileInheritance:
+ """Tests for security profile inheritance marking (lines 413-428)."""
+
+ def test_marks_profile_as_inherited(self, temp_git_repo: Path):
+ """Marks security profile with inherited_from field (lines 416-428)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+ from security.constants import PROFILE_FILENAME
+
+ # Create security profile
+ profile_data = {"profile": "test-profile", "project_type": "python"}
+ profile_file = temp_git_repo / PROFILE_FILENAME
+ profile_file.write_text(json.dumps(profile_data, indent=2), encoding="utf-8")
+
+ # Commit changes
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add profile"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Setup workspace
+ worktree_path, _, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Verify profile was marked as inherited
+ worktree_profile = worktree_path / PROFILE_FILENAME
+ assert worktree_profile.exists()
+
+ with open(worktree_profile, encoding="utf-8") as f:
+ worktree_profile_data = json.load(f)
+
+ assert "inherited_from" in worktree_profile_data
+ assert str(temp_git_repo.resolve()) in worktree_profile_data["inherited_from"]
+
+ def test_handles_corrupt_profile_json(self, temp_git_repo: Path, capsys):
+ """Handles JSON decode error when reading profile (line 427-428)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+ from security.constants import PROFILE_FILENAME
+
+ # Create corrupt profile file
+ profile_file = temp_git_repo / PROFILE_FILENAME
+ profile_file.write_text("{invalid json content", encoding="utf-8")
+
+ # Commit changes
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add corrupt profile"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Setup workspace - should handle error gracefully
+ worktree_path, _, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Verify worktree was created despite corrupt profile
+ assert worktree_path.exists()
+
+
+class TestSpecCopyInSetupWorkspace:
+ """Tests for spec copy in setup_workspace (lines 441-445)."""
+
+ def test_copies_spec_to_workspace(self, temp_git_repo: Path):
+ """Copies spec files to workspace when source_spec_dir is provided (lines 441-445)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+
+ # Create source spec directory
+ source_spec = temp_git_repo / "external-specs" / "test-spec"
+ source_spec.mkdir(parents=True)
+ (source_spec / "spec.md").write_text("# Test Spec", encoding="utf-8")
+ (source_spec / "requirements.json").write_text("{}", encoding="utf-8")
+
+ # Setup workspace with source spec
+ worktree_path, _, localized_spec = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ source_spec_dir=source_spec,
+ )
+
+ # Verify spec was copied
+ assert localized_spec is not None
+ assert localized_spec.exists()
+ assert (localized_spec / "spec.md").exists()
+ assert (localized_spec / "requirements.json").exists()
+
+ def test_skips_spec_copy_when_source_not_exists(self, temp_git_repo: Path):
+ """Skips spec copy when source_spec_dir does not exist (lines 441-445)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+
+ # Setup workspace with non-existent source spec
+ non_existent_spec = temp_git_repo / "non-existent-spec"
+
+ worktree_path, _, localized_spec = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ source_spec_dir=non_existent_spec,
+ )
+
+ # localized_spec should be None
+ assert localized_spec is None
+
+
+class TestTimelineHookNotGitRepo:
+ """Tests for ensure_timeline_hook_installed with non-git directory (line 477)."""
+
+ def test_returns_early_when_not_git_repo(self, temp_dir: Path):
+ """Returns early when directory is not a git repository (line 477)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Should not raise any exception
+ ensure_timeline_hook_installed(temp_dir)
+
+ # Function should return without doing anything
+ assert True
+
+
+class TestTimelineHookWorktreeGitFile:
+ """Tests for worktree .git file handling (lines 480-485)."""
+
+ def test_handles_worktree_git_file(self, temp_git_repo: Path):
+ """Handles worktree where .git is a file, not directory (lines 480-485)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create a worktree-style .git file
+ git_dir = temp_git_repo / ".git"
+ git_dir_content = "gitdir: .git/worktrees/test\n"
+
+ # Save original .git directory
+ git_backup = temp_git_repo / ".git.backup"
+ if git_dir.is_dir():
+ shutil.move(str(git_dir), str(git_backup))
+
+ try:
+ # Create .git as a file (worktree style)
+ git_dir.write_text(git_dir_content, encoding="utf-8")
+
+ # Should handle this gracefully
+ ensure_timeline_hook_installed(temp_git_repo)
+
+ assert True
+ finally:
+ # Restore original .git
+ if git_backup.exists():
+ if git_dir.exists():
+ git_dir.unlink()
+ shutil.move(str(git_backup), str(git_dir))
+
+ def test_handles_invalid_git_file_content(self, temp_git_repo: Path):
+ """Handles .git file with invalid content (lines 481-485)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create a .git file with invalid content
+ git_dir = temp_git_repo / ".git"
+ git_backup = temp_git_repo / ".git.backup"
+
+ # Save original
+ if git_dir.is_dir():
+ shutil.move(str(git_dir), str(git_backup))
+
+ try:
+ # Write invalid content (doesn't start with "gitdir:")
+ git_dir.write_text("invalid content", encoding="utf-8")
+
+ # Should return early without error
+ ensure_timeline_hook_installed(temp_git_repo)
+
+ assert True
+ finally:
+ if git_backup.exists():
+ if git_dir.exists():
+ git_dir.unlink()
+ shutil.move(str(git_backup), str(git_dir))
+
+
+class TestTimelineHookExistsCheck:
+ """Tests for hook exists check (lines 490-493)."""
+
+ def test_skips_when_hook_already_exists(self, temp_git_repo: Path, monkeypatch):
+ """Skips installation when hook already exists with FileTimelineTracker (lines 490-493)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create hooks directory and hook file with FileTimelineTracker marker
+ hooks_dir = temp_git_repo / ".git" / "hooks"
+ hooks_dir.mkdir(parents=True, exist_ok=True)
+
+ hook_file = hooks_dir / "post-commit"
+ hook_content = """#!/bin/sh
+# FileTimelineTracker hook
+git log -1
+"""
+ hook_file.write_text(hook_content, encoding="utf-8")
+
+ # Track if install_hook was called
+ install_called = []
+
+ def mock_install_hook(project_dir):
+ install_called.append(True)
+
+ monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
+
+ ensure_timeline_hook_installed(temp_git_repo)
+
+ # install_hook should NOT have been called
+ assert len(install_called) == 0
+
+
+class TestTimelineHookExceptionHandling:
+ """Tests for exception handling in ensure_timeline_hook_installed (lines 501-503)."""
+
+ def test_handles_exception_gracefully(self, temp_git_repo: Path, monkeypatch):
+ """Handles exceptions during hook installation gracefully (lines 501-503)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Mock install_hook to raise an exception
+ def mock_install_hook(project_dir):
+ raise RuntimeError("Hook installation failed")
+
+ monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
+
+ # Should not raise exception - should handle it via debug_warning
+ ensure_timeline_hook_installed(temp_git_repo)
+
+ # Test passes if no exception was raised
+ assert True
+
+
+class TestInitializeTimelineTrackingNoSourceSpec:
+ """Tests for initialize_timeline_tracking without source spec (lines 563-569)."""
+
+ def test_initializes_from_worktree_without_plan(self, temp_git_repo: Path):
+ """Initializes tracking from worktree when no implementation plan exists (lines 563-569)."""
+ from core.workspace.setup import initialize_timeline_tracking
+
+ # Create worktree with some changes
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+ (worktree_path / "test.py").write_text("# Test file", encoding="utf-8")
+
+ # Call without source_spec_dir
+ initialize_timeline_tracking(
+ project_dir=temp_git_repo,
+ spec_name="test-spec",
+ worktree_path=worktree_path,
+ source_spec_dir=None,
+ )
+
+ # Should complete without error
+ assert True
+
+
+class TestInitializeTimelineTrackingWithNoFiles:
+ """Tests for initialize_timeline_tracking with no files to track."""
+
+ def test_handles_no_files_in_plan(self, temp_git_repo: Path):
+ """Handles implementation plan with no files to modify (lines 546-561)."""
+ from core.workspace.setup import initialize_timeline_tracking
+
+ # Create source spec with empty implementation plan
+ source_spec = temp_git_repo / ".auto-claude" / "specs" / "test-spec"
+ source_spec.mkdir(parents=True)
+
+ plan = {"title": "Empty Plan", "description": "No files", "phases": []}
+ (source_spec / "implementation_plan.json").write_text(
+ json.dumps(plan), encoding="utf-8"
+ )
+
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Should handle empty plan gracefully
+ initialize_timeline_tracking(
+ project_dir=temp_git_repo,
+ spec_name="test-spec",
+ worktree_path=worktree_path,
+ source_spec_dir=source_spec,
+ )
+
+ assert True
+
+
+class TestFinalizationWorkspaceCdPathFallbacks:
+ """Tests for finalization cd path fallback when get_existing_build_worktree returns None (lines 176, 247)."""
+
+ def test_test_choice_fallback_to_default_path(
+ self, temp_git_repo: Path, capsys, monkeypatch
+ ):
+ """Tests TEST choice shows default .auto-claude path when worktree not found (lines 172-180)."""
+ from core.workspace.finalization import handle_workspace_choice
+ from worktree import WorktreeManager
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Mock get_existing_build_worktree to return None (no worktree found)
+ def mock_get_existing_build_worktree(project_dir, spec_name):
+ return None
+
+ monkeypatch.setattr(
+ "core.workspace.finalization.get_existing_build_worktree",
+ mock_get_existing_build_worktree,
+ )
+
+ handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
+
+ captured = capsys.readouterr()
+ # Should show the default .auto-claude/worktrees/tasks/{spec_name} path
+ assert ".auto-claude/worktrees/tasks/test-spec" in captured.out
+
+ def test_later_choice_fallback_to_default_path(
+ self, temp_git_repo: Path, capsys, monkeypatch
+ ):
+ """Tests LATER choice shows default path when worktree not found (lines 243-251)."""
+ from core.workspace.finalization import handle_workspace_choice
+ from worktree import WorktreeManager
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Mock get_existing_build_worktree to return None
+ def mock_get_existing_build_worktree(project_dir, spec_name):
+ return None
+
+ monkeypatch.setattr(
+ "core.workspace.finalization.get_existing_build_worktree",
+ mock_get_existing_build_worktree,
+ )
+
+ handle_workspace_choice(
+ WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
+ )
+
+ captured = capsys.readouterr()
+ # Should show the default .auto-claude/worktrees/tasks/{spec_name} path
+ assert ".auto-claude/worktrees/tasks/test-spec" in captured.out
+
+
+class TestFinalizationWorkspaceCdPathWithExistingBuild:
+ """Tests for finalization cd path when get_existing_build_worktree returns a path (lines 174, 245)."""
+
+ def test_test_choice_shows_existing_worktree_path(
+ self, temp_git_repo: Path, capsys, monkeypatch
+ ):
+ """Tests TEST choice shows worktree path when staging_path is None and get_existing_build_worktree returns path (line 174)."""
+ from core.workspace.finalization import handle_workspace_choice
+ from worktree import WorktreeManager
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create a worktree directory (plain directory, not a git worktree)
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock manager.get_worktree_info to return None (simulating no valid worktree info)
+ # This ensures staging_path will be None
+ monkeypatch.setattr(manager, "get_worktree_info", lambda spec_name: None)
+
+ # Mock get_existing_build_worktree to return the worktree path
+ def mock_get_existing_build_worktree(project_dir, spec_name):
+ return worktree_path
+
+ monkeypatch.setattr(
+ "core.workspace.finalization.get_existing_build_worktree",
+ mock_get_existing_build_worktree,
+ )
+
+ handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
+
+ captured = capsys.readouterr()
+ # Should show the actual worktree path (via line 174)
+ assert str(worktree_path) in captured.out
+
+ def test_later_choice_shows_existing_worktree_path(
+ self, temp_git_repo: Path, capsys, monkeypatch
+ ):
+ """Tests LATER choice shows worktree path when staging_path is None and get_existing_build_worktree returns path (line 245)."""
+ from core.workspace.finalization import handle_workspace_choice
+ from worktree import WorktreeManager
+
+ manager = WorktreeManager(temp_git_repo)
+ spec_name = "test-spec"
+
+ # Create a worktree directory (plain directory, not a git worktree)
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock manager.get_worktree_info to return None (simulating no valid worktree info)
+ # This ensures staging_path will be None
+ monkeypatch.setattr(manager, "get_worktree_info", lambda spec_name: None)
+
+ # Mock get_existing_build_worktree to return the worktree path
+ def mock_get_existing_build_worktree(project_dir, spec_name):
+ return worktree_path
+
+ monkeypatch.setattr(
+ "core.workspace.finalization.get_existing_build_worktree",
+ mock_get_existing_build_worktree,
+ )
+
+ handle_workspace_choice(
+ WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
+ )
+
+ captured = capsys.readouterr()
+ # Should show the actual worktree path (via line 245)
+ assert str(worktree_path) in captured.out
+
+
+class TestChooseWorkspaceMenuSelection:
+ """Tests for choose_workspace menu selection (lines 113-146)."""
+
+ def test_shows_menu_with_isolated_and_direct_options(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Shows menu with isolated and direct options when no uncommitted changes (lines 113-146)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to return False
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: False
+ )
+
+ # Mock select_menu to return "direct" choice
+ def mock_select_menu(title, options, allow_quit=False):
+ from ui import MenuOption
+
+ # Verify the options are correct
+ assert len(options) == 2
+ assert options[0].key == "isolated"
+ assert options[1].key == "direct"
+ assert "Separate workspace" in options[0].label
+ assert "Right here" in options[1].label
+ return "direct"
+
+ monkeypatch.setattr("core.workspace.setup.select_menu", mock_select_menu)
+
+ result = choose_workspace(
+ temp_git_repo,
+ "test-spec",
+ )
+
+ assert result == WorkspaceMode.DIRECT
+ captured = capsys.readouterr()
+ assert "Working directly in your project" in captured.out
+
+ def test_menu_selects_isolated_returns_isolated_mode(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Menu returns isolated mode when isolated option is selected (lines 139-146)."""
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to return False
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: False
+ )
+
+ # Mock select_menu to return "isolated"
+ monkeypatch.setattr(
+ "core.workspace.setup.select_menu",
+ lambda title, options, allow_quit=False: "isolated",
+ )
+
+ result = choose_workspace(
+ temp_git_repo,
+ "test-spec",
+ )
+
+ assert result == WorkspaceMode.ISOLATED
+ captured = capsys.readouterr()
+ assert "Using a separate workspace for safety" in captured.out
+
+ def test_menu_with_none_choice_exits(self, temp_git_repo: Path, monkeypatch):
+ """Menu with None choice (user quit) exits via sys.exit(0) (lines 134-137)."""
+ from core.workspace.setup import choose_workspace
+
+ # Mock has_uncommitted_changes to return False
+ monkeypatch.setattr(
+ "core.workspace.setup.has_uncommitted_changes", lambda x: False
+ )
+
+ # Mock select_menu to return None (user quit)
+ monkeypatch.setattr(
+ "core.workspace.setup.select_menu",
+ lambda title, options, allow_quit=False: None,
+ )
+
+ # Should exit via sys.exit(0)
+ with pytest.raises(SystemExit) as exc_info:
+ choose_workspace(temp_git_repo, "test-spec")
+
+ assert exc_info.value.code == 0
+
+
+class TestWindowsJunctionCreation:
+ """Tests for Windows-specific junction creation in symlink_node_modules_to_worktree (lines 256-262)."""
+
+ @pytest.mark.skipif(
+ sys.platform != "win32",
+ reason="Windows junction creation only applies on Windows",
+ )
+ def test_creates_junction_on_windows(self, temp_git_repo: Path, monkeypatch):
+ """Creates junction on Windows using mklink /J command (lines 256-262)."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create source node_modules directory
+ source_node_modules = temp_git_repo / "node_modules"
+ source_node_modules.mkdir()
+ (source_node_modules / "test-package").mkdir()
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock subprocess.run to simulate mklink /J
+ mock_results = []
+
+ def mock_subprocess_run(cmd, capture_output=False, text=False, **kwargs):
+ mock_results.append(cmd)
+ result = type("MockResult", (), {"returncode": 0, "stderr": ""})()
+ return result
+
+ monkeypatch.setattr("subprocess.run", mock_subprocess_run)
+
+ # Call the function
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # Verify mklink /J command was called
+ assert len(mock_results) > 0
+ cmd = mock_results[0]
+ assert "cmd" in cmd
+ assert "/c" in cmd
+ assert "mklink" in cmd
+ assert "/J" in cmd
+
+ @pytest.mark.skipif(
+ sys.platform != "win32",
+ reason="Windows junction creation only applies on Windows",
+ )
+ def test_handles_junction_creation_failure(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Handles mklink /J failure gracefully (lines 261-262, 269-281)."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Create source node_modules directory
+ source_node_modules = temp_git_repo / "node_modules"
+ source_node_modules.mkdir()
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock subprocess.run to simulate mklink failure
+ def mock_subprocess_run(cmd, capture_output=False, text=False, **kwargs):
+ result = type(
+ "MockResult", (), {"returncode": 1, "stderr": "Access denied"}
+ )()
+ return result
+
+ monkeypatch.setattr("subprocess.run", mock_subprocess_run)
+
+ # Call the function - should handle error gracefully
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # Should return empty list (no successful symlinks)
+ assert len(symlinked) == 0
+
+ captured = capsys.readouterr()
+ # Should show warning
+ assert "Warning" in captured.out or "TypeScript" in captured.out
+
+ def test_creates_relative_symlink_on_non_windows(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """Creates relative symlink on non-Windows platforms (lines 264-266)."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Skip on actual Windows
+ if sys.platform == "win32":
+ pytest.skip("Test for non-Windows platforms")
+
+ # Create source node_modules directory
+ source_node_modules = temp_git_repo / "node_modules"
+ source_node_modules.mkdir()
+ (source_node_modules / "test-package").mkdir()
+
+ # Create worktree
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Call the function
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # Verify symlink was created
+ assert len(symlinked) > 0
+ target_path = worktree_path / symlinked[0]
+ assert target_path.is_symlink()
+
+
+class TestSecurityFileCopyErrorInSetupWorkspace:
+ """Tests for security file copy error handling in setup_workspace (lines 402-403)."""
+
+ def test_handles_security_file_copy_oserror_in_setup(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Handles OSError when copying security files in setup_workspace (lines 402-406)."""
+ from unittest.mock import patch
+
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+ from security.constants import ALLOWLIST_FILENAME
+
+ # Create security file
+ allowlist_file = temp_git_repo / ALLOWLIST_FILENAME
+ allowlist_file.write_text("content", encoding="utf-8")
+
+ # Commit changes
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add allowlist"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Track if warning was printed
+ print_calls = []
+
+ original_print = (
+ __builtins__["print"]
+ if isinstance(__builtins__, dict)
+ else __builtins__.print
+ )
+
+ def mock_print(*args, **kwargs):
+ print_calls.append((args, kwargs))
+ return original_print(*args, **kwargs)
+
+ # Mock shutil.copy2 to raise OSError for security files
+ def mock_copy2(src, dst):
+ if ALLOWLIST_FILENAME in str(src):
+ raise OSError("Permission denied")
+ return shutil.copy2(src, dst)
+
+ monkeypatch.setattr("builtins.print", mock_print)
+
+ with patch("shutil.copy2", side_effect=mock_copy2):
+ # Setup workspace - should handle error gracefully
+ worktree_path, _, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Verify worktree was created despite copy error
+ assert worktree_path.exists()
+
+ def test_handles_permission_error_on_security_copy(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Handles PermissionError when copying security files (lines 402-406)."""
+ from unittest.mock import patch
+
+ from core.workspace.models import WorkspaceMode
+ from core.workspace.setup import setup_workspace
+ from security.constants import PROFILE_FILENAME
+
+ # Create security profile
+ profile_file = temp_git_repo / PROFILE_FILENAME
+ profile_file.write_text('{"profile": "data"}', encoding="utf-8")
+
+ # Commit changes
+ subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Add profile"],
+ cwd=temp_git_repo,
+ capture_output=True,
+ )
+
+ # Mock shutil.copy2 to raise PermissionError for profile file
+ def mock_copy2(src, dst):
+ if PROFILE_FILENAME in str(src):
+ raise PermissionError("Access denied")
+ return shutil.copy2(src, dst)
+
+ with patch("shutil.copy2", side_effect=mock_copy2):
+ # Setup workspace - should handle error gracefully
+ worktree_path, _, _ = setup_workspace(
+ temp_git_repo,
+ "test-spec",
+ WorkspaceMode.ISOLATED,
+ )
+
+ # Verify worktree was created despite permission error
+ assert worktree_path.exists()
+
+ # Verify warning was printed
+
+
+class TestMergeLockExceptionHandlingUnlink:
+ """Tests for MergeLock __exit__ exception handling during unlink (lines 136-137)."""
+
+ def test_merge_lock_exit_handles_unlink_exception(self, temp_git_repo: Path):
+ """MergeLock.__exit__ handles exceptions when unlink() fails (lines 136-137)."""
+ from unittest.mock import patch
+
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ # Enter the lock context
+ lock.__enter__()
+ assert lock.acquired is True
+ assert lock.lock_file.exists()
+
+ # Mock unlink to raise an exception
+ with patch.object(Path, "unlink", side_effect=OSError("Device read-only")):
+ # __exit__ should not raise despite unlink failure
+ lock.__exit__(None, None, None)
+
+ # Lock should still be marked as acquired because cleanup failed silently
+ assert lock.acquired is True
+
+ def test_merge_lock_exit_handles_permission_error(self, temp_git_repo: Path):
+ """MergeLock.__exit__ handles PermissionError when unlink() fails."""
+ from unittest.mock import patch
+
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ lock.__enter__()
+ assert lock.acquired is True
+
+ # Mock unlink to raise PermissionError
+ with patch.object(Path, "unlink", side_effect=PermissionError("Access denied")):
+ # Should not raise
+ lock.__exit__(None, None, None)
+
+ def test_merge_lock_exit_handles_lock_file_becoming_directory(
+ self, temp_git_repo: Path
+ ):
+ """MergeLock.__exit__ handles when lock file becomes a directory (race condition)."""
+ lock = MergeLock(temp_git_repo, "test-spec")
+
+ lock.__enter__()
+ assert lock.acquired is True
+
+ # Simulate race: lock file becomes a directory
+ lock.lock_file.unlink()
+ lock.lock_file.mkdir()
+
+ # unlink() on a directory raises OSError/IsADirectoryError
+ # __exit__ should handle this gracefully
+ lock.__exit__(None, None, None)
+
+ # Cleanup the directory
+ lock.lock_file.rmdir()
+
+
+class TestSpecNumberLockExceptionHandlingUnlink:
+ """Tests for SpecNumberLock __exit__ exception handling during unlink (lines 225-226)."""
+
+ def test_spec_number_lock_exit_handles_unlink_exception(self, temp_git_repo: Path):
+ """SpecNumberLock.__exit__ handles exceptions when unlink() fails (lines 225-226)."""
+ from unittest.mock import patch
+
+ lock = SpecNumberLock(temp_git_repo)
+
+ lock.__enter__()
+ assert lock.acquired is True
+ assert lock.lock_file.exists()
+
+ # Mock unlink to raise an exception
+ with patch.object(Path, "unlink", side_effect=OSError("Device read-only")):
+ # __exit__ should not raise despite unlink failure
+ lock.__exit__(None, None, None)
+
+ def test_spec_number_lock_exit_handles_permission_error(self, temp_git_repo: Path):
+ """SpecNumberLock.__exit__ handles PermissionError when unlink() fails."""
+ from unittest.mock import patch
+
+ lock = SpecNumberLock(temp_git_repo)
+
+ lock.__enter__()
+ assert lock.acquired is True
+
+ # Mock unlink to raise PermissionError
+ with patch.object(Path, "unlink", side_effect=PermissionError("Access denied")):
+ # Should not raise
+ lock.__exit__(None, None, None)
+
+ def test_spec_number_lock_exit_handles_lock_file_becoming_directory(
+ self, temp_git_repo: Path
+ ):
+ """SpecNumberLock.__exit__ handles when lock file becomes a directory (race condition)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ lock.__enter__()
+ assert lock.acquired is True
+
+ # Simulate race: lock file becomes a directory
+ lock.lock_file.unlink()
+ lock.lock_file.mkdir()
+
+ # unlink() on a directory raises OSError/IsADirectoryError
+ # __exit__ should handle this gracefully
+ lock.__exit__(None, None, None)
+
+ # Cleanup the directory
+ lock.lock_file.rmdir()
+
+
+class TestSpecNumberLockScanExceptionHandling:
+ """Tests for _scan_specs_dir exception handling (lines 272-273)."""
+
+ def test_scan_specs_dir_handles_invalid_folder_names(self, temp_git_repo: Path):
+ """_scan_specs_dir handles folders with non-numeric prefixes (lines 272-273)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ # Create specs with invalid names that trigger ValueError
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # These will cause ValueError when trying to int(folder.name[:3])
+ invalid_names = ["abc", "xyz", "invalid-name"]
+ for name in invalid_names:
+ (specs_dir / name).mkdir()
+
+ # Create valid specs
+ (specs_dir / "001-valid").mkdir()
+ (specs_dir / "100-another").mkdir()
+
+ with lock:
+ # Should not raise ValueError, should skip invalid folders
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should only count valid specs
+ assert result == 100
+
+ def test_scan_specs_dir_handles_malformed_number_prefix(self, temp_git_repo: Path):
+ """_scan_specs_dir handles folder names with non-digit characters in prefix."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Create folder that starts with digits but has non-digit in prefix
+ # This will fail the int() conversion
+ (specs_dir / "1a-bad").mkdir()
+ (specs_dir / "9!-bad").mkdir()
+
+ # Create valid specs
+ (specs_dir / "050-good").mkdir()
+
+ with lock:
+ # Should handle malformed prefixes gracefully
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should only count valid specs
+ assert result == 50
+
+ def test_scan_specs_dir_handles_short_folder_names(self, temp_git_repo: Path):
+ """_scan_specs_dir handles folder names shorter than 3 characters."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Edge case: folder with less than 3 chars
+ # name[:3] will be less than 3 chars, but int() may still work if it's numeric
+ (specs_dir / "12").mkdir()
+
+ # Edge case: very large number
+ (specs_dir / "999-very-large").mkdir()
+
+ # Valid specs
+ (specs_dir / "001-first").mkdir()
+
+ with lock:
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should handle all cases and return max
+ assert result == 999
+
+ def test_scan_specs_dir_handles_unexpected_folder_names(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """_scan_specs_dir handles ValueError when glob returns unexpected folder names (lines 272-273)."""
+ lock = SpecNumberLock(temp_git_repo)
+
+ specs_dir = temp_git_repo / ".auto-claude" / "specs"
+ specs_dir.mkdir(parents=True)
+
+ # Create a folder that matches the glob pattern visually
+ # but we'll mock glob to return a folder that triggers ValueError
+ (specs_dir / "001-valid").mkdir()
+
+ # Create fake Path objects that will cause ValueError in int()
+ from pathlib import Path
+ from unittest.mock import MagicMock
+
+ fake_folder = MagicMock()
+ fake_folder.name = "XYZ-invalid" # Non-numeric prefix
+
+ # Mock glob to return both valid and invalid folders
+ original_glob = specs_dir.glob
+
+ def mock_glob(pattern):
+ # Return the actual valid folder plus our fake one
+ real_results = list(original_glob(pattern))
+ return real_results + [fake_folder]
+
+ monkeypatch.setattr(Path, "glob", lambda self, pattern: mock_glob(pattern))
+
+ with lock:
+ # Should not raise ValueError, should skip invalid folder
+ result = lock._scan_specs_dir(specs_dir)
+
+ # Should still find the valid spec
+ assert result == 1
+
+
+class TestSetupDebugFallback:
+ """Tests for debug fallback functions in setup.py (lines 35-43)."""
+
+ def test_debug_fallback_no_op(self, monkeypatch):
+ """Fallback debug function is no-op when debug module not available (lines 39-40)."""
+ # Remove debug module from sys.modules to trigger fallback
+ import importlib
+ import sys
+
+ debug_module = sys.modules.pop("debug", None)
+
+ # Force reload of setup module to trigger fallback path
+ if "core.workspace.setup" in sys.modules:
+ del sys.modules["core.workspace.setup"]
+
+ try:
+ from core.workspace.setup import debug, debug_warning
+
+ # Both functions should be no-ops (don't raise)
+ debug("test", "message")
+ debug_warning("test", "warning")
+
+ # No exception means fallback is working
+ assert True
+ finally:
+ # Restore debug module
+ if debug_module is not None:
+ sys.modules["debug"] = debug_module
+ # Force reload again to restore normal state
+ if "core.workspace.setup" in sys.modules:
+ del sys.modules["core.workspace.setup"]
+ import importlib
+
+ importlib.reload(importlib.import_module("core.workspace.setup"))
+
+ def test_debug_import_error_creates_fallback(self, monkeypatch):
+ """ImportError in debug import creates fallback functions (lines 35-43)."""
+ import builtins
+ import importlib
+ import sys
+
+ # Save original debug module and import function
+ original_debug = sys.modules.get("debug")
+ original_import = builtins.__import__
+
+ # Create a custom import that blocks 'debug' module
+ def debug_blocking_import(name, *args, **kwargs):
+ if name == "debug":
+ raise ImportError("debug module not found (simulated)")
+ return original_import(name, *args, **kwargs)
+
+ try:
+ # Block debug import and remove from sys.modules
+ monkeypatch.setattr(builtins, "__import__", debug_blocking_import)
+ if "debug" in sys.modules:
+ del sys.modules["debug"]
+
+ # Also remove setup module and related modules to force re-import
+ for module_name in list(sys.modules.keys()):
+ if module_name.startswith("core.workspace.setup"):
+ del sys.modules[module_name]
+
+ # Re-import setup module - it should create fallback functions
+ setup_module = importlib.import_module("core.workspace.setup")
+
+ # Check that debug functions exist and are callables
+ assert hasattr(setup_module, "debug")
+ assert hasattr(setup_module, "debug_warning")
+ assert callable(setup_module.debug)
+ assert callable(setup_module.debug_warning)
+
+ # They should be no-ops (accept any args without error)
+ setup_module.debug("module", "message", "extra")
+ setup_module.debug_warning("module", "warning", key="value")
+ finally:
+ # Restore debug module
+ if original_debug is not None:
+ sys.modules["debug"] = original_debug
+ # Restore setup module
+ if "core.workspace.setup" in sys.modules:
+ del sys.modules["core.workspace.setup"]
+ importlib.reload(importlib.import_module("core.workspace.setup"))
+
+
+class TestWindowsJunctionErrorHandling:
+ """Tests for Windows junction creation error handling (lines 256-262)."""
+
+ def test_windows_junction_creation_error_handling(
+ self, temp_git_repo: Path, monkeypatch, capsys
+ ):
+ """Handles OSError when mklink fails on Windows (lines 256-262, 269-281)."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Only test on Windows or when we can mock the platform
+ if sys.platform != "win32":
+ # Mock platform to simulate Windows
+ monkeypatch.setattr("sys.platform", "win32")
+
+ # Create source node_modules directory
+ source_node_modules = temp_git_repo / "node_modules"
+ source_node_modules.mkdir()
+ (source_node_modules / "test-package").mkdir()
+
+ # Create worktree path
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Mock subprocess.run to simulate mklink failure
+ original_run = subprocess.run
+
+ def mock_subprocess_run(cmd, **kwargs):
+ if "mklink" in " ".join(cmd):
+ # Simulate mklink failure
+ return subprocess.CompletedProcess(
+ cmd, returncode=1, stderr="Access is denied"
+ )
+ return original_run(cmd, **kwargs)
+
+ monkeypatch.setattr("subprocess.run", mock_subprocess_run)
+
+ # Call the function - should handle error gracefully
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # Verify no symlinks were created (due to error)
+ assert len(symlinked) == 0
+
+ # Verify warning was printed
+ captured = capsys.readouterr()
+ assert "Warning" in captured.out or "warning" in captured.out.lower()
+
+ def test_windows_junction_osexception_continues(
+ self, temp_git_repo: Path, monkeypatch
+ ):
+ """Continues after OSError in junction creation (lines 261-262, 269-281)."""
+ from core.workspace.setup import symlink_node_modules_to_worktree
+
+ # Mock Windows platform
+ original_platform = sys.platform
+ monkeypatch.setattr("sys.platform", "win32")
+
+ try:
+ # Create source and worktree directories
+ source_node_modules = temp_git_repo / "node_modules"
+ source_node_modules.mkdir()
+
+ worktree_path = (
+ temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
+ )
+ worktree_path.mkdir(parents=True)
+
+ # Create a second source to test that function continues after error
+ source_frontend_modules = (
+ temp_git_repo / "apps" / "frontend" / "node_modules"
+ )
+ source_frontend_modules.mkdir(parents=True)
+
+ # Mock subprocess.run to fail on first, succeed on second
+ call_count = [0]
+ original_run = subprocess.run
+
+ def mock_subprocess_run(cmd, **kwargs):
+ call_count[0] += 1
+ if "mklink" in " ".join(cmd) and call_count[0] == 1:
+ # First mklink fails
+ raise OSError("mklink /J failed")
+ elif "mklink" in " ".join(cmd):
+ # Second succeeds
+ return subprocess.CompletedProcess(cmd, returncode=0, stderr="")
+ return original_run(cmd, **kwargs)
+
+ monkeypatch.setattr("subprocess.run", mock_subprocess_run)
+
+ # Call the function - should continue after first error
+ symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
+
+ # At least one symlink should have succeeded (or both failed gracefully)
+ # The important thing is the function didn't crash
+ assert isinstance(symlinked, list)
+ finally:
+ monkeypatch.setattr("sys.platform", original_platform)
+
+
+class TestTimelineHookInstallationEdgeCases:
+ """Tests for timeline hook installation edge cases (lines 461-503)."""
+
+ def setup_method(self):
+ """Reset the global hook check flag before each test."""
+ import core.workspace.setup as setup_module
+
+ setup_module._git_hook_check_done = False
+
+ def test_hook_installation_skips_when_no_git_dir(self, temp_dir: Path, monkeypatch):
+ """Skips hook installation when .git directory doesn't exist (line 477)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # temp_dir is not a git repo
+ assert not (temp_dir / ".git").exists()
+
+ # Should return early without error
+ ensure_timeline_hook_installed(temp_dir)
+
+ # No .git directory should have been created
+ assert not (temp_dir / ".git").exists()
+
+ def test_hook_installation_handles_worktree_invalid_git_file(self, tmp_path):
+ """Handles worktrees with invalid .git file content (lines 481-485)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create a fake worktree directory (not a real git repo)
+ fake_worktree = tmp_path / "fake_worktree"
+ fake_worktree.mkdir()
+
+ # Create .git as a FILE with invalid content (worktree style)
+ git_file = fake_worktree / ".git"
+ git_file.write_text(
+ "invalid content that doesn't start with gitdir:", encoding="utf-8"
+ )
+
+ # Should handle gracefully and return early
+ ensure_timeline_hook_installed(fake_worktree)
+
+ # Verify the file wasn't modified
+ assert "invalid content" in git_file.read_text(encoding="utf-8")
+
+ def test_hook_installation_worktree_gitdir_extraction(self, tmp_path):
+ """Extracts gitdir from worktree .git file correctly (lines 481-483)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create a fake worktree structure
+ # First, create the actual git dir in a different location
+ actual_git_dir = tmp_path / "actual_git_dir"
+ actual_git_dir.mkdir()
+ (actual_git_dir / "hooks").mkdir()
+
+ # Create a fake worktree directory with .git as a FILE
+ fake_worktree = tmp_path / "fake_worktree"
+ fake_worktree.mkdir()
+ git_file = fake_worktree / ".git"
+ git_file.write_text(f"gitdir: {actual_git_dir}", encoding="utf-8")
+
+ # Should correctly extract gitdir path
+ ensure_timeline_hook_installed(fake_worktree)
+
+ # Verify the actual git dir has hooks directory
+ assert (actual_git_dir / "hooks").exists()
+
+ def test_hook_installation_skips_when_hook_already_installed(
+ self, tmp_path, monkeypatch
+ ):
+ """Skips installation when FileTimelineTracker hook already exists (lines 491-493)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create a git directory structure
+ git_dir = tmp_path / ".git"
+ git_dir.mkdir()
+ hooks_dir = git_dir / "hooks"
+ hooks_dir.mkdir()
+
+ # Create an existing hook with FileTimelineTracker marker
+ hook_path = hooks_dir / "post-commit"
+ hook_path.write_text(
+ "#!/bin/bash\n# FileTimelineTracker hook\necho 'Timeline tracking'\n",
+ encoding="utf-8",
+ )
+
+ # Mock install_hook to verify it's NOT called
+ install_hook_called = []
+
+ def mock_install_hook(project_dir):
+ install_hook_called.append(project_dir)
+
+ # Patch the import location where install_hook is used
+ monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
+
+ # Should skip installation
+ ensure_timeline_hook_installed(tmp_path)
+
+ # install_hook should NOT have been called
+ assert len(install_hook_called) == 0
+
+ def test_hook_installation_handles_exceptions_gracefully(
+ self, tmp_path, monkeypatch
+ ):
+ """Handles exceptions during hook installation gracefully (lines 501-503)."""
+ from core.workspace.setup import ensure_timeline_hook_installed
+
+ # Create a git directory structure
+ git_dir = tmp_path / ".git"
+ git_dir.mkdir()
+ hooks_dir = git_dir / "hooks"
+ hooks_dir.mkdir()
+
+ # Mock install_hook to raise an exception
+ def mock_install_hook(project_dir):
+ raise RuntimeError("Simulated installation failure")
+
+ # Patch the import location where install_hook is used
+ monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
+
+ # Should handle exception gracefully (not crash)
+ ensure_timeline_hook_installed(tmp_path)
+
+ # Function should complete without raising an exception
diff --git a/apps/backend/integrations/graphiti/test_graphiti_memory.py b/apps/backend/integrations/graphiti/test_graphiti_memory.py
deleted file mode 100644
index b2a9a875..00000000
--- a/apps/backend/integrations/graphiti/test_graphiti_memory.py
+++ /dev/null
@@ -1,720 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test Script for Memory Integration with LadybugDB
-=================================================
-
-This script tests the memory layer (graph + semantic search) to verify
-data is being saved and retrieved correctly from LadybugDB (embedded Kuzu).
-
-LadybugDB is an embedded graph database - no Docker required!
-
-Usage:
- # Set environment variables first (or in .env file):
- export GRAPHITI_ENABLED=true
- export GRAPHITI_EMBEDDER_PROVIDER=ollama # or: openai, voyage, azure_openai, google
-
- # For Ollama (recommended - free, local):
- export OLLAMA_EMBEDDING_MODEL=embeddinggemma
- export OLLAMA_EMBEDDING_DIM=768
-
- # For OpenAI:
- export OPENAI_API_KEY=sk-...
-
- # Run the test:
- cd auto-claude
- python integrations/graphiti/test_graphiti_memory.py
-
- # Or run specific tests:
- python integrations/graphiti/test_graphiti_memory.py --test connection
- python integrations/graphiti/test_graphiti_memory.py --test save
- python integrations/graphiti/test_graphiti_memory.py --test search
- python integrations/graphiti/test_graphiti_memory.py --test ollama
-"""
-
-import argparse
-import asyncio
-import json
-import os
-import sys
-from datetime import datetime, timezone
-from pathlib import Path
-
-# Add auto-claude to path
-auto_claude_dir = Path(__file__).parent.parent.parent
-sys.path.insert(0, str(auto_claude_dir))
-
-# Load .env file
-try:
- from dotenv import load_dotenv
-
- env_file = auto_claude_dir / ".env"
- if env_file.exists():
- load_dotenv(env_file)
- print(f"Loaded .env from {env_file}")
-except ImportError:
- print("Note: python-dotenv not installed, using environment variables only")
-
-
-def apply_ladybug_monkeypatch():
- """Apply LadybugDB monkeypatch for embedded database support."""
- try:
- import real_ladybug
-
- sys.modules["kuzu"] = real_ladybug
- return True
- except ImportError:
- pass
-
- # Try native kuzu as fallback
- try:
- import kuzu # noqa: F401
-
- return True
- except ImportError:
- return False
-
-
-def print_header(title: str):
- """Print a section header."""
- print("\n" + "=" * 60)
- print(f" {title}")
- print("=" * 60 + "\n")
-
-
-def print_result(label: str, value: str, success: bool = True):
- """Print a result line."""
- status = "✅" if success else "❌"
- print(f" {status} {label}: {value}")
-
-
-def print_info(message: str):
- """Print an info line."""
- print(f" ℹ️ {message}")
-
-
-async def test_ladybugdb_connection(db_path: str, database: str) -> bool:
- """Test basic LadybugDB connection."""
- print_header("1. Testing LadybugDB Connection")
-
- print(f" Database path: {db_path}")
- print(f" Database name: {database}")
- print()
-
- if not apply_ladybug_monkeypatch():
- print_result("LadybugDB", "Not installed (pip install real-ladybug)", False)
- return False
-
- print_result("LadybugDB", "Installed", True)
-
- try:
- import kuzu # This is real_ladybug via monkeypatch
-
- # Ensure parent directory exists (database will create its own structure)
- full_path = Path(db_path) / database
- full_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Create database and connection
- db = kuzu.Database(str(full_path))
- conn = kuzu.Connection(db)
-
- # Test basic query
- result = conn.execute("RETURN 1 + 1 as test")
- df = result.get_as_df()
- test_value = df["test"].iloc[0] if len(df) > 0 else None
-
- if test_value == 2:
- print_result("Connection", "SUCCESS - Database responds correctly", True)
- return True
- else:
- print_result("Connection", f"Unexpected result: {test_value}", False)
- return False
-
- except Exception as e:
- print_result("Connection", f"FAILED: {e}", False)
- return False
-
-
-async def test_save_episode(db_path: str, database: str) -> tuple[str, str]:
- """Test saving an episode to the graph."""
- print_header("2. Testing Episode Save")
-
- try:
- from integrations.graphiti.config import GraphitiConfig
- from integrations.graphiti.queries_pkg.client import GraphitiClient
-
- # Create config
- config = GraphitiConfig.from_env()
- config.db_path = db_path
- config.database = database
- config.enabled = True
-
- print(f" Embedder provider: {config.embedder_provider}")
- print()
-
- # Initialize client
- client = GraphitiClient(config)
- initialized = await client.initialize()
-
- if not initialized:
- print_result("Client Init", "Failed to initialize", False)
- return None, None
-
- print_result("Client Init", "SUCCESS", True)
-
- # Create test episode data
- test_data = {
- "type": "test_episode",
- "timestamp": datetime.now(timezone.utc).isoformat(),
- "test_field": "Hello from LadybugDB test!",
- "test_number": 42,
- "embedder": config.embedder_provider,
- }
-
- episode_name = f"test_episode_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
- group_id = "ladybug_test_group"
-
- print(f" Episode name: {episode_name}")
- print(f" Group ID: {group_id}")
- print(f" Data: {json.dumps(test_data, indent=4)}")
- print()
-
- # Save using Graphiti
- from graphiti_core.nodes import EpisodeType
-
- print(" Saving episode...")
- await client.graphiti.add_episode(
- name=episode_name,
- episode_body=json.dumps(test_data),
- source=EpisodeType.text,
- source_description="Test episode from test_graphiti_memory.py",
- reference_time=datetime.now(timezone.utc),
- group_id=group_id,
- )
-
- print_result("Episode Save", "SUCCESS", True)
-
- await client.close()
- return episode_name, group_id
-
- except ImportError as e:
- print_result("Import", f"Missing dependency: {e}", False)
- return None, None
- except Exception as e:
- print_result("Episode Save", f"FAILED: {e}", False)
- import traceback
-
- traceback.print_exc()
- return None, None
-
-
-async def test_keyword_search(db_path: str, database: str) -> bool:
- """Test keyword search (works without embeddings)."""
- print_header("3. Testing Keyword Search")
-
- if not apply_ladybug_monkeypatch():
- print_result("LadybugDB", "Not installed", False)
- return False
-
- try:
- import kuzu
-
- full_path = Path(db_path) / database
- if not full_path.exists():
- print_info("Database doesn't exist yet - run save test first")
- return True
-
- db = kuzu.Database(str(full_path))
- conn = kuzu.Connection(db)
-
- # Search for test episodes
- search_query = "test"
- print(f" Search query: '{search_query}'")
- print()
-
- query = f"""
- MATCH (e:Episodic)
- WHERE toLower(e.name) CONTAINS '{search_query}'
- OR toLower(e.content) CONTAINS '{search_query}'
- RETURN e.name as name, e.content as content
- LIMIT 5
- """
-
- try:
- result = conn.execute(query)
- df = result.get_as_df()
-
- print(f" Found {len(df)} results:")
- for _, row in df.iterrows():
- name = row.get("name", "unknown")[:50]
- content = str(row.get("content", ""))[:60]
- print(f" - {name}: {content}...")
-
- print_result("Keyword Search", f"Found {len(df)} results", True)
- return True
-
- except Exception as e:
- if "Episodic" in str(e) and "not exist" in str(e).lower():
- print_info("Episodic table doesn't exist yet - run save test first")
- return True
- raise
-
- except Exception as e:
- print_result("Keyword Search", f"FAILED: {e}", False)
- return False
-
-
-async def test_semantic_search(db_path: str, database: str, group_id: str) -> bool:
- """Test semantic search using embeddings."""
- print_header("4. Testing Semantic Search")
-
- if not group_id:
- print_info("Skipping - no group_id from save test")
- return True
-
- try:
- from integrations.graphiti.config import GraphitiConfig
- from integrations.graphiti.queries_pkg.client import GraphitiClient
-
- # Create config
- config = GraphitiConfig.from_env()
- config.db_path = db_path
- config.database = database
- config.enabled = True
-
- if not config.embedder_provider:
- print_info("No embedder configured - semantic search requires embeddings")
- return True
-
- print(f" Embedder: {config.embedder_provider}")
- print()
-
- # Initialize client
- client = GraphitiClient(config)
- initialized = await client.initialize()
-
- if not initialized:
- print_result("Client Init", "Failed", False)
- return False
-
- # Search
- query = "test episode hello LadybugDB"
- print(f" Query: '{query}'")
- print(f" Group ID: {group_id}")
- print()
-
- print(" Searching...")
- results = await client.graphiti.search(
- query=query,
- group_ids=[group_id],
- num_results=10,
- )
-
- print(f" Found {len(results)} results:")
- for i, result in enumerate(results[:5]):
- # Print available attributes
- if hasattr(result, "fact") and result.fact:
- print(f" {i + 1}. [fact] {str(result.fact)[:80]}...")
- elif hasattr(result, "content") and result.content:
- print(f" {i + 1}. [content] {str(result.content)[:80]}...")
- elif hasattr(result, "name"):
- print(f" {i + 1}. [name] {str(result.name)[:80]}...")
-
- await client.close()
-
- if results:
- print_result(
- "Semantic Search", f"SUCCESS - Found {len(results)} results", True
- )
- else:
- print_result(
- "Semantic Search", "No results (may need time for embedding)", False
- )
-
- return len(results) > 0
-
- except Exception as e:
- print_result("Semantic Search", f"FAILED: {e}", False)
- import traceback
-
- traceback.print_exc()
- return False
-
-
-async def test_ollama_embeddings() -> bool:
- """Test Ollama embedding generation directly."""
- print_header("5. Testing Ollama Embeddings")
-
- ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
- ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
-
- print(f" Model: {ollama_model}")
- print(f" Base URL: {ollama_base_url}")
- print()
-
- try:
- import requests
-
- # Check Ollama status
- print(" Checking Ollama status...")
- try:
- resp = requests.get(f"{ollama_base_url}/api/tags", timeout=5)
- if resp.status_code != 200:
- print_result(
- "Ollama", f"Not responding (status {resp.status_code})", False
- )
- return False
-
- models = [m["name"] for m in resp.json().get("models", [])]
- embedding_models = [
- m for m in models if "embed" in m.lower() or "gemma" in m.lower()
- ]
- print_result("Ollama", f"Running with {len(models)} models", True)
- print(f" Embedding models: {embedding_models}")
-
- except requests.exceptions.ConnectionError:
- print_result("Ollama", "Not running - start with 'ollama serve'", False)
- return False
-
- # Test embedding generation
- print()
- print(" Generating test embedding...")
-
- test_text = (
- "This is a test embedding for Auto Claude memory system using LadybugDB."
- )
-
- resp = requests.post(
- f"{ollama_base_url}/api/embeddings",
- json={"model": ollama_model, "prompt": test_text},
- timeout=30,
- )
-
- if resp.status_code == 200:
- data = resp.json()
- embedding = data.get("embedding", [])
- print_result("Embedding", f"SUCCESS - {len(embedding)} dimensions", True)
- print(f" First 5 values: {embedding[:5]}")
-
- # Verify dimension matches config
- expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", 768))
- if len(embedding) == expected_dim:
- print_result("Dimension", f"Matches expected ({expected_dim})", True)
- else:
- print_result(
- "Dimension",
- f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
- False,
- )
- print_info(
- f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config"
- )
-
- return True
- else:
- print_result(
- "Embedding", f"FAILED: {resp.status_code} - {resp.text}", False
- )
- return False
-
- except ImportError:
- print_result("requests", "Not installed (pip install requests)", False)
- return False
- except Exception as e:
- print_result("Ollama Embeddings", f"FAILED: {e}", False)
- return False
-
-
-async def test_graphiti_memory_class(db_path: str, database: str) -> bool:
- """Test the GraphitiMemory wrapper class."""
- print_header("6. Testing GraphitiMemory Class")
-
- try:
- from integrations.graphiti.memory import GraphitiMemory
-
- # Create temporary directories for testing
- test_spec_dir = Path("/tmp/graphiti_test_spec")
- test_spec_dir.mkdir(parents=True, exist_ok=True)
-
- test_project_dir = Path("/tmp/graphiti_test_project")
- test_project_dir.mkdir(parents=True, exist_ok=True)
-
- print(f" Spec dir: {test_spec_dir}")
- print(f" Project dir: {test_project_dir}")
- print()
-
- # Override database path via environment
- os.environ["GRAPHITI_DB_PATH"] = db_path
- os.environ["GRAPHITI_DATABASE"] = database
-
- # Create memory instance
- memory = GraphitiMemory(test_spec_dir, test_project_dir)
-
- print(f" Is enabled: {memory.is_enabled}")
- print(f" Group ID: {memory.group_id}")
- print()
-
- if not memory.is_enabled:
- print_info("GraphitiMemory not enabled - check GRAPHITI_ENABLED=true")
- return True
-
- # Initialize
- print(" Initializing...")
- init_result = await memory.initialize()
-
- if not init_result:
- print_result("Initialize", "Failed", False)
- return False
-
- print_result("Initialize", "SUCCESS", True)
-
- # Test save_session_insights
- print()
- print(" Testing save_session_insights...")
- insights = {
- "subtasks_completed": ["test-subtask-1"],
- "discoveries": {
- "files_understood": {"test.py": "Test file"},
- "patterns_found": ["Pattern: LadybugDB works!"],
- "gotchas_encountered": [],
- },
- "what_worked": ["Using embedded database"],
- "what_failed": [],
- "recommendations_for_next_session": ["Continue testing"],
- }
-
- save_result = await memory.save_session_insights(
- session_num=1, insights=insights
- )
- print_result(
- "save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
- )
-
- # Test save_pattern
- print()
- print(" Testing save_pattern...")
- pattern_result = await memory.save_pattern(
- "LadybugDB pattern: Embedded graph database works without Docker"
- )
- print_result(
- "save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
- )
-
- # Test get_relevant_context
- print()
- print(" Testing get_relevant_context...")
- await asyncio.sleep(1) # Brief wait for processing
-
- context = await memory.get_relevant_context("LadybugDB embedded database")
- print(f" Found {len(context)} context items")
-
- for item in context[:3]:
- item_type = item.get("type", "unknown")
- content = str(item.get("content", ""))[:60]
- print(f" - [{item_type}] {content}...")
-
- print_result("get_relevant_context", f"Found {len(context)} items", True)
-
- # Get status
- print()
- print(" Status summary:")
- status = memory.get_status_summary()
- for key, value in status.items():
- print(f" {key}: {value}")
-
- await memory.close()
- print_result("GraphitiMemory", "All tests passed", True)
- return True
-
- except ImportError as e:
- print_result("Import", f"Missing: {e}", False)
- return False
- except Exception as e:
- print_result("GraphitiMemory", f"FAILED: {e}", False)
- import traceback
-
- traceback.print_exc()
- return False
-
-
-async def test_database_contents(db_path: str, database: str) -> bool:
- """Show what's in the database (debug)."""
- print_header("7. Database Contents (Debug)")
-
- if not apply_ladybug_monkeypatch():
- print_result("LadybugDB", "Not installed", False)
- return False
-
- try:
- import kuzu
-
- full_path = Path(db_path) / database
- if not full_path.exists():
- print_info(f"Database doesn't exist at {full_path}")
- return True
-
- db = kuzu.Database(str(full_path))
- conn = kuzu.Connection(db)
-
- # Get table info
- print(" Checking tables...")
-
- tables_to_check = ["Episodic", "Entity", "Community"]
-
- for table in tables_to_check:
- try:
- result = conn.execute(f"MATCH (n:{table}) RETURN count(n) as count")
- df = result.get_as_df()
- count = df["count"].iloc[0] if len(df) > 0 else 0
- print(f" {table}: {count} nodes")
- except Exception as e:
- if "not exist" in str(e).lower() or "cannot" in str(e).lower():
- print(f" {table}: (table not created yet)")
- else:
- print(f" {table}: Error - {e}")
-
- # Show sample episodic nodes
- print()
- print(" Sample Episodic nodes:")
- try:
- result = conn.execute("""
- MATCH (e:Episodic)
- RETURN e.name as name, e.created_at as created
- ORDER BY e.created_at DESC
- LIMIT 5
- """)
- df = result.get_as_df()
-
- if len(df) == 0:
- print(" (none)")
- else:
- for _, row in df.iterrows():
- print(f" - {row.get('name', 'unknown')}")
- except Exception as e:
- if "Episodic" in str(e):
- print(" (table not created yet)")
- else:
- print(f" Error: {e}")
-
- print_result("Database Contents", "Displayed", True)
- return True
-
- except Exception as e:
- print_result("Database Contents", f"FAILED: {e}", False)
- return False
-
-
-async def main():
- """Run all tests."""
- parser = argparse.ArgumentParser(description="Test Memory System with LadybugDB")
- parser.add_argument(
- "--test",
- choices=[
- "all",
- "connection",
- "save",
- "keyword",
- "semantic",
- "ollama",
- "memory",
- "contents",
- ],
- default="all",
- help="Which test to run",
- )
- parser.add_argument(
- "--db-path",
- default=os.path.expanduser("~/.auto-claude/memories"),
- help="Database path",
- )
- parser.add_argument(
- "--database",
- default="test_memory",
- help="Database name (use 'test_memory' for testing)",
- )
-
- args = parser.parse_args()
-
- print("\n" + "=" * 60)
- print(" MEMORY SYSTEM TEST SUITE (LadybugDB)")
- print("=" * 60)
-
- # Configuration check
- print_header("0. Configuration Check")
-
- print(f" Database path: {args.db_path}")
- print(f" Database name: {args.database}")
- print()
-
- # Check environment
- graphiti_enabled = os.environ.get("GRAPHITI_ENABLED", "").lower() == "true"
- embedder_provider = os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", "")
-
- print_result("GRAPHITI_ENABLED", str(graphiti_enabled), graphiti_enabled)
- print_result(
- "GRAPHITI_EMBEDDER_PROVIDER",
- embedder_provider or "(not set)",
- bool(embedder_provider),
- )
-
- if embedder_provider == "ollama":
- ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "")
- ollama_dim = os.environ.get("OLLAMA_EMBEDDING_DIM", "")
- print_result(
- "OLLAMA_EMBEDDING_MODEL", ollama_model or "(not set)", bool(ollama_model)
- )
- print_result(
- "OLLAMA_EMBEDDING_DIM", ollama_dim or "(not set)", bool(ollama_dim)
- )
- elif embedder_provider == "openai":
- has_key = bool(os.environ.get("OPENAI_API_KEY"))
- print_result("OPENAI_API_KEY", "Set" if has_key else "Not set", has_key)
-
- # Run tests based on selection
- test = args.test
- group_id = None
-
- if test in ["all", "connection"]:
- await test_ladybugdb_connection(args.db_path, args.database)
-
- if test in ["all", "ollama"]:
- await test_ollama_embeddings()
-
- if test in ["all", "save"]:
- _, group_id = await test_save_episode(args.db_path, args.database)
- if group_id:
- print("\n Waiting 2 seconds for embedding processing...")
- await asyncio.sleep(2)
-
- if test in ["all", "keyword"]:
- await test_keyword_search(args.db_path, args.database)
-
- if test in ["all", "semantic"]:
- await test_semantic_search(
- args.db_path, args.database, group_id or "ladybug_test_group"
- )
-
- if test in ["all", "memory"]:
- await test_graphiti_memory_class(args.db_path, args.database)
-
- if test in ["all", "contents"]:
- await test_database_contents(args.db_path, args.database)
-
- print_header("TEST SUMMARY")
- print(" Tests completed. Check the results above for any failures.")
- print()
- print(" Quick commands:")
- print(" # Run all tests:")
- print(" python integrations/graphiti/test_graphiti_memory.py")
- print()
- print(" # Test just Ollama embeddings:")
- print(" python integrations/graphiti/test_graphiti_memory.py --test ollama")
- print()
- print(" # Test with production database:")
- print(
- " python integrations/graphiti/test_graphiti_memory.py --database auto_claude_memory"
- )
- print()
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/apps/backend/integrations/graphiti/test_ollama_embedding_memory.py b/apps/backend/integrations/graphiti/test_ollama_embedding_memory.py
deleted file mode 100644
index cc5b1efa..00000000
--- a/apps/backend/integrations/graphiti/test_ollama_embedding_memory.py
+++ /dev/null
@@ -1,862 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test Script for Ollama Embedding Memory Integration
-====================================================
-
-This test validates that the memory system works correctly with local Ollama
-embedding models (like embeddinggemma, nomic-embed-text) for creating and
-retrieving memories in the hybrid RAG system.
-
-The test covers:
-1. Ollama embedding generation (direct API test)
-2. Creating memories with Ollama embeddings via GraphitiMemory
-3. Retrieving memories via semantic search
-4. Verifying the full create → store → retrieve cycle
-
-Prerequisites:
- 1. Install Ollama: https://ollama.ai/
- 2. Pull an embedding model:
- ollama pull embeddinggemma # 768 dimensions (lightweight)
- ollama pull nomic-embed-text # 768 dimensions (good quality)
- 3. Pull an LLM model (for knowledge graph construction):
- ollama pull deepseek-r1:7b # or llama3.2:3b, mistral:7b
- 4. Start Ollama server: ollama serve
- 5. Configure environment:
- export GRAPHITI_ENABLED=true
- export GRAPHITI_LLM_PROVIDER=ollama
- export GRAPHITI_EMBEDDER_PROVIDER=ollama
- export OLLAMA_LLM_MODEL=deepseek-r1:7b
- export OLLAMA_EMBEDDING_MODEL=embeddinggemma
- export OLLAMA_EMBEDDING_DIM=768
-
-NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
- For full offline operation, set a dummy key: export OPENAI_API_KEY=dummy
- The reranker will fail at search time, but embedding creation works.
- For production, use OpenAI API key for best search quality.
-
-Usage:
- cd apps/backend
- python integrations/graphiti/test_ollama_embedding_memory.py
-
- # Run specific tests:
- python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
- python integrations/graphiti/test_ollama_embedding_memory.py --test create
- python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
- python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
-"""
-
-import argparse
-import asyncio
-import os
-import shutil
-import sys
-import tempfile
-from datetime import datetime
-from pathlib import Path
-
-# Add auto-claude to path
-auto_claude_dir = Path(__file__).parent.parent.parent
-sys.path.insert(0, str(auto_claude_dir))
-
-# Load .env file
-try:
- from dotenv import load_dotenv
-
- env_file = auto_claude_dir / ".env"
- if env_file.exists():
- load_dotenv(env_file)
- print(f"Loaded .env from {env_file}")
-except ImportError:
- print("Note: python-dotenv not installed, using environment variables only")
-
-
-# ============================================================================
-# Helper Functions
-# ============================================================================
-
-
-def print_header(title: str):
- """Print a section header."""
- print("\n" + "=" * 70)
- print(f" {title}")
- print("=" * 70 + "\n")
-
-
-def print_result(label: str, value: str, success: bool = True):
- """Print a result line."""
- status = "PASS" if success else "FAIL"
- print(f" [{status}] {label}: {value}")
-
-
-def print_info(message: str):
- """Print an info line."""
- print(f" INFO: {message}")
-
-
-def print_step(step: int, message: str):
- """Print a step indicator."""
- print(f"\n Step {step}: {message}")
-
-
-def apply_ladybug_monkeypatch():
- """Apply LadybugDB monkeypatch for embedded database support."""
- try:
- import real_ladybug
-
- sys.modules["kuzu"] = real_ladybug
- return True
- except ImportError:
- pass
-
- # Try native kuzu as fallback
- try:
- import kuzu # noqa: F401
-
- return True
- except ImportError:
- return False
-
-
-# ============================================================================
-# Test 1: Ollama Embedding Generation
-# ============================================================================
-
-
-async def test_ollama_embeddings() -> bool:
- """
- Test Ollama embedding generation directly via API.
-
- This validates that Ollama is running and can generate embeddings
- with the configured model.
- """
- print_header("Test 1: Ollama Embedding Generation")
-
- ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
- ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
- expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "768"))
-
- print(f" Ollama Model: {ollama_model}")
- print(f" Base URL: {ollama_base_url}")
- print(f" Expected Dimension: {expected_dim}")
- print()
-
- try:
- import requests
- except ImportError:
- print_result("requests library", "Not installed - pip install requests", False)
- return False
-
- # Step 1: Check Ollama is running
- print_step(1, "Checking Ollama server status")
- try:
- resp = requests.get(f"{ollama_base_url}/api/tags", timeout=10)
- if resp.status_code != 200:
- print_result(
- "Ollama server",
- f"Not responding (status {resp.status_code})",
- False,
- )
- return False
-
- models = resp.json().get("models", [])
- model_names = [m.get("name", "") for m in models]
- print_result("Ollama server", f"Running with {len(models)} models", True)
-
- # Check if embedding model is available
- embedding_model_found = any(
- ollama_model in name or ollama_model.split(":")[0] in name
- for name in model_names
- )
- if not embedding_model_found:
- print_info(f"Model '{ollama_model}' not found. Available: {model_names}")
- print_info(f"Pull it with: ollama pull {ollama_model}")
-
- except requests.exceptions.ConnectionError:
- print_result(
- "Ollama server",
- "Not running - start with 'ollama serve'",
- False,
- )
- return False
-
- # Step 2: Generate test embedding
- print_step(2, "Generating test embeddings")
-
- test_texts = [
- "This is a test memory about implementing OAuth authentication.",
- "The user prefers using TypeScript for frontend development.",
- "A gotcha discovered: always validate JWT tokens on the server side.",
- ]
-
- embeddings = []
- for i, text in enumerate(test_texts):
- resp = requests.post(
- f"{ollama_base_url}/api/embeddings",
- json={"model": ollama_model, "prompt": text},
- timeout=60,
- )
-
- if resp.status_code != 200:
- print_result(
- f"Embedding {i + 1}",
- f"Failed: {resp.status_code} - {resp.text[:100]}",
- False,
- )
- return False
-
- data = resp.json()
- embedding = data.get("embedding", [])
- embeddings.append(embedding)
-
- print_result(
- f"Embedding {i + 1}",
- f"Generated {len(embedding)} dimensions",
- True,
- )
-
- # Step 3: Validate embedding dimensions
- print_step(3, "Validating embedding dimensions")
-
- for i, embedding in enumerate(embeddings):
- if len(embedding) != expected_dim:
- print_result(
- f"Embedding {i + 1} dimension",
- f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
- False,
- )
- print_info(f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config")
- return False
- print_result(
- f"Embedding {i + 1} dimension", f"{len(embedding)} matches expected", True
- )
-
- # Step 4: Test embedding similarity (basic sanity check)
- print_step(4, "Testing embedding similarity")
-
- def cosine_similarity(a, b):
- """Calculate cosine similarity between two vectors."""
- dot_product = sum(x * y for x, y in zip(a, b))
- norm_a = sum(x * x for x in a) ** 0.5
- norm_b = sum(x * x for x in b) ** 0.5
- return dot_product / (norm_a * norm_b) if norm_a and norm_b else 0
-
- # Generate embedding for a similar query
- query = "OAuth authentication implementation"
- resp = requests.post(
- f"{ollama_base_url}/api/embeddings",
- json={"model": ollama_model, "prompt": query},
- timeout=60,
- )
- query_embedding = resp.json().get("embedding", [])
-
- similarities = [cosine_similarity(query_embedding, emb) for emb in embeddings]
-
- print(f" Query: '{query}'")
- print(" Similarities to test texts:")
- for i, (text, sim) in enumerate(zip(test_texts, similarities)):
- print(f" {i + 1}. {sim:.4f} - '{text[:50]}...'")
-
- # First text (about OAuth) should have highest similarity to OAuth query
- if similarities[0] > similarities[1] and similarities[0] > similarities[2]:
- print_result("Semantic similarity", "OAuth query matches OAuth text best", True)
- else:
- print_info("Similarity ordering may vary - embeddings are still working")
-
- print()
- print_result("Ollama Embeddings", "All tests passed", True)
- return True
-
-
-# ============================================================================
-# Test 2: Memory Creation with Ollama
-# ============================================================================
-
-
-async def test_memory_creation(test_db_path: Path) -> tuple[Path, Path, bool]:
- """
- Test creating memories using GraphitiMemory with Ollama embeddings.
-
- Returns:
- Tuple of (spec_dir, project_dir, success)
- """
- print_header("Test 2: Memory Creation with Ollama Embeddings")
-
- # Create test directories
- spec_dir = test_db_path / "test_spec"
- project_dir = test_db_path / "test_project"
- spec_dir.mkdir(parents=True, exist_ok=True)
- project_dir.mkdir(parents=True, exist_ok=True)
-
- print(f" Spec dir: {spec_dir}")
- print(f" Project dir: {project_dir}")
- print(f" Database path: {test_db_path}")
- print()
-
- # Override database path for testing
- os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
- os.environ["GRAPHITI_DATABASE"] = "test_ollama_memory"
-
- try:
- from integrations.graphiti.memory import GraphitiMemory
- except ImportError as e:
- print_result("Import GraphitiMemory", f"Failed: {e}", False)
- return spec_dir, project_dir, False
-
- # Step 1: Initialize GraphitiMemory
- print_step(1, "Initializing GraphitiMemory")
-
- memory = GraphitiMemory(spec_dir, project_dir)
- print(f" Is enabled: {memory.is_enabled}")
- print(f" Group ID: {memory.group_id}")
-
- if not memory.is_enabled:
- print_result(
- "GraphitiMemory",
- "Not enabled - check GRAPHITI_ENABLED=true",
- False,
- )
- return spec_dir, project_dir, False
-
- init_result = await memory.initialize()
- if not init_result:
- print_result("Initialize", "Failed to initialize", False)
- return spec_dir, project_dir, False
-
- print_result("Initialize", "SUCCESS", True)
-
- # Step 2: Save session insights
- print_step(2, "Saving session insights")
-
- session_insights = {
- "subtasks_completed": ["implement-oauth-login", "add-jwt-validation"],
- "discoveries": {
- "files_understood": {
- "auth/oauth.py": "OAuth 2.0 flow implementation with Google/GitHub",
- "auth/jwt.py": "JWT token generation and validation utilities",
- },
- "patterns_found": [
- "Pattern: Use refresh tokens for long-lived sessions",
- "Pattern: Store tokens in httpOnly cookies for security",
- ],
- "gotchas_encountered": [
- "Gotcha: Always validate JWT signature on server side",
- "Gotcha: OAuth state parameter prevents CSRF attacks",
- ],
- },
- "what_worked": [
- "Using PyJWT for token handling",
- "Separating OAuth providers into individual modules",
- ],
- "what_failed": [],
- "recommendations_for_next_session": [
- "Consider adding refresh token rotation",
- "Add rate limiting to auth endpoints",
- ],
- }
-
- save_result = await memory.save_session_insights(
- session_num=1, insights=session_insights
- )
- print_result(
- "save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
- )
-
- # Step 3: Save patterns
- print_step(3, "Saving code patterns")
-
- patterns = [
- "OAuth implementation uses authorization code flow for web apps",
- "JWT tokens include user ID, roles, and expiration in payload",
- "Token refresh happens automatically when access token expires",
- ]
-
- for i, pattern in enumerate(patterns):
- result = await memory.save_pattern(pattern)
- print_result(f"save_pattern {i + 1}", "SUCCESS" if result else "FAILED", result)
-
- # Step 4: Save gotchas
- print_step(4, "Saving gotchas (pitfalls)")
-
- gotchas = [
- "Never store config values in frontend code or files checked into git",
- "API redirect URIs must exactly match the registered URIs",
- "Cache expiration times should be short for performance (15 min default)",
- ]
-
- for i, gotcha in enumerate(gotchas):
- result = await memory.save_gotcha(gotcha)
- print_result(f"save_gotcha {i + 1}", "SUCCESS" if result else "FAILED", result)
-
- # Step 5: Save codebase discoveries
- print_step(5, "Saving codebase discoveries")
-
- discoveries = {
- "api/routes/users.py": "User management API endpoints (list, create, update)",
- "middleware/logging.py": "Request logging middleware for all routes",
- "models/user.py": "User model with profile data and role management",
- "services/notifications.py": "Notification service integrations (email, SMS, push)",
- }
-
- discovery_result = await memory.save_codebase_discoveries(discoveries)
- print_result(
- "save_codebase_discoveries",
- "SUCCESS" if discovery_result else "FAILED",
- discovery_result,
- )
-
- # Brief wait for embedding processing
- print()
- print_info("Waiting 3 seconds for embedding processing...")
- await asyncio.sleep(3)
-
- await memory.close()
-
- print()
- print_result("Memory Creation", "All memories saved successfully", True)
- return spec_dir, project_dir, True
-
-
-# ============================================================================
-# Test 3: Memory Retrieval with Semantic Search
-# ============================================================================
-
-
-async def test_memory_retrieval(spec_dir: Path, project_dir: Path) -> bool:
- """
- Test retrieving memories using semantic search with Ollama embeddings.
-
- This validates that saved memories can be found via semantic similarity.
- """
- print_header("Test 3: Memory Retrieval with Semantic Search")
-
- try:
- from integrations.graphiti.memory import GraphitiMemory
- except ImportError as e:
- print_result("Import GraphitiMemory", f"Failed: {e}", False)
- return False
-
- # Step 1: Initialize memory (reconnect)
- print_step(1, "Reconnecting to GraphitiMemory")
-
- memory = GraphitiMemory(spec_dir, project_dir)
- init_result = await memory.initialize()
-
- if not init_result:
- print_result("Initialize", "Failed to reconnect", False)
- return False
-
- print_result("Initialize", "Reconnected successfully", True)
-
- # Step 2: Semantic search for API-related content
- print_step(2, "Searching for API-related memories")
-
- api_query = "How do the API endpoints work in this project?"
- results = await memory.get_relevant_context(api_query, num_results=5)
-
- print(f" Query: '{api_query}'")
- print(f" Found {len(results)} results:")
-
- api_found = False
- for i, result in enumerate(results):
- content = result.get("content", "")[:100]
- result_type = result.get("type", "unknown")
- score = result.get("score", 0)
- print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
- if "api" in content.lower() or "routes" in content.lower():
- api_found = True
-
- if api_found:
- print_result("API search", "Found API-related content", True)
- else:
- print_info("API content may not be in top results - checking other queries")
-
- # Step 3: Search for middleware-related content
- print_step(3, "Searching for middleware patterns")
-
- middleware_query = "middleware and request handling best practices"
- results = await memory.get_relevant_context(middleware_query, num_results=5)
-
- print(f" Query: '{middleware_query}'")
- print(f" Found {len(results)} results:")
-
- middleware_found = False
- for i, result in enumerate(results):
- content = result.get("content", "")[:100]
- result_type = result.get("type", "unknown")
- score = result.get("score", 0)
- print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
- if "middleware" in content.lower() or "routes" in content.lower():
- middleware_found = True
-
- print_result(
- "Middleware search",
- "Found middleware-related content" if middleware_found else "No direct matches",
- middleware_found or len(results) > 0,
- )
-
- # Step 4: Get session history
- print_step(4, "Retrieving session history")
-
- history = await memory.get_session_history(limit=3)
- print(f" Found {len(history)} session records:")
-
- for i, session in enumerate(history):
- session_num = session.get("session_number", "?")
- subtasks = session.get("subtasks_completed", [])
- print(f" Session {session_num}: {len(subtasks)} subtasks completed")
- for subtask in subtasks[:3]:
- print(f" - {subtask}")
-
- print_result(
- "Session history", f"Retrieved {len(history)} sessions", len(history) > 0
- )
-
- # Step 5: Get status summary
- print_step(5, "Memory status summary")
-
- status = memory.get_status_summary()
- for key, value in status.items():
- print(f" {key}: {value}")
-
- await memory.close()
-
- print()
- all_passed = len(results) > 0 and len(history) > 0
- print_result(
- "Memory Retrieval",
- "All retrieval tests passed" if all_passed else "Some tests had issues",
- all_passed,
- )
- return all_passed
-
-
-# ============================================================================
-# Test 4: Full Create → Store → Retrieve Cycle
-# ============================================================================
-
-
-async def test_full_cycle(test_db_path: Path) -> bool:
- """
- Test the complete memory lifecycle:
- 1. Create unique test data
- 2. Store in graph database with Ollama embeddings
- 3. Search and retrieve via semantic similarity
- 4. Verify retrieved data matches what was stored
- """
- print_header("Test 4: Full Create-Store-Retrieve Cycle")
-
- # Create fresh test directories
- spec_dir = test_db_path / "cycle_test_spec"
- project_dir = test_db_path / "cycle_test_project"
- spec_dir.mkdir(parents=True, exist_ok=True)
- project_dir.mkdir(parents=True, exist_ok=True)
-
- # Override database path for testing
- os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
- os.environ["GRAPHITI_DATABASE"] = "test_full_cycle"
-
- try:
- from integrations.graphiti.memory import GraphitiMemory
- except ImportError as e:
- print_result("Import", f"Failed: {e}", False)
- return False
-
- # Step 1: Create unique test content
- print_step(1, "Creating unique test content")
-
- unique_id = datetime.now().strftime("%Y%m%d_%H%M%S")
- unique_pattern = (
- f"Unique pattern {unique_id}: Use dependency injection for database connections"
- )
- unique_gotcha = f"Unique gotcha {unique_id}: Always close database connections in finally blocks"
-
- print(f" Unique ID: {unique_id}")
- print(f" Pattern: {unique_pattern[:60]}...")
- print(f" Gotcha: {unique_gotcha[:60]}...")
-
- # Step 2: Store the content
- print_step(2, "Storing content in memory system")
-
- memory = GraphitiMemory(spec_dir, project_dir)
- init_result = await memory.initialize()
-
- if not init_result:
- print_result("Initialize", "Failed", False)
- return False
-
- print_result("Initialize", "SUCCESS", True)
-
- pattern_result = await memory.save_pattern(unique_pattern)
- print_result(
- "save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
- )
-
- gotcha_result = await memory.save_gotcha(unique_gotcha)
- print_result("save_gotcha", "SUCCESS" if gotcha_result else "FAILED", gotcha_result)
-
- # Wait for embedding processing
- print()
- print_info("Waiting 4 seconds for embedding processing and indexing...")
- await asyncio.sleep(4)
-
- # Step 3: Search for the unique content
- print_step(3, "Searching for unique content")
-
- # Search for the pattern
- pattern_query = "dependency injection database connections"
- pattern_results = await memory.get_relevant_context(pattern_query, num_results=5)
-
- print(f" Query: '{pattern_query}'")
- print(f" Found {len(pattern_results)} results")
-
- pattern_found = False
- for result in pattern_results:
- content = result.get("content", "")
- if unique_id in content:
- pattern_found = True
- print(f" MATCH: {content[:80]}...")
-
- print_result(
- "Pattern retrieval",
- f"Found unique pattern (ID: {unique_id})"
- if pattern_found
- else "Unique pattern not in top results",
- pattern_found,
- )
-
- # Search for the gotcha
- gotcha_query = "database connection cleanup finally block"
- gotcha_results = await memory.get_relevant_context(gotcha_query, num_results=5)
-
- print(f" Query: '{gotcha_query}'")
- print(f" Found {len(gotcha_results)} results")
-
- gotcha_found = False
- for result in gotcha_results:
- content = result.get("content", "")
- if unique_id in content:
- gotcha_found = True
- print(f" MATCH: {content[:80]}...")
-
- print_result(
- "Gotcha retrieval",
- f"Found unique gotcha (ID: {unique_id})"
- if gotcha_found
- else "Unique gotcha not in top results",
- gotcha_found,
- )
-
- # Step 4: Verify semantic similarity works
- print_step(4, "Verifying semantic similarity")
-
- # Search with semantically similar but different wording
- alt_query = "closing connections properly in error handling"
- alt_results = await memory.get_relevant_context(alt_query, num_results=3)
-
- print(f" Alternative query: '{alt_query}'")
- print(f" Found {len(alt_results)} semantically similar results:")
-
- for i, result in enumerate(alt_results):
- content = result.get("content", "")[:80]
- score = result.get("score", 0)
- print(f" {i + 1}. (score: {score:.4f}) {content}...")
-
- semantic_works = len(alt_results) > 0
- print_result(
- "Semantic similarity",
- "Working - found related content" if semantic_works else "No results",
- semantic_works,
- )
-
- await memory.close()
-
- # Summary
- print()
- cycle_passed = (
- pattern_result
- and gotcha_result
- and (pattern_found or gotcha_found or len(alt_results) > 0)
- )
- print_result(
- "Full Cycle Test",
- "Create-Store-Retrieve cycle verified"
- if cycle_passed
- else "Some steps had issues",
- cycle_passed,
- )
-
- return cycle_passed
-
-
-# ============================================================================
-# Main Entry Point
-# ============================================================================
-
-
-async def main():
- """Run Ollama embedding memory tests."""
- parser = argparse.ArgumentParser(
- description="Test Ollama Embedding Memory Integration"
- )
- parser.add_argument(
- "--test",
- choices=["all", "embeddings", "create", "retrieve", "full-cycle"],
- default="all",
- help="Which test to run",
- )
- parser.add_argument(
- "--keep-db",
- action="store_true",
- help="Keep test database after completion (default: cleanup)",
- )
-
- args = parser.parse_args()
-
- print("\n" + "=" * 70)
- print(" OLLAMA EMBEDDING MEMORY TEST SUITE")
- print("=" * 70)
-
- # Configuration check
- print_header("Configuration Check")
-
- config_items = {
- "GRAPHITI_ENABLED": os.environ.get("GRAPHITI_ENABLED", ""),
- "GRAPHITI_LLM_PROVIDER": os.environ.get("GRAPHITI_LLM_PROVIDER", ""),
- "GRAPHITI_EMBEDDER_PROVIDER": os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", ""),
- "OLLAMA_LLM_MODEL": os.environ.get("OLLAMA_LLM_MODEL", ""),
- "OLLAMA_EMBEDDING_MODEL": os.environ.get("OLLAMA_EMBEDDING_MODEL", ""),
- "OLLAMA_EMBEDDING_DIM": os.environ.get("OLLAMA_EMBEDDING_DIM", ""),
- "OLLAMA_BASE_URL": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434"),
- "OPENAI_API_KEY": "(set)"
- if os.environ.get("OPENAI_API_KEY")
- else "(not set - needed for reranker)",
- }
-
- all_configured = True
- required_keys = [
- "GRAPHITI_ENABLED",
- "GRAPHITI_LLM_PROVIDER",
- "GRAPHITI_EMBEDDER_PROVIDER",
- "OLLAMA_LLM_MODEL",
- "OLLAMA_EMBEDDING_MODEL",
- ]
-
- for key, value in config_items.items():
- is_optional = key in [
- "OLLAMA_BASE_URL",
- "OPENAI_API_KEY",
- "OLLAMA_EMBEDDING_DIM",
- ]
- is_set = bool(value) if not is_optional else True
- display_value = value or "(not set)"
- if key == "OPENAI_API_KEY":
- display_value = value # Already formatted above
- is_set = True # Optional for testing
- print_result(key, display_value, is_set)
- if key in required_keys and not bool(os.environ.get(key)):
- all_configured = False
-
- if not all_configured:
- print()
- print(" Missing required configuration. Please set:")
- print(" export GRAPHITI_ENABLED=true")
- print(" export GRAPHITI_LLM_PROVIDER=ollama")
- print(" export GRAPHITI_EMBEDDER_PROVIDER=ollama")
- print(" export OLLAMA_LLM_MODEL=deepseek-r1:7b")
- print(" export OLLAMA_EMBEDDING_MODEL=embeddinggemma")
- print(" export OLLAMA_EMBEDDING_DIM=768")
- print(" export OPENAI_API_KEY=dummy # For graphiti-core reranker")
- print()
- return
-
- # Check LadybugDB
- if not apply_ladybug_monkeypatch():
- print()
- print_result("LadybugDB", "Not installed - pip install real-ladybug", False)
- return
-
- print_result("LadybugDB", "Installed", True)
-
- # Create temp directory for test database
- test_db_path = Path(tempfile.mkdtemp(prefix="ollama_memory_test_"))
- print()
- print_info(f"Test database: {test_db_path}")
-
- # Run tests
- test = args.test
- results = {}
-
- try:
- if test in ["all", "embeddings"]:
- results["embeddings"] = await test_ollama_embeddings()
-
- spec_dir = None
- project_dir = None
-
- if test in ["all", "create"]:
- spec_dir, project_dir, results["create"] = await test_memory_creation(
- test_db_path
- )
-
- if test in ["all", "retrieve"]:
- if spec_dir and project_dir:
- results["retrieve"] = await test_memory_retrieval(spec_dir, project_dir)
- else:
- print_info(
- "Skipping retrieve test - no spec/project dir from create test"
- )
-
- if test in ["all", "full-cycle"]:
- results["full-cycle"] = await test_full_cycle(test_db_path)
-
- finally:
- # Cleanup unless --keep-db specified
- if not args.keep_db and test_db_path.exists():
- print()
- print_info(f"Cleaning up test database: {test_db_path}")
- shutil.rmtree(test_db_path, ignore_errors=True)
-
- # Summary
- print_header("TEST SUMMARY")
-
- all_passed = True
- for test_name, passed in results.items():
- status = "PASSED" if passed else "FAILED"
- print(f" {test_name}: {status}")
- if not passed:
- all_passed = False
-
- print()
- if all_passed:
- print(" All tests PASSED!")
- print()
- print(" The memory system is working correctly with Ollama embeddings.")
- print(" Memories can be created and retrieved using semantic search.")
- else:
- print(" Some tests FAILED. Check the output above for details.")
- print()
- print(" Common issues:")
- print(" - Ollama not running: ollama serve")
- print(" - Model not pulled: ollama pull embeddinggemma")
- print(" - Wrong dimension: Update OLLAMA_EMBEDDING_DIM to match model")
-
- print()
- print(" Commands:")
- print(" # Run all tests:")
- print(" python integrations/graphiti/test_ollama_embedding_memory.py")
- print()
- print(" # Run specific test:")
- print(
- " python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
- )
- print(
- " python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
- )
- print()
- print(" # Keep database for inspection:")
- print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
- print()
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/apps/backend/integrations/graphiti/test_provider_naming.py b/apps/backend/integrations/graphiti/test_provider_naming.py
deleted file mode 100644
index 4fce56b7..00000000
--- a/apps/backend/integrations/graphiti/test_provider_naming.py
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/usr/bin/env python3
-"""
-Quick test to demonstrate provider-specific database naming.
-
-Shows how Auto Claude automatically generates provider-specific database names
-to prevent embedding dimension mismatches.
-"""
-
-import os
-import sys
-from pathlib import Path
-
-# Add auto-claude to path
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-
-from integrations.graphiti.config import GraphitiConfig
-
-
-def test_provider_naming():
- """Demonstrate provider-specific database naming."""
-
- print("\n" + "=" * 70)
- print(" PROVIDER-SPECIFIC DATABASE NAMING")
- print("=" * 70 + "\n")
-
- providers = [
- ("openai", None, None),
- ("ollama", "embeddinggemma", 768),
- ("ollama", "qwen3-embedding:0.6b", 1024),
- ("voyage", None, None),
- ("google", None, None),
- ]
-
- for provider, model, dim in providers:
- # Create config
- config = GraphitiConfig.from_env()
- config.embedder_provider = provider
-
- if provider == "ollama" and model:
- config.ollama_embedding_model = model
- if dim:
- config.ollama_embedding_dim = dim
-
- # Get naming info
- dimension = config.get_embedding_dimension()
- signature = config.get_provider_signature()
- db_name = config.get_provider_specific_database_name("auto_claude_memory")
-
- print(f"Provider: {provider}")
- if model:
- print(f" Model: {model}")
- print(f" Embedding Dimension: {dimension}")
- print(f" Provider Signature: {signature}")
- print(f" Database Name: {db_name}")
- print(f" Full Path: ~/.auto-claude/memories/{db_name}/")
- print()
-
- print("=" * 70)
- print("\nKey Benefits:")
- print(" ✅ No dimension mismatch errors")
- print(" ✅ Each provider uses its own database")
- print(" ✅ Can switch providers without conflicts")
- print(" ✅ Migration utility available for data transfer")
- print()
-
-
-if __name__ == "__main__":
- test_provider_naming()
diff --git a/apps/backend/runners/github/services/review_tools.py b/apps/backend/runners/github/services/review_tools.py
index 6853833f..c318d571 100644
--- a/apps/backend/runners/github/services/review_tools.py
+++ b/apps/backend/runners/github/services/review_tools.py
@@ -15,18 +15,18 @@ from dataclasses import dataclass
from pathlib import Path
try:
- from ...analysis.test_discovery import TestDiscovery
from ...core.client import create_client
from ..context_gatherer import PRContext
from ..models import PRReviewFinding, ReviewSeverity
from .category_utils import map_category
except (ImportError, ValueError, SystemError):
- from analysis.test_discovery import TestDiscovery
from category_utils import map_category
from context_gatherer import PRContext
from core.client import create_client
from models import PRReviewFinding, ReviewSeverity
+# TestDiscovery was removed - tests are now co-located in their respective modules
+
logger = logging.getLogger(__name__)
@@ -367,48 +367,58 @@ async def run_tests(
"""
logger.info("[Orchestrator] Running tests...")
+ # Determine test command based on project configuration
+ # Try common test commands in order of preference
+ test_commands = [
+ "pytest --cov=.", # Python with coverage
+ "pytest", # Python
+ "npm test", # Node.js
+ "npm run test", # Node.js (script form)
+ "python -m pytest", # Python alternative
+ ]
+
try:
- # Discover test framework
- discovery = TestDiscovery()
- test_info = discovery.discover(project_dir)
-
- if not test_info.has_tests:
- logger.warning("[Orchestrator] No tests found")
- return TestResult(executed=False, passed=False, error="No tests found")
-
- # Get test command
- test_cmd = test_info.test_command
- if not test_cmd:
- return TestResult(
- executed=False, passed=False, error="No test command available"
+ # Execute tests with timeout - try common commands
+ for test_cmd in test_commands:
+ logger.info(f"[Orchestrator] Attempting: {test_cmd}")
+ proc = await asyncio.create_subprocess_shell(
+ test_cmd,
+ cwd=project_dir,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
- # Execute tests with timeout
- logger.info(f"[Orchestrator] Executing: {test_cmd}")
- proc = await asyncio.create_subprocess_shell(
- test_cmd,
- cwd=project_dir,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
-
- try:
- stdout, stderr = await asyncio.wait_for(
- proc.communicate(),
- timeout=300.0, # 5 min max
- )
- except asyncio.TimeoutError:
- logger.error("[Orchestrator] Tests timed out after 5 minutes")
- proc.kill()
- return TestResult(executed=True, passed=False, error="Timeout after 5min")
-
- passed = proc.returncode == 0
- logger.info(f"[Orchestrator] Tests {'passed' if passed else 'failed'}")
+ try:
+ stdout, stderr = await asyncio.wait_for(
+ proc.communicate(),
+ timeout=300.0, # 5 min max
+ )
+ # If command not found (127) or not executable (126), try next command
+ # For any other exit code (including test failures), the test framework exists
+ if proc.returncode in (126, 127):
+ # Command not found or not executable - try next one
+ continue
+ # Test ran (may have passed or failed) - return result
+ passed = proc.returncode == 0
+ logger.info(f"[Orchestrator] Tests {'passed' if passed else 'failed'}")
+ return TestResult(
+ executed=True,
+ passed=passed,
+ error=None if passed else stderr.decode("utf-8")[:500],
+ )
+ except asyncio.TimeoutError:
+ # Command timed out - kill it and try next command
+ proc.kill()
+ await proc.wait() # Ensure process is fully terminated
+ continue
+ except FileNotFoundError:
+ # Command not found - try next one
+ continue
+ # If no test command worked
+ logger.warning("[Orchestrator] No test command could be executed")
return TestResult(
- executed=True,
- passed=passed,
- error=None if passed else stderr.decode("utf-8")[:500],
+ executed=False, passed=False, error="No test command available"
)
except Exception as e:
diff --git a/apps/backend/runners/github/test_bot_detection.py b/apps/backend/runners/github/test_bot_detection.py
deleted file mode 100644
index e8811502..00000000
--- a/apps/backend/runners/github/test_bot_detection.py
+++ /dev/null
@@ -1,707 +0,0 @@
-"""
-Tests for Bot Detection Module
-================================
-
-Tests the BotDetector class to ensure it correctly prevents infinite loops.
-"""
-
-import json
-import subprocess
-import sys
-from datetime import datetime, timedelta
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-# Use direct file import to avoid package import issues
-_github_dir = Path(__file__).parent
-if str(_github_dir) not in sys.path:
- sys.path.insert(0, str(_github_dir))
-
-from bot_detection import BotDetectionState, BotDetector
-
-
-@pytest.fixture
-def temp_state_dir(tmp_path):
- """Create temporary state directory."""
- state_dir = tmp_path / "github"
- state_dir.mkdir()
- return state_dir
-
-
-@pytest.fixture
-def mock_bot_detector(temp_state_dir):
- """Create bot detector with mocked bot username."""
- with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"):
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="fake-token",
- review_own_prs=False,
- )
- return detector
-
-
-class TestBotDetectionState:
- """Test BotDetectionState data class."""
-
- def test_save_and_load(self, temp_state_dir):
- """Test saving and loading state."""
- state = BotDetectionState(
- reviewed_commits={
- "123": ["abc123", "def456"],
- "456": ["ghi789"],
- },
- last_review_times={
- "123": "2025-01-01T10:00:00",
- "456": "2025-01-01T11:00:00",
- },
- )
-
- # Save
- state.save(temp_state_dir)
-
- # Load
- loaded = BotDetectionState.load(temp_state_dir)
-
- assert loaded.reviewed_commits == state.reviewed_commits
- assert loaded.last_review_times == state.last_review_times
-
- def test_load_nonexistent(self, temp_state_dir):
- """Test loading when file doesn't exist."""
- loaded = BotDetectionState.load(temp_state_dir)
-
- assert loaded.reviewed_commits == {}
- assert loaded.last_review_times == {}
-
-
-class TestBotDetectorInit:
- """Test BotDetector initialization."""
-
- def test_init_with_token(self, temp_state_dir):
- """Test initialization with bot token."""
- with patch("subprocess.run") as mock_run:
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout=json.dumps({"login": "my-bot"}),
- )
-
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="ghp_test123",
- review_own_prs=False,
- )
-
- assert detector.bot_username == "my-bot"
- assert detector.review_own_prs is False
-
- def test_init_without_token(self, temp_state_dir):
- """Test initialization without bot token."""
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token=None,
- review_own_prs=True,
- )
-
- assert detector.bot_username is None
- assert detector.review_own_prs is True
-
-
-class TestBotDetection:
- """Test bot detection methods."""
-
- def test_is_bot_pr(self, mock_bot_detector):
- """Test detecting bot-authored PRs."""
- bot_pr = {"author": {"login": "test-bot"}}
- human_pr = {"author": {"login": "alice"}}
-
- assert mock_bot_detector.is_bot_pr(bot_pr) is True
- assert mock_bot_detector.is_bot_pr(human_pr) is False
-
- def test_is_bot_commit(self, mock_bot_detector):
- """Test detecting bot-authored commits."""
- bot_commit = {"author": {"login": "test-bot"}}
- human_commit = {"author": {"login": "alice"}}
- bot_committer = {
- "committer": {"login": "test-bot"},
- "author": {"login": "alice"},
- }
-
- assert mock_bot_detector.is_bot_commit(bot_commit) is True
- assert mock_bot_detector.is_bot_commit(human_commit) is False
- assert mock_bot_detector.is_bot_commit(bot_committer) is True
-
- def test_get_last_commit_sha(self, mock_bot_detector):
- """Test extracting last commit SHA."""
- # GitHub API returns commits in chronological order (oldest first, newest last)
- # So commits[-1] is the LATEST commit
- commits = [
- {"oid": "abc123"}, # Oldest commit
- {"oid": "def456"}, # Latest commit
- ]
-
- sha = mock_bot_detector.get_last_commit_sha(commits)
- assert sha == "def456" # Should return the LAST (latest) commit
-
- # Test with sha field instead of oid
- commits_with_sha = [{"sha": "xyz789"}]
- sha = mock_bot_detector.get_last_commit_sha(commits_with_sha)
- assert sha == "xyz789"
-
- # Empty commits
- assert mock_bot_detector.get_last_commit_sha([]) is None
-
-
-class TestCoolingOff:
- """Test cooling off period.
-
- Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs.
- """
-
- def test_within_cooling_off(self, mock_bot_detector):
- """Test PR within cooling off period."""
- # Set last review to 30 seconds ago (within 1 minute cooling off)
- half_min_ago = datetime.now() - timedelta(seconds=30)
- mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
-
- is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
-
- assert is_cooling is True
- assert "Cooling off" in reason
-
- def test_outside_cooling_off(self, mock_bot_detector):
- """Test PR outside cooling off period."""
- # Set last review to 2 minutes ago (outside 1 minute cooling off)
- two_min_ago = datetime.now() - timedelta(minutes=2)
- mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
-
- is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
-
- assert is_cooling is False
- assert reason == ""
-
- def test_no_previous_review(self, mock_bot_detector):
- """Test PR with no previous review."""
- is_cooling, reason = mock_bot_detector.is_within_cooling_off(999)
-
- assert is_cooling is False
- assert reason == ""
-
-
-class TestReviewedCommits:
- """Test reviewed commit tracking."""
-
- def test_has_reviewed_commit(self, mock_bot_detector):
- """Test checking if commit was reviewed."""
- mock_bot_detector.state.reviewed_commits["123"] = ["abc123", "def456"]
-
- assert mock_bot_detector.has_reviewed_commit(123, "abc123") is True
- assert mock_bot_detector.has_reviewed_commit(123, "xyz789") is False
- assert mock_bot_detector.has_reviewed_commit(999, "abc123") is False
-
- def test_mark_reviewed(self, mock_bot_detector, temp_state_dir):
- """Test marking PR as reviewed."""
- mock_bot_detector.mark_reviewed(123, "abc123")
-
- # Check state
- assert "123" in mock_bot_detector.state.reviewed_commits
- assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
- assert "123" in mock_bot_detector.state.last_review_times
-
- # Check persistence
- loaded = BotDetectionState.load(temp_state_dir)
- assert "123" in loaded.reviewed_commits
- assert "abc123" in loaded.reviewed_commits["123"]
-
- def test_mark_reviewed_multiple(self, mock_bot_detector):
- """Test marking same PR reviewed multiple times."""
- mock_bot_detector.mark_reviewed(123, "abc123")
- mock_bot_detector.mark_reviewed(123, "def456")
-
- commits = mock_bot_detector.state.reviewed_commits["123"]
- assert len(commits) == 2
- assert "abc123" in commits
- assert "def456" in commits
-
-
-class TestShouldSkipReview:
- """Test main should_skip_pr_review logic."""
-
- def test_skip_bot_pr(self, mock_bot_detector):
- """Test skipping bot-authored PR."""
- pr_data = {"author": {"login": "test-bot"}}
- commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}]
-
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- assert should_skip is True
- assert "bot user" in reason
-
- def test_skip_bot_commit(self, mock_bot_detector):
- """Test skipping PR with bot commit as the latest commit."""
- pr_data = {"author": {"login": "alice"}}
- # GitHub API returns commits in chronological order (oldest first, newest last)
- # So commits[-1] is the LATEST commit - which is the bot commit
- commits = [
- {"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice)
- {
- "author": {"login": "test-bot"},
- "oid": "def456",
- }, # Latest commit (by bot)
- ]
-
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- assert should_skip is True
- assert "bot" in reason.lower()
-
- def test_skip_cooling_off(self, mock_bot_detector):
- """Test skipping during cooling off period."""
- # Set last review to 30 seconds ago (within 1 minute cooling off)
- half_min_ago = datetime.now() - timedelta(seconds=30)
- mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
-
- pr_data = {"author": {"login": "alice"}}
- commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
-
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- assert should_skip is True
- assert "Cooling off" in reason
-
- def test_skip_already_reviewed(self, mock_bot_detector):
- """Test skipping already-reviewed commit."""
- mock_bot_detector.state.reviewed_commits["123"] = ["abc123"]
-
- pr_data = {"author": {"login": "alice"}}
- commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
-
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- assert should_skip is True
- assert "Already reviewed" in reason
-
- def test_allow_review(self, mock_bot_detector):
- """Test allowing review when all checks pass."""
- pr_data = {"author": {"login": "alice"}}
- commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
-
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- assert should_skip is False
- assert reason == ""
-
- def test_allow_review_own_prs(self, temp_state_dir):
- """Test allowing review when review_own_prs is True."""
- with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"):
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="fake-token",
- review_own_prs=True, # Allow bot to review own PRs
- )
-
- pr_data = {"author": {"login": "test-bot"}}
- commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}]
-
- should_skip, reason = detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- # Should not skip even though it's bot's own PR
- assert should_skip is False
-
-
-class TestStateManagement:
- """Test state management methods."""
-
- def test_clear_pr_state(self, mock_bot_detector, temp_state_dir):
- """Test clearing PR state."""
- # Set up state
- mock_bot_detector.mark_reviewed(123, "abc123")
- mock_bot_detector.mark_reviewed(456, "def456")
-
- # Clear one PR
- mock_bot_detector.clear_pr_state(123)
-
- # Check in-memory state
- assert "123" not in mock_bot_detector.state.reviewed_commits
- assert "123" not in mock_bot_detector.state.last_review_times
- assert "456" in mock_bot_detector.state.reviewed_commits
-
- # Check persistence
- loaded = BotDetectionState.load(temp_state_dir)
- assert "123" not in loaded.reviewed_commits
- assert "456" in loaded.reviewed_commits
-
- def test_get_stats(self, mock_bot_detector):
- """Test getting detector statistics."""
- mock_bot_detector.mark_reviewed(123, "abc123")
- mock_bot_detector.mark_reviewed(123, "def456")
- mock_bot_detector.mark_reviewed(456, "ghi789")
-
- stats = mock_bot_detector.get_stats()
-
- assert stats["bot_username"] == "test-bot"
- assert stats["review_own_prs"] is False
- assert stats["total_prs_tracked"] == 2
- assert stats["total_reviews_performed"] == 3
- assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing
-
-
-class TestEdgeCases:
- """Test edge cases and error handling."""
-
- def test_no_commits(self, mock_bot_detector):
- """Test handling PR with no commits."""
- pr_data = {"author": {"login": "alice"}}
- commits = []
-
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- # Should not skip (no bot commit to detect)
- assert should_skip is False
-
- def test_malformed_commit_data(self, mock_bot_detector):
- """Test handling malformed commit data."""
- pr_data = {"author": {"login": "alice"}}
- commits = [
- {"author": {"login": "alice"}}, # Missing oid/sha
- {}, # Empty commit
- ]
-
- # Should not crash
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- assert should_skip is False
-
- def test_invalid_last_review_time(self, mock_bot_detector):
- """Test handling invalid timestamp in state."""
- mock_bot_detector.state.last_review_times["123"] = "invalid-timestamp"
-
- is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
-
- # Should not crash, should return False
- assert is_cooling is False
-
-
-class TestGhExecutableDetection:
- """Test gh executable detection in bot_detector._get_bot_username."""
-
- def test_get_bot_username_with_gh_not_found(self, temp_state_dir):
- """Test _get_bot_username when gh CLI is not found."""
- with patch("bot_detection.get_gh_executable", return_value=None):
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="fake-token",
- review_own_prs=False,
- )
-
- # Should not crash, username should be None
- assert detector.bot_username is None
-
- def test_get_bot_username_with_detected_gh(self, temp_state_dir):
- """Test _get_bot_username when gh CLI is found."""
- mock_gh_path = str(temp_state_dir / "gh")
- with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
- with patch("subprocess.run") as mock_run:
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout=json.dumps({"login": "test-bot-user"}),
- )
-
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="fake-token",
- review_own_prs=False,
- )
-
- # Should use the detected gh path
- assert detector.bot_username == "test-bot-user"
-
- # Verify subprocess was called with the correct gh path
- mock_run.assert_called_once()
- called_cmd_list = mock_run.call_args[0][0]
- assert called_cmd_list[0] == mock_gh_path
- assert called_cmd_list[1:] == ["api", "user"]
-
- def test_get_bot_username_uses_get_gh_executable_return_value(self, temp_state_dir):
- """Test that _get_bot_username uses the path returned by get_gh_executable."""
- # Note: GITHUB_CLI_PATH env var is tested by get_gh_executable's own tests
- # This test verifies _get_bot_username uses whatever get_gh_executable returns
- mock_gh_path = str(temp_state_dir / "gh")
-
- with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
- with patch("subprocess.run") as mock_run:
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout=json.dumps({"login": "env-bot-user"}),
- )
-
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="fake-token",
- review_own_prs=False,
- )
-
- # Verify the command was run with the path from get_gh_executable
- assert detector.bot_username == "env-bot-user"
-
- # Verify subprocess was called with the correct path
- mock_run.assert_called_once()
- called_cmd_list = mock_run.call_args[0][0]
- assert called_cmd_list[0] == mock_gh_path
- assert called_cmd_list[1:] == ["api", "user"]
-
- def test_get_bot_username_with_api_error(self, temp_state_dir):
- """Test _get_bot_username when gh api command fails."""
- mock_gh_path = str(temp_state_dir / "gh")
- with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
- with patch("subprocess.run") as mock_run:
- mock_run.return_value = MagicMock(
- returncode=1,
- stderr="Authentication failed",
- )
-
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="invalid-token",
- review_own_prs=False,
- )
-
- # Should handle error gracefully, username should be None
- assert detector.bot_username is None
-
- def test_get_bot_username_with_subprocess_timeout(self, temp_state_dir):
- """Test _get_bot_username when subprocess times out."""
- mock_gh_path = str(temp_state_dir / "gh")
- with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
- with patch(
- "subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 5)
- ):
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token="fake-token",
- review_own_prs=False,
- )
-
- # Should handle timeout gracefully, username should be None
- assert detector.bot_username is None
-
- def test_get_bot_username_without_token(self, temp_state_dir):
- """Test _get_bot_username when no bot token is provided."""
- with patch("subprocess.run") as mock_run:
- detector = BotDetector(
- state_dir=temp_state_dir,
- bot_token=None,
- review_own_prs=False,
- )
-
- # Should return None without trying to call gh
- assert detector.bot_username is None
- # Verify subprocess.run was not called (no gh CLI invocation)
- mock_run.assert_not_called()
-
-
-class TestInProgressTracking:
- """Test in-progress review tracking."""
-
- def test_mark_review_started(self, mock_bot_detector, temp_state_dir):
- """Test marking review as started."""
- mock_bot_detector.mark_review_started(123)
-
- # Check state
- assert "123" in mock_bot_detector.state.in_progress_reviews
- start_time_str = mock_bot_detector.state.in_progress_reviews["123"]
- start_time = datetime.fromisoformat(start_time_str)
-
- # Should be very recent (within last 5 seconds)
- time_diff = datetime.now() - start_time
- assert time_diff.total_seconds() < 5
-
- # Check persistence
- loaded = BotDetectionState.load(temp_state_dir)
- assert "123" in loaded.in_progress_reviews
-
- def test_mark_review_finished_success(self, mock_bot_detector, temp_state_dir):
- """Test marking review as finished successfully."""
- mock_bot_detector.mark_review_started(123)
- assert "123" in mock_bot_detector.state.in_progress_reviews
-
- mock_bot_detector.mark_review_finished(123, success=True)
-
- # In-progress state should be cleared
- assert "123" not in mock_bot_detector.state.in_progress_reviews
-
- # Check persistence
- loaded = BotDetectionState.load(temp_state_dir)
- assert "123" not in loaded.in_progress_reviews
-
- def test_mark_review_finished_error(self, mock_bot_detector):
- """Test marking review as finished with error."""
- mock_bot_detector.mark_review_started(123)
- mock_bot_detector.mark_review_finished(123, success=False)
-
- # In-progress state should be cleared
- assert "123" not in mock_bot_detector.state.in_progress_reviews
-
- def test_is_review_in_progress_active(self, mock_bot_detector):
- """Test detecting active in-progress review."""
- mock_bot_detector.mark_review_started(123)
-
- is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
-
- assert is_in_progress is True
- assert "already in progress" in reason.lower()
-
- def test_is_review_in_progress_not_started(self, mock_bot_detector):
- """Test checking in-progress when review not started."""
- is_in_progress, reason = mock_bot_detector.is_review_in_progress(999)
-
- assert is_in_progress is False
- assert reason == ""
-
- def test_is_review_in_progress_stale(self, mock_bot_detector):
- """Test detecting stale in-progress review."""
- # Set review start time to 31 minutes ago (past timeout)
- stale_time = datetime.now() - timedelta(minutes=31)
- mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
-
- is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
-
- # Should detect as stale and clear it
- assert is_in_progress is False
- assert reason == ""
- # Should be removed from state
- assert "123" not in mock_bot_detector.state.in_progress_reviews
-
- def test_is_review_in_progress_invalid_timestamp(self, mock_bot_detector):
- """Test handling invalid timestamp in in-progress state."""
- mock_bot_detector.state.in_progress_reviews["123"] = "invalid-timestamp"
-
- is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
-
- # Should clear invalid state
- assert is_in_progress is False
- assert reason == ""
- assert "123" not in mock_bot_detector.state.in_progress_reviews
-
- def test_should_skip_review_in_progress(self, mock_bot_detector):
- """Test skipping PR when review is in progress."""
- mock_bot_detector.mark_review_started(123)
-
- pr_data = {"author": {"login": "alice"}}
- commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
-
- should_skip, reason = mock_bot_detector.should_skip_pr_review(
- pr_number=123,
- pr_data=pr_data,
- commits=commits,
- )
-
- assert should_skip is True
- assert "already in progress" in reason.lower()
-
- def test_mark_reviewed_clears_in_progress(self, mock_bot_detector):
- """Test that mark_reviewed also clears in-progress state."""
- mock_bot_detector.mark_review_started(123)
- assert "123" in mock_bot_detector.state.in_progress_reviews
-
- mock_bot_detector.mark_reviewed(123, "abc123")
-
- # In-progress should be cleared
- assert "123" not in mock_bot_detector.state.in_progress_reviews
- # Reviewed state should be set
- assert "123" in mock_bot_detector.state.reviewed_commits
- assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
-
- def test_clear_pr_state_clears_in_progress(self, mock_bot_detector):
- """Test that clear_pr_state also clears in-progress state."""
- mock_bot_detector.mark_review_started(123)
- mock_bot_detector.mark_reviewed(123, "abc123")
-
- assert (
- "123" in mock_bot_detector.state.in_progress_reviews or True
- ) # May be cleared by mark_reviewed
- assert "123" in mock_bot_detector.state.reviewed_commits
-
- # Start another review
- mock_bot_detector.mark_review_started(123)
- assert "123" in mock_bot_detector.state.in_progress_reviews
-
- mock_bot_detector.clear_pr_state(123)
-
- # Everything should be cleared
- assert "123" not in mock_bot_detector.state.in_progress_reviews
- assert "123" not in mock_bot_detector.state.reviewed_commits
- assert "123" not in mock_bot_detector.state.last_review_times
-
- def test_get_stats_includes_in_progress(self, mock_bot_detector):
- """Test that get_stats includes in-progress count."""
- mock_bot_detector.mark_review_started(123)
- mock_bot_detector.mark_review_started(456)
- mock_bot_detector.mark_reviewed(789, "abc123")
-
- stats = mock_bot_detector.get_stats()
-
- assert stats["in_progress_reviews"] == 2
- assert stats["total_prs_tracked"] == 1 # Only 789 is tracked as reviewed
- assert stats["in_progress_timeout_minutes"] == 30
-
- def test_cleanup_stale_prs_removes_stale_in_progress(self, mock_bot_detector):
- """Test that cleanup_stale_prs removes stale in-progress reviews."""
- # Add a stale in-progress review (32 minutes ago)
- stale_time = datetime.now() - timedelta(minutes=32)
- mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
-
- # Add an active in-progress review (5 minutes ago)
- active_time = datetime.now() - timedelta(minutes=5)
- mock_bot_detector.state.in_progress_reviews["456"] = active_time.isoformat()
-
- # Add a stale reviewed PR (40 days ago)
- stale_review_time = datetime.now() - timedelta(days=40)
- mock_bot_detector.state.reviewed_commits["789"] = ["abc123"]
- mock_bot_detector.state.last_review_times["789"] = stale_review_time.isoformat()
-
- cleaned = mock_bot_detector.cleanup_stale_prs(max_age_days=30)
-
- # Should remove stale in-progress and stale reviewed PR
- assert cleaned == 2 # 1 stale in-progress + 1 stale reviewed
- assert "123" not in mock_bot_detector.state.in_progress_reviews
- assert (
- "456" in mock_bot_detector.state.in_progress_reviews
- ) # Active one remains
- assert "789" not in mock_bot_detector.state.reviewed_commits
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/apps/backend/runners/github/test_context_gatherer.py b/apps/backend/runners/github/test_context_gatherer.py
deleted file mode 100644
index 19ed4498..00000000
--- a/apps/backend/runners/github/test_context_gatherer.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""
-Unit tests for PR Context Gatherer
-===================================
-
-Tests the context gathering functionality without requiring actual GitHub API calls.
-"""
-
-from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from context_gatherer import ChangedFile, PRContext, PRContextGatherer
-
-
-@pytest.mark.asyncio
-async def test_gather_basic_pr_context(tmp_path):
- """Test gathering basic PR context."""
- # Create a temporary project directory
- project_dir = tmp_path / "project"
- project_dir.mkdir()
-
- # Mock the subprocess calls
- pr_metadata = {
- "number": 123,
- "title": "Add new feature",
- "body": "This PR adds a new feature",
- "author": {"login": "testuser"},
- "baseRefName": "main",
- "headRefName": "feature/new-feature",
- "files": [
- {
- "path": "src/app.ts",
- "status": "modified",
- "additions": 10,
- "deletions": 5,
- }
- ],
- "additions": 10,
- "deletions": 5,
- "changedFiles": 1,
- "labels": [{"name": "feature"}],
- }
-
- with patch("subprocess.run") as mock_run:
- # Mock metadata fetch
- mock_run.return_value = MagicMock(
- returncode=0, stdout='{"number": 123, "title": "Add new feature"}'
- )
-
- gatherer = PRContextGatherer(project_dir, 123)
-
- # We can't fully test without real git, but we can verify the structure
- assert gatherer.pr_number == 123
- assert gatherer.project_dir == project_dir
-
-
-def test_normalize_status():
- """Test file status normalization."""
- gatherer = PRContextGatherer(Path("/tmp"), 1)
-
- assert gatherer._normalize_status("added") == "added"
- assert gatherer._normalize_status("ADD") == "added"
- assert gatherer._normalize_status("modified") == "modified"
- assert gatherer._normalize_status("mod") == "modified"
- assert gatherer._normalize_status("deleted") == "deleted"
- assert gatherer._normalize_status("renamed") == "renamed"
-
-
-def test_find_test_files(tmp_path):
- """Test finding related test files."""
- # Create a project structure
- project_dir = tmp_path / "project"
- src_dir = project_dir / "src"
- src_dir.mkdir(parents=True)
-
- # Create source file
- source_file = src_dir / "utils.ts"
- source_file.write_text("export const add = (a, b) => a + b;", encoding="utf-8")
-
- # Create test file
- test_file = src_dir / "utils.test.ts"
- test_file.write_text("import { add } from './utils';", encoding="utf-8")
-
- gatherer = PRContextGatherer(project_dir, 1)
-
- # Find test files for the source file
- source_path = Path("src/utils.ts")
- test_files = gatherer._find_test_files(source_path)
-
- assert "src/utils.test.ts" in test_files
-
-
-def test_resolve_import_path(tmp_path):
- """Test resolving relative import paths."""
- # Create a project structure
- project_dir = tmp_path / "project"
- src_dir = project_dir / "src"
- src_dir.mkdir(parents=True)
-
- # Create imported file
- utils_file = src_dir / "utils.ts"
- utils_file.write_text("export const helper = () => {};", encoding="utf-8")
-
- # Create importing file
- app_file = src_dir / "app.ts"
- app_file.write_text("import { helper } from './utils';", encoding="utf-8")
-
- gatherer = PRContextGatherer(project_dir, 1)
-
- # Resolve import path
- source_path = Path("src/app.ts")
- resolved = gatherer._resolve_import_path("./utils", source_path)
-
- assert resolved == "src/utils.ts"
-
-
-def test_detect_repo_structure_monorepo(tmp_path):
- """Test detecting monorepo structure."""
- # Create monorepo structure
- project_dir = tmp_path / "project"
- project_dir.mkdir()
-
- apps_dir = project_dir / "apps"
- apps_dir.mkdir()
-
- (apps_dir / "frontend").mkdir()
- (apps_dir / "backend").mkdir()
-
- # Create package.json with workspaces
- package_json = project_dir / "package.json"
- package_json.write_text('{"workspaces": ["apps/*"]}', encoding="utf-8")
-
- gatherer = PRContextGatherer(project_dir, 1)
-
- structure = gatherer._detect_repo_structure()
-
- assert "Monorepo Apps" in structure
- assert "frontend" in structure
- assert "backend" in structure
- assert "Workspaces" in structure
-
-
-def test_detect_repo_structure_python(tmp_path):
- """Test detecting Python project structure."""
- project_dir = tmp_path / "project"
- project_dir.mkdir()
-
- # Create pyproject.toml
- pyproject = project_dir / "pyproject.toml"
- pyproject.write_text("[tool.poetry]\nname = 'test'", encoding="utf-8")
-
- gatherer = PRContextGatherer(project_dir, 1)
-
- structure = gatherer._detect_repo_structure()
-
- assert "Python Project" in structure
-
-
-def test_find_config_files(tmp_path):
- """Test finding configuration files."""
- project_dir = tmp_path / "project"
- src_dir = project_dir / "src"
- src_dir.mkdir(parents=True)
-
- # Create config files
- (src_dir / "tsconfig.json").write_text("{}", encoding="utf-8")
- (src_dir / "package.json").write_text("{}", encoding="utf-8")
-
- gatherer = PRContextGatherer(project_dir, 1)
-
- config_files = gatherer._find_config_files(Path("src"))
-
- assert "src/tsconfig.json" in config_files
- assert "src/package.json" in config_files
-
-
-def test_get_file_extension():
- """Test file extension mapping for syntax highlighting."""
- gatherer = PRContextGatherer(Path("/tmp"), 1)
-
- assert gatherer._get_file_extension("app.ts") == "typescript"
- assert gatherer._get_file_extension("utils.tsx") == "typescript"
- assert gatherer._get_file_extension("script.js") == "javascript"
- assert gatherer._get_file_extension("script.jsx") == "javascript"
- assert gatherer._get_file_extension("main.py") == "python"
- assert gatherer._get_file_extension("config.json") == "json"
- assert gatherer._get_file_extension("readme.md") == "markdown"
- assert gatherer._get_file_extension("config.yml") == "yaml"
-
-
-def test_find_imports_typescript(tmp_path):
- """Test finding imports in TypeScript code."""
- project_dir = tmp_path / "project"
- project_dir.mkdir()
-
- content = """
-import { Component } from 'react';
-import { helper } from './utils';
-import { config } from '../config';
-import external from 'lodash';
-"""
-
- gatherer = PRContextGatherer(project_dir, 1)
- source_path = Path("src/app.tsx")
-
- imports = gatherer._find_imports(content, source_path)
-
- # Should only include relative imports
- assert len(imports) >= 0 # Depends on whether files actually exist
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/apps/backend/runners/github/test_enhanced_pr_review.py b/apps/backend/runners/github/test_enhanced_pr_review.py
deleted file mode 100644
index 87c11a43..00000000
--- a/apps/backend/runners/github/test_enhanced_pr_review.py
+++ /dev/null
@@ -1,582 +0,0 @@
-#!/usr/bin/env python3
-"""
-Validation tests for the Enhanced PR Review System.
-
-These tests validate:
-1. Model serialization/deserialization
-2. Verdict generation logic
-3. Risk assessment calculation
-4. AI comment parsing
-5. Structural issue parsing
-6. Summary generation
-"""
-
-import json
-import sys
-from dataclasses import asdict
-
-from context_gatherer import AI_BOT_PATTERNS, AIBotComment
-
-# Direct imports (avoid parent __init__.py issues)
-from models import (
- AICommentTriage,
- AICommentVerdict,
- MergeVerdict,
- PRReviewFinding,
- PRReviewResult,
- ReviewCategory,
- ReviewPass,
- ReviewSeverity,
- StructuralIssue,
-)
-
-
-def test_merge_verdict_enum():
- """Test MergeVerdict enum values."""
- print("Testing MergeVerdict enum...")
-
- assert MergeVerdict.READY_TO_MERGE.value == "ready_to_merge"
- assert MergeVerdict.MERGE_WITH_CHANGES.value == "merge_with_changes"
- assert MergeVerdict.NEEDS_REVISION.value == "needs_revision"
- assert MergeVerdict.BLOCKED.value == "blocked"
-
- # Test string conversion
- assert MergeVerdict("ready_to_merge") == MergeVerdict.READY_TO_MERGE
- assert MergeVerdict("blocked") == MergeVerdict.BLOCKED
-
- print(" ✅ MergeVerdict enum: PASS")
-
-
-def test_ai_comment_verdict_enum():
- """Test AICommentVerdict enum values."""
- print("Testing AICommentVerdict enum...")
-
- assert AICommentVerdict.CRITICAL.value == "critical"
- assert AICommentVerdict.IMPORTANT.value == "important"
- assert AICommentVerdict.NICE_TO_HAVE.value == "nice_to_have"
- assert AICommentVerdict.TRIVIAL.value == "trivial"
- assert AICommentVerdict.FALSE_POSITIVE.value == "false_positive"
-
- print(" ✅ AICommentVerdict enum: PASS")
-
-
-def test_review_pass_enum():
- """Test ReviewPass enum includes new passes."""
- print("Testing ReviewPass enum...")
-
- assert ReviewPass.STRUCTURAL.value == "structural"
- assert ReviewPass.AI_COMMENT_TRIAGE.value == "ai_comment_triage"
-
- # Ensure all 6 passes exist
- passes = [p.value for p in ReviewPass]
- assert len(passes) == 6
- assert "quick_scan" in passes
- assert "security" in passes
- assert "quality" in passes
- assert "deep_analysis" in passes
- assert "structural" in passes
- assert "ai_comment_triage" in passes
-
- print(" ✅ ReviewPass enum: PASS")
-
-
-def test_ai_bot_patterns():
- """Test AI bot detection patterns."""
- print("Testing AI bot patterns...")
-
- # Check known patterns exist
- assert "coderabbitai" in AI_BOT_PATTERNS
- assert "greptile" in AI_BOT_PATTERNS
- assert "copilot" in AI_BOT_PATTERNS
- assert "sourcery-ai" in AI_BOT_PATTERNS
-
- # Check pattern -> name mapping
- assert AI_BOT_PATTERNS["coderabbitai"] == "CodeRabbit"
- assert AI_BOT_PATTERNS["greptile"] == "Greptile"
- assert AI_BOT_PATTERNS["copilot"] == "GitHub Copilot"
-
- # Check we have a reasonable number of patterns
- assert len(AI_BOT_PATTERNS) >= 15, (
- f"Expected at least 15 patterns, got {len(AI_BOT_PATTERNS)}"
- )
-
- print(f" ✅ AI bot patterns ({len(AI_BOT_PATTERNS)} patterns): PASS")
-
-
-def test_ai_bot_comment_dataclass():
- """Test AIBotComment dataclass."""
- print("Testing AIBotComment dataclass...")
-
- comment = AIBotComment(
- comment_id=12345,
- author="coderabbitai[bot]",
- tool_name="CodeRabbit",
- body="This function has a potential SQL injection vulnerability.",
- file="src/db/queries.py",
- line=42,
- created_at="2024-01-15T10:30:00Z",
- )
-
- assert comment.comment_id == 12345
- assert comment.tool_name == "CodeRabbit"
- assert "SQL injection" in comment.body
- assert comment.file == "src/db/queries.py"
- assert comment.line == 42
-
- print(" ✅ AIBotComment dataclass: PASS")
-
-
-def test_ai_comment_triage_dataclass():
- """Test AICommentTriage dataclass."""
- print("Testing AICommentTriage dataclass...")
-
- triage = AICommentTriage(
- comment_id=12345,
- tool_name="CodeRabbit",
- original_comment="SQL injection vulnerability detected",
- verdict=AICommentVerdict.CRITICAL,
- reasoning="Verified - user input is directly concatenated into SQL query",
- response_comment="✅ Verified: Critical security issue - must fix before merge",
- )
-
- assert triage.verdict == AICommentVerdict.CRITICAL
- assert triage.tool_name == "CodeRabbit"
- assert "Verified" in triage.reasoning
-
- print(" ✅ AICommentTriage dataclass: PASS")
-
-
-def test_structural_issue_dataclass():
- """Test StructuralIssue dataclass."""
- print("Testing StructuralIssue dataclass...")
-
- issue = StructuralIssue(
- id="struct-1",
- issue_type="feature_creep",
- severity=ReviewSeverity.HIGH,
- title="PR includes unrelated authentication refactor",
- description="The PR titled 'Fix payment bug' also refactors auth middleware.",
- impact="Bundles unrelated changes, harder to review and revert.",
- suggestion="Split into two PRs: one for payment fix, one for auth refactor.",
- )
-
- assert issue.issue_type == "feature_creep"
- assert issue.severity == ReviewSeverity.HIGH
- assert "unrelated" in issue.title.lower()
-
- print(" ✅ StructuralIssue dataclass: PASS")
-
-
-def test_pr_review_result_new_fields():
- """Test PRReviewResult has all new fields."""
- print("Testing PRReviewResult new fields...")
-
- result = PRReviewResult(
- pr_number=123,
- repo="owner/repo",
- success=True,
- findings=[],
- summary="Test summary",
- overall_status="approve",
- # New fields
- verdict=MergeVerdict.READY_TO_MERGE,
- verdict_reasoning="No blocking issues found",
- blockers=[],
- risk_assessment={
- "complexity": "low",
- "security_impact": "none",
- "scope_coherence": "good",
- },
- structural_issues=[],
- ai_comment_triages=[],
- quick_scan_summary={"purpose": "Test PR", "complexity": "low"},
- )
-
- assert result.verdict == MergeVerdict.READY_TO_MERGE
- assert result.verdict_reasoning == "No blocking issues found"
- assert result.blockers == []
- assert result.risk_assessment["complexity"] == "low"
- assert result.structural_issues == []
- assert result.ai_comment_triages == []
-
- print(" ✅ PRReviewResult new fields: PASS")
-
-
-def test_pr_review_result_serialization():
- """Test PRReviewResult serializes and deserializes correctly."""
- print("Testing PRReviewResult serialization...")
-
- # Create a complex result
- finding = PRReviewFinding(
- id="finding-1",
- severity=ReviewSeverity.HIGH,
- category=ReviewCategory.SECURITY,
- title="SQL Injection",
- description="User input not sanitized",
- file="src/db.py",
- line=42,
- )
-
- structural = StructuralIssue(
- id="struct-1",
- issue_type="feature_creep",
- severity=ReviewSeverity.MEDIUM,
- title="Unrelated changes",
- description="Extra refactoring",
- impact="Harder to review",
- suggestion="Split PR",
- )
-
- triage = AICommentTriage(
- comment_id=999,
- tool_name="CodeRabbit",
- original_comment="Missing null check",
- verdict=AICommentVerdict.TRIVIAL,
- reasoning="Value is guaranteed non-null by upstream validation",
- )
-
- result = PRReviewResult(
- pr_number=456,
- repo="test/repo",
- success=True,
- findings=[finding],
- summary="Test",
- overall_status="comment",
- verdict=MergeVerdict.MERGE_WITH_CHANGES,
- verdict_reasoning="1 high-priority issue",
- blockers=["Security: SQL Injection (src/db.py:42)"],
- risk_assessment={
- "complexity": "medium",
- "security_impact": "medium",
- "scope_coherence": "mixed",
- },
- structural_issues=[structural],
- ai_comment_triages=[triage],
- quick_scan_summary={"purpose": "Test", "complexity": "medium"},
- )
-
- # Serialize to dict
- data = result.to_dict()
-
- # Check serialized data
- assert data["verdict"] == "merge_with_changes"
- assert data["blockers"] == ["Security: SQL Injection (src/db.py:42)"]
- assert len(data["structural_issues"]) == 1
- assert len(data["ai_comment_triages"]) == 1
- assert data["structural_issues"][0]["issue_type"] == "feature_creep"
- assert data["ai_comment_triages"][0]["verdict"] == "trivial"
-
- # Deserialize back
- loaded = PRReviewResult.from_dict(data)
-
- assert loaded.verdict == MergeVerdict.MERGE_WITH_CHANGES
- assert loaded.verdict_reasoning == "1 high-priority issue"
- assert len(loaded.structural_issues) == 1
- assert loaded.structural_issues[0].issue_type == "feature_creep"
- assert len(loaded.ai_comment_triages) == 1
- assert loaded.ai_comment_triages[0].verdict == AICommentVerdict.TRIVIAL
-
- print(" ✅ PRReviewResult serialization: PASS")
-
-
-def test_verdict_generation_logic():
- """Test verdict generation produces correct verdicts."""
- print("Testing verdict generation logic...")
-
- # Test case 1: No issues -> READY_TO_MERGE
- findings = []
- structural = []
- triages = []
-
- # Simulate verdict logic
- critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
- high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
- security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY]
- structural_blockers = [
- s
- for s in structural
- if s.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH)
- ]
- ai_critical = [t for t in triages if t.verdict == AICommentVerdict.CRITICAL]
-
- blockers = []
- for f in security_critical:
- blockers.append(f"Security: {f.title}")
- for f in critical:
- if f not in security_critical:
- blockers.append(f"Critical: {f.title}")
- for s in structural_blockers:
- blockers.append(f"Structure: {s.title}")
- for t in ai_critical:
- blockers.append(f"{t.tool_name}: {t.original_comment[:50]}")
-
- if blockers:
- if security_critical:
- verdict = MergeVerdict.BLOCKED
- elif len(critical) > 0:
- verdict = MergeVerdict.BLOCKED
- else:
- verdict = MergeVerdict.NEEDS_REVISION
- elif high:
- verdict = MergeVerdict.MERGE_WITH_CHANGES
- else:
- verdict = MergeVerdict.READY_TO_MERGE
-
- assert verdict == MergeVerdict.READY_TO_MERGE
- assert len(blockers) == 0
- print(" ✓ Case 1: No issues -> READY_TO_MERGE")
-
- # Test case 2: Security critical -> BLOCKED
- findings = [
- PRReviewFinding(
- id="sec-1",
- severity=ReviewSeverity.CRITICAL,
- category=ReviewCategory.SECURITY,
- title="SQL Injection",
- description="Test",
- file="test.py",
- line=1,
- )
- ]
-
- critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
- security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY]
-
- blockers = []
- for f in security_critical:
- blockers.append(f"Security: {f.title}")
-
- if blockers and security_critical:
- verdict = MergeVerdict.BLOCKED
-
- assert verdict == MergeVerdict.BLOCKED
- assert len(blockers) == 1
- assert "SQL Injection" in blockers[0]
- print(" ✓ Case 2: Security critical -> BLOCKED")
-
- # Test case 3: High severity only -> MERGE_WITH_CHANGES
- findings = [
- PRReviewFinding(
- id="q-1",
- severity=ReviewSeverity.HIGH,
- category=ReviewCategory.QUALITY,
- title="Missing error handling",
- description="Test",
- file="test.py",
- line=1,
- )
- ]
-
- critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
- high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
- security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY]
-
- blockers = []
- if not blockers and high:
- verdict = MergeVerdict.MERGE_WITH_CHANGES
-
- assert verdict == MergeVerdict.MERGE_WITH_CHANGES
- print(" ✓ Case 3: High severity only -> MERGE_WITH_CHANGES")
-
- print(" ✅ Verdict generation logic: PASS")
-
-
-def test_risk_assessment_logic():
- """Test risk assessment calculation."""
- print("Testing risk assessment logic...")
-
- # Test complexity levels
- def calculate_complexity(additions, deletions):
- total = additions + deletions
- if total > 500:
- return "high"
- elif total > 200:
- return "medium"
- else:
- return "low"
-
- assert calculate_complexity(50, 20) == "low"
- assert calculate_complexity(150, 100) == "medium"
- assert calculate_complexity(400, 200) == "high"
- print(" ✓ Complexity calculation")
-
- # Test security impact levels
- def calculate_security_impact(findings):
- security = [f for f in findings if f.category == ReviewCategory.SECURITY]
- if any(f.severity == ReviewSeverity.CRITICAL for f in security):
- return "critical"
- elif any(f.severity == ReviewSeverity.HIGH for f in security):
- return "medium"
- elif security:
- return "low"
- else:
- return "none"
-
- assert calculate_security_impact([]) == "none"
-
- findings_low = [
- PRReviewFinding(
- id="s1",
- severity=ReviewSeverity.LOW,
- category=ReviewCategory.SECURITY,
- title="Test",
- description="",
- file="",
- line=1,
- )
- ]
- assert calculate_security_impact(findings_low) == "low"
-
- findings_critical = [
- PRReviewFinding(
- id="s2",
- severity=ReviewSeverity.CRITICAL,
- category=ReviewCategory.SECURITY,
- title="Test",
- description="",
- file="",
- line=1,
- )
- ]
- assert calculate_security_impact(findings_critical) == "critical"
- print(" ✓ Security impact calculation")
-
- print(" ✅ Risk assessment logic: PASS")
-
-
-def test_json_parsing_robustness():
- """Test JSON parsing handles edge cases."""
- print("Testing JSON parsing robustness...")
-
- import re
-
- def parse_json_array(text):
- """Simulate the JSON parsing from AI response."""
- try:
- json_match = re.search(r"```json\s*(\[.*?\])\s*```", text, re.DOTALL)
- if json_match:
- return json.loads(json_match.group(1))
- except (json.JSONDecodeError, ValueError):
- pass
- return []
-
- # Test valid JSON
- valid = """
-Here is my analysis:
-```json
-[{"id": "f1", "title": "Test"}]
-```
-Done.
-"""
- result = parse_json_array(valid)
- assert len(result) == 1
- assert result[0]["id"] == "f1"
- print(" ✓ Valid JSON parsing")
-
- # Test empty array
- empty = """
-```json
-[]
-```
-"""
- result = parse_json_array(empty)
- assert result == []
- print(" ✓ Empty array parsing")
-
- # Test no JSON block
- no_json = "This response has no JSON block."
- result = parse_json_array(no_json)
- assert result == []
- print(" ✓ No JSON block handling")
-
- # Test malformed JSON
- malformed = """
-```json
-[{"id": "f1", "title": "Missing close bracket"
-```
-"""
- result = parse_json_array(malformed)
- assert result == []
- print(" ✓ Malformed JSON handling")
-
- print(" ✅ JSON parsing robustness: PASS")
-
-
-def test_confidence_threshold():
- """Test 80% confidence threshold filtering."""
- print("Testing confidence threshold...")
-
- CONFIDENCE_THRESHOLD = 0.80
-
- findings_data = [
- {"id": "f1", "confidence": 0.95, "title": "High confidence"},
- {"id": "f2", "confidence": 0.80, "title": "At threshold"},
- {"id": "f3", "confidence": 0.79, "title": "Below threshold"},
- {"id": "f4", "confidence": 0.50, "title": "Low confidence"},
- {"id": "f5", "title": "No confidence field"}, # Should default to 0.85
- ]
-
- filtered = []
- for f in findings_data:
- confidence = float(f.get("confidence", 0.85))
- if confidence >= CONFIDENCE_THRESHOLD:
- filtered.append(f)
-
- assert len(filtered) == 3
- assert filtered[0]["id"] == "f1" # 0.95 >= 0.80
- assert filtered[1]["id"] == "f2" # 0.80 >= 0.80
- assert filtered[2]["id"] == "f5" # 0.85 (default) >= 0.80
-
- print(
- f" ✓ Filtered {len(findings_data) - len(filtered)}/{len(findings_data)} findings below threshold"
- )
- print(" ✅ Confidence threshold: PASS")
-
-
-def run_all_tests():
- """Run all validation tests."""
- print("\n" + "=" * 60)
- print("Enhanced PR Review System - Validation Tests")
- print("=" * 60 + "\n")
-
- tests = [
- test_merge_verdict_enum,
- test_ai_comment_verdict_enum,
- test_review_pass_enum,
- test_ai_bot_patterns,
- test_ai_bot_comment_dataclass,
- test_ai_comment_triage_dataclass,
- test_structural_issue_dataclass,
- test_pr_review_result_new_fields,
- test_pr_review_result_serialization,
- test_verdict_generation_logic,
- test_risk_assessment_logic,
- test_json_parsing_robustness,
- test_confidence_threshold,
- ]
-
- passed = 0
- failed = 0
-
- for test in tests:
- try:
- test()
- passed += 1
- except Exception as e:
- print(f" ❌ {test.__name__}: FAILED")
- print(f" Error: {e}")
- failed += 1
-
- print("\n" + "=" * 60)
- print(f"Results: {passed} passed, {failed} failed")
- print("=" * 60)
-
- if failed > 0:
- sys.exit(1)
- else:
- print("\n✅ All validation tests passed! System is ready for production.\n")
- sys.exit(0)
-
-
-if __name__ == "__main__":
- run_all_tests()
diff --git a/apps/backend/runners/github/test_file_lock.py b/apps/backend/runners/github/test_file_lock.py
deleted file mode 100644
index fe6c5ea8..00000000
--- a/apps/backend/runners/github/test_file_lock.py
+++ /dev/null
@@ -1,333 +0,0 @@
-"""
-Test File Locking for Concurrent Operations
-===========================================
-
-Demonstrates file locking preventing data corruption in concurrent scenarios.
-"""
-
-import asyncio
-import json
-import tempfile
-import time
-from pathlib import Path
-
-from file_lock import (
- FileLock,
- FileLockTimeout,
- locked_json_read,
- locked_json_update,
- locked_json_write,
- locked_read,
- locked_write,
-)
-
-
-async def test_basic_file_lock():
- """Test basic file locking mechanism."""
- print("\n=== Test 1: Basic File Lock ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "test.txt"
- test_file.write_text("initial content", encoding="utf-8")
-
- # Acquire lock and hold it
- async with FileLock(test_file, timeout=5.0):
- print("✓ Lock acquired successfully")
- # Do work while holding lock
- await asyncio.sleep(0.1)
- print("✓ Lock held during work")
-
- print("✓ Lock released automatically")
-
-
-async def test_locked_write():
- """Test atomic locked write operations."""
- print("\n=== Test 2: Locked Write ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "data.json"
-
- # Write data with locking
- data = {"count": 0, "items": ["a", "b", "c"]}
- async with locked_write(test_file, timeout=5.0) as f:
- json.dump(data, f, indent=2)
-
- print(f"✓ Written to {test_file.name}")
-
- # Verify data was written correctly
- with open(test_file, encoding="utf-8") as f:
- loaded = json.load(f)
- assert loaded == data
- print(f"✓ Data verified: {loaded}")
-
-
-async def test_locked_json_helpers():
- """Test JSON helper functions."""
- print("\n=== Test 3: JSON Helpers ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "data.json"
-
- # Write JSON
- data = {"users": [], "total": 0}
- await locked_json_write(test_file, data, timeout=5.0)
- print(f"✓ JSON written: {data}")
-
- # Read JSON
- loaded = await locked_json_read(test_file, timeout=5.0)
- assert loaded == data
- print(f"✓ JSON read: {loaded}")
-
-
-async def test_locked_json_update():
- """Test atomic read-modify-write updates."""
- print("\n=== Test 4: Atomic Updates ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "counter.json"
-
- # Initialize counter
- await locked_json_write(test_file, {"count": 0}, timeout=5.0)
- print("✓ Counter initialized to 0")
-
- # Define update function
- def increment_counter(data):
- data["count"] += 1
- return data
-
- # Perform 5 atomic updates
- for i in range(5):
- await locked_json_update(test_file, increment_counter, timeout=5.0)
-
- # Verify final count
- final = await locked_json_read(test_file, timeout=5.0)
- assert final["count"] == 5
- print(f"✓ Counter incremented 5 times: {final}")
-
-
-async def test_concurrent_updates_without_lock():
- """Demonstrate data corruption WITHOUT file locking."""
- print("\n=== Test 5: Concurrent Updates WITHOUT Locking (UNSAFE) ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "unsafe.json"
-
- # Initialize counter
- test_file.write_text(json.dumps({"count": 0}), encoding="utf-8")
-
- async def unsafe_increment():
- """Increment without locking - RACE CONDITION!"""
- # Read
- with open(test_file, encoding="utf-8") as f:
- data = json.load(f)
-
- # Simulate some processing
- await asyncio.sleep(0.01)
-
- # Write
- data["count"] += 1
- with open(test_file, "w", encoding="utf-8") as f:
- json.dump(data, f)
-
- # Run 10 concurrent increments
- await asyncio.gather(*[unsafe_increment() for _ in range(10)])
-
- # Check final count
- with open(test_file, encoding="utf-8") as f:
- final = json.load(f)
-
- print("✗ Expected count: 10")
- print(f"✗ Actual count: {final['count']} (CORRUPTED due to race condition)")
- print(
- f"✗ Lost updates: {10 - final['count']} (multiple processes overwrote each other)"
- )
-
-
-async def test_concurrent_updates_with_lock():
- """Demonstrate data integrity WITH file locking."""
- print("\n=== Test 6: Concurrent Updates WITH Locking (SAFE) ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "safe.json"
-
- # Initialize counter
- await locked_json_write(test_file, {"count": 0}, timeout=5.0)
-
- async def safe_increment():
- """Increment with locking - NO RACE CONDITION!"""
-
- def increment(data):
- # Simulate some processing
- time.sleep(0.01)
- data["count"] += 1
- return data
-
- await locked_json_update(test_file, increment, timeout=5.0)
-
- # Run 10 concurrent increments
- await asyncio.gather(*[safe_increment() for _ in range(10)])
-
- # Check final count
- final = await locked_json_read(test_file, timeout=5.0)
-
- assert final["count"] == 10
- print("✓ Expected count: 10")
- print(f"✓ Actual count: {final['count']} (CORRECT with file locking)")
- print("✓ No data corruption - all updates applied successfully")
-
-
-async def test_lock_timeout():
- """Test lock timeout behavior."""
- print("\n=== Test 7: Lock Timeout ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "timeout.json"
- test_file.write_text(json.dumps({"data": "test"}), encoding="utf-8")
-
- # Acquire lock and hold it
- lock1 = FileLock(test_file, timeout=1.0)
- await lock1.__aenter__()
- print("✓ First lock acquired")
-
- try:
- # Try to acquire second lock with short timeout
- lock2 = FileLock(test_file, timeout=0.5)
- await lock2.__aenter__()
- print("✗ Second lock acquired (should have timed out!)")
- except FileLockTimeout as e:
- print(f"✓ Second lock timed out as expected: {e}")
- finally:
- await lock1.__aexit__(None, None, None)
- print("✓ First lock released")
-
-
-async def test_index_update_pattern():
- """Test the index update pattern used in models.py."""
- print("\n=== Test 8: Index Update Pattern (Production Pattern) ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- index_file = Path(tmpdir) / "index.json"
-
- # Simulate multiple PR reviews updating the index concurrently
- async def add_review(pr_number: int, status: str):
- """Add or update a PR review in the index."""
-
- def update_index(current_data):
- if current_data is None:
- current_data = {"reviews": [], "last_updated": None}
-
- reviews = current_data.get("reviews", [])
- existing = next(
- (r for r in reviews if r["pr_number"] == pr_number), None
- )
-
- entry = {
- "pr_number": pr_number,
- "status": status,
- "timestamp": time.time(),
- }
-
- if existing:
- reviews = [
- entry if r["pr_number"] == pr_number else r for r in reviews
- ]
- else:
- reviews.append(entry)
-
- current_data["reviews"] = reviews
- current_data["last_updated"] = time.time()
-
- return current_data
-
- await locked_json_update(index_file, update_index, timeout=5.0)
-
- # Simulate 5 concurrent review updates
- print("Simulating 5 concurrent PR review updates...")
- await asyncio.gather(
- add_review(101, "approved"),
- add_review(102, "changes_requested"),
- add_review(103, "commented"),
- add_review(104, "approved"),
- add_review(105, "approved"),
- )
-
- # Verify all reviews were recorded
- final_index = await locked_json_read(index_file, timeout=5.0)
- assert len(final_index["reviews"]) == 5
- print("✓ All 5 reviews recorded correctly")
- print(f"✓ Index state: {len(final_index['reviews'])} reviews")
-
- # Update an existing review
- await add_review(102, "approved") # Change status
- updated_index = await locked_json_read(index_file, timeout=5.0)
- assert len(updated_index["reviews"]) == 5 # Still 5, not 6
- review_102 = next(r for r in updated_index["reviews"] if r["pr_number"] == 102)
- assert review_102["status"] == "approved"
- print("✓ Review #102 updated from 'changes_requested' to 'approved'")
- print("✓ No duplicate entries created")
-
-
-async def test_atomic_write_failure():
- """Test that failed writes don't corrupt existing files."""
- print("\n=== Test 9: Atomic Write Failure Handling ===")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- test_file = Path(tmpdir) / "important.json"
-
- # Write initial data
- initial_data = {"important": "data", "version": 1}
- await locked_json_write(test_file, initial_data, timeout=5.0)
- print(f"✓ Initial data written: {initial_data}")
-
- # Try to write invalid data that will fail
- try:
- async with locked_write(test_file, timeout=5.0) as f:
- f.write("{invalid json")
- # Simulate an error during write
- raise Exception("Simulated write failure")
- except Exception as e:
- print(f"✓ Write failed as expected: {e}")
-
- # Verify original data is intact (atomic write rolled back)
- current_data = await locked_json_read(test_file, timeout=5.0)
- assert current_data == initial_data
- print(f"✓ Original data intact after failed write: {current_data}")
- print(
- "✓ Atomic write prevented corruption (temp file discarded, original preserved)"
- )
-
-
-async def main():
- """Run all tests."""
- print("=" * 70)
- print("File Locking Tests - Preventing Concurrent Operation Corruption")
- print("=" * 70)
-
- tests = [
- test_basic_file_lock,
- test_locked_write,
- test_locked_json_helpers,
- test_locked_json_update,
- test_concurrent_updates_without_lock,
- test_concurrent_updates_with_lock,
- test_lock_timeout,
- test_index_update_pattern,
- test_atomic_write_failure,
- ]
-
- for test in tests:
- try:
- await test()
- except Exception as e:
- print(f"✗ Test failed: {e}")
- import traceback
-
- traceback.print_exc()
-
- print("\n" + "=" * 70)
- print("All Tests Completed!")
- print("=" * 70)
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/apps/backend/runners/github/test_gh_client.py b/apps/backend/runners/github/test_gh_client.py
deleted file mode 100644
index 0bed21b9..00000000
--- a/apps/backend/runners/github/test_gh_client.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""
-Tests for GHClient timeout and retry functionality.
-"""
-
-import asyncio
-from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from gh_client import GHClient, GHCommandError, GHTimeoutError
-
-
-class TestGHClient:
- """Test suite for GHClient."""
-
- @pytest.fixture
- def client(self, tmp_path):
- """Create a test client."""
- return GHClient(
- project_dir=tmp_path,
- default_timeout=2.0,
- max_retries=3,
- )
-
- @pytest.mark.asyncio
- async def test_timeout_raises_error(self, client):
- """Test that commands timeout after max retries."""
- # Use a command that will timeout (sleep longer than timeout)
- with pytest.raises(GHTimeoutError) as exc_info:
- await client.run(["api", "/repos/nonexistent/repo"], timeout=0.1)
-
- assert "timed out after 3 attempts" in str(exc_info.value)
-
- @pytest.mark.asyncio
- async def test_invalid_command_raises_error(self, client):
- """Test that invalid commands raise GHCommandError."""
- with pytest.raises(GHCommandError):
- await client.run(["invalid-command"])
-
- @pytest.mark.asyncio
- async def test_successful_command(self, client):
- """Test successful command execution."""
- # This test requires gh CLI to be installed
- try:
- result = await client.run(["--version"])
- assert result.returncode == 0
- assert "gh version" in result.stdout
- assert result.attempts == 1
- except Exception:
- pytest.skip("gh CLI not available")
-
- @pytest.mark.asyncio
- async def test_convenience_methods_timeout_protection(self, client):
- """Test that convenience methods have timeout protection."""
- # These will fail because repo doesn't exist, but should not hang
- with pytest.raises((GHCommandError, GHTimeoutError)):
- await client.pr_list()
-
- with pytest.raises((GHCommandError, GHTimeoutError)):
- await client.issue_list()
-
-
-class TestGHClientGhExecutableDetection:
- """Test suite for GHClient gh executable detection."""
-
- @pytest.fixture
- def client(self, tmp_path):
- """Create a test client."""
- return GHClient(
- project_dir=tmp_path,
- default_timeout=2.0,
- max_retries=3,
- )
-
- @pytest.mark.asyncio
- async def test_run_raises_error_when_gh_not_found(self, client):
- """Test that run() raises GHCommandError when gh is not found."""
- with patch("gh_client.get_gh_executable", return_value=None):
- with pytest.raises(GHCommandError) as exc_info:
- await client.run(["--version"])
-
- assert "not found" in str(exc_info.value)
- # Test verifies error message mentions GitHub CLI for user guidance
- assert "GitHub CLI" in str(exc_info.value)
-
- @pytest.mark.asyncio
- async def test_run_uses_detected_gh_executable(self, client):
- """Test that run() uses the detected gh executable path."""
- mock_exec = "/custom/path/to/gh"
-
- with patch("gh_client.get_gh_executable", return_value=mock_exec):
- with patch("asyncio.create_subprocess_exec") as mock_subprocess:
- # Mock the subprocess to return immediately
- mock_proc = MagicMock()
- mock_proc.communicate = AsyncMock(
- return_value=(b"gh version 2.0.0\n", b"")
- )
- mock_proc.returncode = 0
- mock_subprocess.return_value = mock_proc
-
- await client.run(["--version"])
-
- # Verify the correct gh path was used
- mock_subprocess.assert_called_once()
- called_cmd = mock_subprocess.call_args[0][0]
- assert called_cmd == mock_exec
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/apps/backend/runners/github/test_permissions.py b/apps/backend/runners/github/test_permissions.py
deleted file mode 100644
index 38c8ac4c..00000000
--- a/apps/backend/runners/github/test_permissions.py
+++ /dev/null
@@ -1,393 +0,0 @@
-"""
-Unit Tests for GitHub Permission System
-=======================================
-
-Tests for GitHubPermissionChecker and permission verification.
-"""
-
-from unittest.mock import AsyncMock, MagicMock
-
-import pytest
-from permissions import GitHubPermissionChecker, PermissionCheckResult, PermissionError
-
-
-class MockGitHubClient:
- """Mock GitHub API client for testing."""
-
- def __init__(self):
- self.get = AsyncMock()
- self._get_headers = AsyncMock()
-
-
-@pytest.fixture
-def mock_gh_client():
- """Create a mock GitHub client."""
- return MockGitHubClient()
-
-
-@pytest.fixture
-def permission_checker(mock_gh_client):
- """Create a permission checker instance."""
- return GitHubPermissionChecker(
- gh_client=mock_gh_client,
- repo="owner/test-repo",
- allowed_roles=["OWNER", "MEMBER", "COLLABORATOR"],
- allow_external_contributors=False,
- )
-
-
-@pytest.mark.asyncio
-async def test_verify_token_scopes_success(permission_checker, mock_gh_client):
- """Test successful token scope verification."""
- mock_gh_client._get_headers.return_value = {
- "X-OAuth-Scopes": "repo, read:org, admin:repo_hook"
- }
-
- # Should not raise
- await permission_checker.verify_token_scopes()
-
-
-@pytest.mark.asyncio
-async def test_verify_token_scopes_minimum(permission_checker, mock_gh_client):
- """Test token with minimum scopes (repo only) triggers warning."""
- mock_gh_client._get_headers.return_value = {"X-OAuth-Scopes": "repo"}
-
- # Should warn but not raise (for non-org repos)
- await permission_checker.verify_token_scopes()
-
-
-@pytest.mark.asyncio
-async def test_verify_token_scopes_insufficient(permission_checker, mock_gh_client):
- """Test insufficient token scopes raises error."""
- mock_gh_client._get_headers.return_value = {"X-OAuth-Scopes": "read:user"}
-
- with pytest.raises(PermissionError, match="missing required scopes"):
- await permission_checker.verify_token_scopes()
-
-
-@pytest.mark.asyncio
-async def test_check_label_adder_success(permission_checker, mock_gh_client):
- """Test successfully finding who added a label."""
- mock_gh_client.get.side_effect = [
- # Issue events
- [
- {
- "event": "labeled",
- "label": {"name": "auto-fix"},
- "actor": {"login": "alice"},
- },
- {
- "event": "commented",
- "actor": {"login": "bob"},
- },
- ],
- # Collaborator permission check for alice
- {"permission": "write"},
- ]
-
- username, role = await permission_checker.check_label_adder(123, "auto-fix")
-
- assert username == "alice"
- assert role == "COLLABORATOR"
- mock_gh_client.get.assert_any_call("/repos/owner/test-repo/issues/123/events")
-
-
-@pytest.mark.asyncio
-async def test_check_label_adder_not_found(permission_checker, mock_gh_client):
- """Test error when label not found in events."""
- mock_gh_client.get.return_value = [
- {
- "event": "labeled",
- "label": {"name": "bug"},
- "actor": {"login": "alice"},
- },
- ]
-
- with pytest.raises(PermissionError, match="Label 'auto-fix' not found"):
- await permission_checker.check_label_adder(123, "auto-fix")
-
-
-@pytest.mark.asyncio
-async def test_get_user_role_owner(permission_checker, mock_gh_client):
- """Test getting role for repository owner."""
- role = await permission_checker.get_user_role("owner")
-
- assert role == "OWNER"
- # Should use cache, no API calls needed
- assert mock_gh_client.get.call_count == 0
-
-
-@pytest.mark.asyncio
-async def test_get_user_role_collaborator(permission_checker, mock_gh_client):
- """Test getting role for collaborator with write access."""
- mock_gh_client.get.return_value = {"permission": "write"}
-
- role = await permission_checker.get_user_role("alice")
-
- assert role == "COLLABORATOR"
- mock_gh_client.get.assert_called_with(
- "/repos/owner/test-repo/collaborators/alice/permission"
- )
-
-
-@pytest.mark.asyncio
-async def test_get_user_role_org_member(permission_checker, mock_gh_client):
- """Test getting role for organization member."""
- mock_gh_client.get.side_effect = [
- # Not a collaborator
- Exception("Not a collaborator"),
- # Repo info (org-owned)
- {"owner": {"type": "Organization"}},
- # Org membership check
- {"state": "active"},
- ]
-
- role = await permission_checker.get_user_role("bob")
-
- assert role == "MEMBER"
-
-
-@pytest.mark.asyncio
-async def test_get_user_role_contributor(permission_checker, mock_gh_client):
- """Test getting role for external contributor."""
- mock_gh_client.get.side_effect = [
- # Not a collaborator
- Exception("Not a collaborator"),
- # Repo info (user-owned, not org)
- {"owner": {"type": "User"}},
- # Contributors list
- [
- {"login": "alice"},
- {"login": "charlie"}, # The user we're checking
- ],
- ]
-
- role = await permission_checker.get_user_role("charlie")
-
- assert role == "CONTRIBUTOR"
-
-
-@pytest.mark.asyncio
-async def test_get_user_role_none(permission_checker, mock_gh_client):
- """Test getting role for user with no relationship to repo."""
- mock_gh_client.get.side_effect = [
- # Not a collaborator
- Exception("Not a collaborator"),
- # Repo info
- {"owner": {"type": "User"}},
- # Contributors list (user not in it)
- [{"login": "alice"}],
- ]
-
- role = await permission_checker.get_user_role("stranger")
-
- assert role == "NONE"
-
-
-@pytest.mark.asyncio
-async def test_get_user_role_caching(permission_checker, mock_gh_client):
- """Test that user roles are cached."""
- mock_gh_client.get.return_value = {"permission": "write"}
-
- # First call
- role1 = await permission_checker.get_user_role("alice")
- assert role1 == "COLLABORATOR"
-
- # Second call should use cache
- role2 = await permission_checker.get_user_role("alice")
- assert role2 == "COLLABORATOR"
-
- # Only one API call should have been made
- assert mock_gh_client.get.call_count == 1
-
-
-@pytest.mark.asyncio
-async def test_is_allowed_for_autofix_owner(permission_checker, mock_gh_client):
- """Test auto-fix permission for owner."""
- result = await permission_checker.is_allowed_for_autofix("owner")
-
- assert result.allowed is True
- assert result.username == "owner"
- assert result.role == "OWNER"
- assert result.reason is None
-
-
-@pytest.mark.asyncio
-async def test_is_allowed_for_autofix_collaborator(permission_checker, mock_gh_client):
- """Test auto-fix permission for collaborator."""
- mock_gh_client.get.return_value = {"permission": "write"}
-
- result = await permission_checker.is_allowed_for_autofix("alice")
-
- assert result.allowed is True
- assert result.username == "alice"
- assert result.role == "COLLABORATOR"
-
-
-@pytest.mark.asyncio
-async def test_is_allowed_for_autofix_denied(permission_checker, mock_gh_client):
- """Test auto-fix permission denied for unauthorized user."""
- mock_gh_client.get.side_effect = [
- Exception("Not a collaborator"),
- {"owner": {"type": "User"}},
- [], # Not in contributors
- ]
-
- result = await permission_checker.is_allowed_for_autofix("stranger")
-
- assert result.allowed is False
- assert result.username == "stranger"
- assert result.role == "NONE"
- assert "not in allowed roles" in result.reason
-
-
-@pytest.mark.asyncio
-async def test_is_allowed_for_autofix_contributor_allowed(mock_gh_client):
- """Test auto-fix permission for contributor when external contributors allowed."""
- checker = GitHubPermissionChecker(
- gh_client=mock_gh_client,
- repo="owner/test-repo",
- allow_external_contributors=True,
- )
-
- mock_gh_client.get.side_effect = [
- Exception("Not a collaborator"),
- {"owner": {"type": "User"}},
- [{"login": "charlie"}], # Is a contributor
- ]
-
- result = await checker.is_allowed_for_autofix("charlie")
-
- assert result.allowed is True
- assert result.role == "CONTRIBUTOR"
-
-
-@pytest.mark.asyncio
-async def test_check_org_membership_true(permission_checker, mock_gh_client):
- """Test successful org membership check."""
- mock_gh_client.get.side_effect = [
- # Repo info
- {"owner": {"type": "Organization"}},
- # Org membership
- {"state": "active"},
- ]
-
- is_member = await permission_checker.check_org_membership("alice")
-
- assert is_member is True
-
-
-@pytest.mark.asyncio
-async def test_check_org_membership_false(permission_checker, mock_gh_client):
- """Test failed org membership check."""
- mock_gh_client.get.side_effect = [
- # Repo info
- {"owner": {"type": "Organization"}},
- # Org membership check fails
- Exception("Not a member"),
- ]
-
- is_member = await permission_checker.check_org_membership("stranger")
-
- assert is_member is False
-
-
-@pytest.mark.asyncio
-async def test_check_org_membership_non_org_repo(permission_checker, mock_gh_client):
- """Test org membership check for non-org repo returns True."""
- mock_gh_client.get.return_value = {"owner": {"type": "User"}}
-
- is_member = await permission_checker.check_org_membership("anyone")
-
- assert is_member is True
-
-
-@pytest.mark.asyncio
-async def test_check_team_membership_true(permission_checker, mock_gh_client):
- """Test successful team membership check."""
- mock_gh_client.get.return_value = {"state": "active"}
-
- is_member = await permission_checker.check_team_membership("alice", "developers")
-
- assert is_member is True
- mock_gh_client.get.assert_called_with(
- "/orgs/owner/teams/developers/memberships/alice"
- )
-
-
-@pytest.mark.asyncio
-async def test_check_team_membership_false(permission_checker, mock_gh_client):
- """Test failed team membership check."""
- mock_gh_client.get.side_effect = Exception("Not a team member")
-
- is_member = await permission_checker.check_team_membership("bob", "developers")
-
- assert is_member is False
-
-
-@pytest.mark.asyncio
-async def test_verify_automation_trigger_allowed(permission_checker, mock_gh_client):
- """Test complete automation trigger verification (allowed)."""
- mock_gh_client.get.side_effect = [
- # Issue events
- [
- {
- "event": "labeled",
- "label": {"name": "auto-fix"},
- "actor": {"login": "alice"},
- }
- ],
- # Collaborator permission
- {"permission": "write"},
- ]
-
- result = await permission_checker.verify_automation_trigger(123, "auto-fix")
-
- assert result.allowed is True
- assert result.username == "alice"
- assert result.role == "COLLABORATOR"
-
-
-@pytest.mark.asyncio
-async def test_verify_automation_trigger_denied(permission_checker, mock_gh_client):
- """Test complete automation trigger verification (denied)."""
- mock_gh_client.get.side_effect = [
- # Issue events
- [
- {
- "event": "labeled",
- "label": {"name": "auto-fix"},
- "actor": {"login": "stranger"},
- }
- ],
- # Not a collaborator
- Exception("Not a collaborator"),
- # Repo info
- {"owner": {"type": "User"}},
- # Not in contributors
- [],
- ]
-
- result = await permission_checker.verify_automation_trigger(123, "auto-fix")
-
- assert result.allowed is False
- assert result.username == "stranger"
- assert result.role == "NONE"
-
-
-def test_log_permission_denial(permission_checker, caplog):
- """Test permission denial logging."""
- import logging
-
- caplog.set_level(logging.WARNING)
-
- permission_checker.log_permission_denial(
- action="auto-fix",
- username="stranger",
- role="NONE",
- issue_number=123,
- )
-
- assert "PERMISSION DENIED" in caplog.text
- assert "stranger" in caplog.text
- assert "auto-fix" in caplog.text
diff --git a/apps/backend/runners/github/test_rate_limiter.py b/apps/backend/runners/github/test_rate_limiter.py
deleted file mode 100644
index 281f68f4..00000000
--- a/apps/backend/runners/github/test_rate_limiter.py
+++ /dev/null
@@ -1,506 +0,0 @@
-"""
-Tests for Rate Limiter
-======================
-
-Comprehensive test suite for rate limiting system covering:
-- Token bucket algorithm
-- GitHub API rate limiting
-- AI cost tracking
-- Decorator functionality
-- Exponential backoff
-- Edge cases
-"""
-
-import asyncio
-import time
-
-import pytest
-from rate_limiter import (
- CostLimitExceeded,
- CostTracker,
- RateLimiter,
- RateLimitExceeded,
- TokenBucket,
- check_rate_limit,
- rate_limited,
-)
-
-
-class TestTokenBucket:
- """Test token bucket algorithm."""
-
- def test_initial_state(self):
- """Bucket starts full."""
- bucket = TokenBucket(capacity=100, refill_rate=10.0)
- assert bucket.available() == 100
-
- def test_try_acquire_success(self):
- """Can acquire tokens when available."""
- bucket = TokenBucket(capacity=100, refill_rate=10.0)
- assert bucket.try_acquire(10) is True
- assert bucket.available() == 90
-
- def test_try_acquire_failure(self):
- """Cannot acquire when insufficient tokens."""
- bucket = TokenBucket(capacity=100, refill_rate=10.0)
- bucket.try_acquire(100)
- assert bucket.try_acquire(1) is False
- assert bucket.available() == 0
-
- @pytest.mark.asyncio
- async def test_acquire_waits(self):
- """Acquire waits for refill when needed."""
- bucket = TokenBucket(capacity=10, refill_rate=10.0) # 10 tokens/sec
- bucket.try_acquire(10) # Empty the bucket
-
- start = time.monotonic()
- result = await bucket.acquire(1) # Should wait ~0.1s for 1 token
- elapsed = time.monotonic() - start
-
- assert result is True
- assert elapsed >= 0.05 # At least some delay
- assert elapsed < 0.5 # But not too long
-
- @pytest.mark.asyncio
- async def test_acquire_timeout(self):
- """Acquire respects timeout."""
- bucket = TokenBucket(capacity=10, refill_rate=1.0) # 1 token/sec
- bucket.try_acquire(10) # Empty the bucket
-
- start = time.monotonic()
- result = await bucket.acquire(100, timeout=0.1) # Need 100s, timeout 0.1s
- elapsed = time.monotonic() - start
-
- assert result is False
- assert elapsed < 0.5 # Should timeout quickly
-
- def test_refill_over_time(self):
- """Tokens refill at correct rate."""
- bucket = TokenBucket(capacity=100, refill_rate=100.0) # 100 tokens/sec
- bucket.try_acquire(50) # Take 50
- assert bucket.available() == 50
-
- time.sleep(0.5) # Wait 0.5s = 50 tokens
- available = bucket.available()
- assert 95 <= available <= 100 # Should be near full
-
- def test_time_until_available(self):
- """Calculate wait time correctly."""
- bucket = TokenBucket(capacity=100, refill_rate=10.0)
- bucket.try_acquire(100) # Empty
-
- wait = bucket.time_until_available(10)
- assert 0.9 <= wait <= 1.1 # Should be ~1s for 10 tokens at 10/s
-
-
-class TestCostTracker:
- """Test AI cost tracking."""
-
- def test_calculate_cost_sonnet(self):
- """Calculate cost for Sonnet model."""
- cost = CostTracker.calculate_cost(
- input_tokens=1_000_000,
- output_tokens=1_000_000,
- model="claude-sonnet-4-5-20250929",
- )
- # $3 input + $15 output = $18 for 1M each
- assert cost == 18.0
-
- def test_calculate_cost_opus(self):
- """Calculate cost for Opus model."""
- cost = CostTracker.calculate_cost(
- input_tokens=1_000_000,
- output_tokens=1_000_000,
- model="claude-opus-4-5-20251101",
- )
- # $15 input + $75 output = $90 for 1M each
- assert cost == 90.0
-
- def test_calculate_cost_haiku(self):
- """Calculate cost for Haiku model."""
- cost = CostTracker.calculate_cost(
- input_tokens=1_000_000,
- output_tokens=1_000_000,
- model="claude-haiku-4-5-20251001",
- )
- # $0.80 input + $4 output = $4.80 for 1M each
- assert cost == 4.80
-
- def test_calculate_cost_unknown_model(self):
- """Unknown model uses default pricing."""
- cost = CostTracker.calculate_cost(
- input_tokens=1_000_000,
- output_tokens=1_000_000,
- model="unknown-model",
- )
- # Default: $3 input + $15 output = $18
- assert cost == 18.0
-
- def test_add_operation_under_limit(self):
- """Can add operation under budget."""
- tracker = CostTracker(cost_limit=10.0)
- cost = tracker.add_operation(
- input_tokens=100_000, # $0.30
- output_tokens=50_000, # $0.75
- model="claude-sonnet-4-5-20250929",
- operation_name="test",
- )
- assert 1.0 <= cost <= 1.1
- assert tracker.total_cost == cost
- assert len(tracker.operations) == 1
-
- def test_add_operation_exceeds_limit(self):
- """Cannot add operation that exceeds budget."""
- tracker = CostTracker(cost_limit=1.0)
- with pytest.raises(CostLimitExceeded):
- tracker.add_operation(
- input_tokens=1_000_000, # $3 - exceeds $1 limit
- output_tokens=0,
- model="claude-sonnet-4-5-20250929",
- )
-
- def test_remaining_budget(self):
- """Remaining budget calculated correctly."""
- tracker = CostTracker(cost_limit=10.0)
- tracker.add_operation(
- input_tokens=100_000,
- output_tokens=50_000,
- model="claude-sonnet-4-5-20250929",
- )
- remaining = tracker.remaining_budget()
- assert 8.9 <= remaining <= 9.1
-
- def test_usage_report(self):
- """Usage report generated."""
- tracker = CostTracker(cost_limit=10.0)
- tracker.add_operation(
- input_tokens=100_000,
- output_tokens=50_000,
- model="claude-sonnet-4-5-20250929",
- operation_name="operation1",
- )
- report = tracker.usage_report()
- assert "Total Cost:" in report
- assert "Budget:" in report
- assert "operation1" in report
-
-
-class TestRateLimiter:
- """Test RateLimiter singleton."""
-
- def setup_method(self):
- """Reset singleton before each test."""
- RateLimiter.reset_instance()
-
- def test_singleton_pattern(self):
- """Only one instance exists."""
- limiter1 = RateLimiter.get_instance()
- limiter2 = RateLimiter.get_instance()
- assert limiter1 is limiter2
-
- @pytest.mark.asyncio
- async def test_acquire_github(self):
- """Can acquire GitHub tokens."""
- limiter = RateLimiter.get_instance(github_limit=10)
- assert await limiter.acquire_github() is True
- assert limiter.github_requests == 1
-
- @pytest.mark.asyncio
- async def test_acquire_github_rate_limited(self):
- """GitHub rate limiting works."""
- limiter = RateLimiter.get_instance(
- github_limit=2,
- github_refill_rate=0.0, # No refill
- )
- assert await limiter.acquire_github() is True
- assert await limiter.acquire_github() is True
- # Third should timeout immediately
- assert await limiter.acquire_github(timeout=0.1) is False
- assert limiter.github_rate_limited == 1
-
- def test_check_github_available(self):
- """Check GitHub availability without consuming."""
- limiter = RateLimiter.get_instance(github_limit=100)
- available, msg = limiter.check_github_available()
- assert available is True
- assert "100" in msg
-
- def test_track_ai_cost(self):
- """Track AI costs."""
- limiter = RateLimiter.get_instance(cost_limit=10.0)
- cost = limiter.track_ai_cost(
- input_tokens=100_000,
- output_tokens=50_000,
- model="claude-sonnet-4-5-20250929",
- operation_name="test",
- )
- assert cost > 0
- assert limiter.cost_tracker.total_cost == cost
-
- def test_track_ai_cost_exceeds_limit(self):
- """Cost limit enforcement."""
- limiter = RateLimiter.get_instance(cost_limit=1.0)
- with pytest.raises(CostLimitExceeded):
- limiter.track_ai_cost(
- input_tokens=1_000_000,
- output_tokens=1_000_000,
- model="claude-sonnet-4-5-20250929",
- )
-
- def test_check_cost_available(self):
- """Check cost availability."""
- limiter = RateLimiter.get_instance(cost_limit=10.0)
- available, msg = limiter.check_cost_available()
- assert available is True
- assert "$10" in msg
-
- def test_record_github_error(self):
- """Record GitHub errors."""
- limiter = RateLimiter.get_instance()
- limiter.record_github_error()
- assert limiter.github_errors == 1
-
- def test_statistics(self):
- """Statistics collection."""
- limiter = RateLimiter.get_instance()
- stats = limiter.statistics()
- assert "github" in stats
- assert "cost" in stats
- assert "runtime_seconds" in stats
-
- def test_report(self):
- """Report generation."""
- limiter = RateLimiter.get_instance()
- report = limiter.report()
- assert "Rate Limiter Report" in report
- assert "GitHub API:" in report
- assert "AI Cost:" in report
-
-
-class TestRateLimitedDecorator:
- """Test @rate_limited decorator."""
-
- def setup_method(self):
- """Reset singleton before each test."""
- RateLimiter.reset_instance()
-
- @pytest.mark.asyncio
- async def test_decorator_success(self):
- """Decorator allows successful calls."""
-
- @rate_limited(operation_type="github")
- async def test_func():
- return "success"
-
- result = await test_func()
- assert result == "success"
-
- @pytest.mark.asyncio
- async def test_decorator_rate_limited(self):
- """Decorator handles rate limiting."""
- limiter = RateLimiter.get_instance(
- github_limit=1,
- github_refill_rate=0.0, # No refill
- )
-
- @rate_limited(operation_type="github", max_retries=0)
- async def test_func():
- # Consume token manually first
- if limiter.github_requests == 0:
- await limiter.acquire_github()
- return "success"
-
- # First call succeeds
- result = await test_func()
- assert result == "success"
-
- # Second call should fail (no tokens, no retry)
- with pytest.raises(RateLimitExceeded):
- await test_func()
-
- @pytest.mark.asyncio
- async def test_decorator_retries(self):
- """Decorator retries on rate limit."""
- limiter = RateLimiter.get_instance(
- github_limit=1,
- github_refill_rate=10.0, # Fast refill for test
- )
- call_count = 0
-
- @rate_limited(operation_type="github", max_retries=2, base_delay=0.1)
- async def test_func():
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- # Consume all tokens
- await limiter.acquire_github()
- raise Exception("403 rate limit exceeded")
- return "success"
-
- result = await test_func()
- assert result == "success"
- assert call_count == 2 # Initial + 1 retry
-
- @pytest.mark.asyncio
- async def test_decorator_cost_limit_no_retry(self):
- """Cost limit is not retried."""
- limiter = RateLimiter.get_instance(cost_limit=0.1)
-
- @rate_limited(operation_type="github")
- async def test_func():
- # Exceed cost limit
- limiter.track_ai_cost(
- input_tokens=1_000_000,
- output_tokens=1_000_000,
- model="claude-sonnet-4-5-20250929",
- )
- return "success"
-
- with pytest.raises(CostLimitExceeded):
- await test_func()
-
-
-class TestCheckRateLimit:
- """Test check_rate_limit helper."""
-
- def setup_method(self):
- """Reset singleton before each test."""
- RateLimiter.reset_instance()
-
- @pytest.mark.asyncio
- async def test_check_github_success(self):
- """Check passes when available."""
- RateLimiter.get_instance(github_limit=100)
- await check_rate_limit(operation_type="github") # Should not raise
-
- @pytest.mark.asyncio
- async def test_check_github_failure(self):
- """Check fails when rate limited."""
- limiter = RateLimiter.get_instance(
- github_limit=0, # No tokens
- github_refill_rate=0.0,
- )
- with pytest.raises(RateLimitExceeded):
- await check_rate_limit(operation_type="github")
-
- @pytest.mark.asyncio
- async def test_check_cost_success(self):
- """Check passes when budget available."""
- RateLimiter.get_instance(cost_limit=10.0)
- await check_rate_limit(operation_type="cost") # Should not raise
-
- @pytest.mark.asyncio
- async def test_check_cost_failure(self):
- """Check fails when budget exceeded."""
- limiter = RateLimiter.get_instance(cost_limit=0.01)
- limiter.cost_tracker.total_cost = 10.0 # Manually exceed
- with pytest.raises(CostLimitExceeded):
- await check_rate_limit(operation_type="cost")
-
-
-class TestIntegration:
- """Integration tests simulating real usage."""
-
- def setup_method(self):
- """Reset singleton before each test."""
- RateLimiter.reset_instance()
-
- @pytest.mark.asyncio
- async def test_github_workflow(self):
- """Simulate GitHub automation workflow."""
- limiter = RateLimiter.get_instance(
- github_limit=10,
- github_refill_rate=10.0,
- cost_limit=5.0,
- )
-
- @rate_limited(operation_type="github")
- async def fetch_pr():
- return {"number": 123}
-
- @rate_limited(operation_type="github")
- async def fetch_diff():
- return {"files": []}
-
- # Simulate workflow
- pr = await fetch_pr()
- assert pr["number"] == 123
-
- diff = await fetch_diff()
- assert "files" in diff
-
- # Track AI review
- limiter.track_ai_cost(
- input_tokens=5000,
- output_tokens=2000,
- model="claude-sonnet-4-5-20250929",
- operation_name="PR review",
- )
-
- # Check stats
- stats = limiter.statistics()
- assert stats["github"]["total_requests"] >= 2
- assert stats["cost"]["total_cost"] > 0
-
- @pytest.mark.asyncio
- async def test_burst_handling(self):
- """Handle burst of requests."""
- limiter = RateLimiter.get_instance(
- github_limit=5,
- github_refill_rate=5.0,
- )
-
- @rate_limited(operation_type="github", max_retries=1, base_delay=0.1)
- async def api_call(n: int):
- return n
-
- # Make 10 calls (will hit limit at 5, then wait for refill)
- results = []
- for i in range(10):
- result = await api_call(i)
- results.append(result)
-
- assert len(results) == 10
- assert results == list(range(10))
-
- @pytest.mark.asyncio
- async def test_cost_tracking_multiple_models(self):
- """Track costs across different models."""
- limiter = RateLimiter.get_instance(cost_limit=100.0)
-
- # Sonnet for review
- limiter.track_ai_cost(
- input_tokens=10_000,
- output_tokens=5_000,
- model="claude-sonnet-4-5-20250929",
- operation_name="PR review",
- )
-
- # Haiku for triage
- limiter.track_ai_cost(
- input_tokens=5_000,
- output_tokens=2_000,
- model="claude-haiku-4-5-20251001",
- operation_name="Issue triage",
- )
-
- # Opus for complex analysis
- limiter.track_ai_cost(
- input_tokens=20_000,
- output_tokens=10_000,
- model="claude-opus-4-5-20251101",
- operation_name="Architecture review",
- )
-
- stats = limiter.statistics()
- assert stats["cost"]["operations"] == 3
- assert stats["cost"]["total_cost"] < 100.0
-
- report = limiter.cost_tracker.usage_report()
- assert "PR review" in report
- assert "Issue triage" in report
- assert "Architecture review" in report
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/apps/backend/scripts/test_memory_save.py b/apps/backend/scripts/test_memory_save.py
deleted file mode 100644
index 52715671..00000000
--- a/apps/backend/scripts/test_memory_save.py
+++ /dev/null
@@ -1,379 +0,0 @@
-#!/usr/bin/env python3
-"""
-Memory Save Verification Script
-================================
-
-Tests the memory save functionality with Graphiti enabled.
-Run with DEBUG=true SENTRY_DEV=true to verify Sentry events.
-
-Usage:
- cd apps/backend
- DEBUG=true python scripts/test_memory_save.py
-
- # With Sentry enabled (for Sentry event verification):
- DEBUG=true SENTRY_DEV=true python scripts/test_memory_save.py
-"""
-
-import asyncio
-import logging
-import os
-import sys
-import tempfile
-from pathlib import Path
-
-# Add the backend directory to the path so we can import modules
-SCRIPT_DIR = Path(__file__).resolve().parent
-BACKEND_DIR = SCRIPT_DIR.parent
-if str(BACKEND_DIR) not in sys.path:
- sys.path.insert(0, str(BACKEND_DIR))
-
-# Configure logging
-logging.basicConfig(
- level=logging.DEBUG if os.environ.get("DEBUG") else logging.INFO,
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
-)
-logger = logging.getLogger(__name__)
-
-
-async def test_memory_imports():
- """Test that all memory-related imports work correctly."""
- print("\n=== Testing Memory System Imports ===")
-
- errors = []
-
- # Test memory_manager imports
- try:
- from agents.memory_manager import (
- debug_memory_system_status,
- get_graphiti_context,
- save_session_memory,
- )
-
- print("[OK] agents.memory_manager imports successful")
- except ImportError as e:
- errors.append(f"agents.memory_manager: {e}")
- print(f"[FAIL] agents.memory_manager: {e}")
-
- # Test graphiti_helpers imports
- try:
- from memory.graphiti_helpers import (
- get_graphiti_memory,
- is_graphiti_memory_enabled,
- save_to_graphiti_async,
- )
-
- print("[OK] memory.graphiti_helpers imports successful")
- except ImportError as e:
- errors.append(f"memory.graphiti_helpers: {e}")
- print(f"[FAIL] memory.graphiti_helpers: {e}")
-
- # Test graphiti_config imports
- try:
- from graphiti_config import (
- get_graphiti_status,
- is_graphiti_enabled,
- )
-
- print("[OK] graphiti_config imports successful")
- except ImportError as e:
- errors.append(f"graphiti_config: {e}")
- print(f"[FAIL] graphiti_config: {e}")
-
- # Test sentry imports
- try:
- from core.sentry import (
- capture_exception,
- capture_message,
- init_sentry,
- )
- from core.sentry import (
- is_enabled as sentry_is_enabled,
- )
-
- print("[OK] core.sentry imports successful")
- except ImportError as e:
- errors.append(f"core.sentry: {e}")
- print(f"[FAIL] core.sentry: {e}")
-
- # Test graphiti queries_pkg imports
- try:
- from integrations.graphiti.queries_pkg.client import GraphitiClient
- from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory
- from integrations.graphiti.queries_pkg.queries import GraphitiQueries
- from integrations.graphiti.queries_pkg.search import GraphitiSearch
-
- print("[OK] integrations.graphiti.queries_pkg imports successful")
- except ImportError as e:
- errors.append(f"integrations.graphiti.queries_pkg: {e}")
- print(f"[FAIL] integrations.graphiti.queries_pkg: {e}")
-
- if errors:
- print(f"\n[FAIL] {len(errors)} import error(s) found")
- return False
- else:
- print("\n[OK] All imports successful")
- return True
-
-
-async def test_graphiti_status():
- """Test Graphiti configuration status."""
- print("\n=== Testing Graphiti Status ===")
-
- try:
- from graphiti_config import get_graphiti_status, is_graphiti_enabled
-
- enabled = is_graphiti_enabled()
- status = get_graphiti_status()
-
- print(f"Graphiti Enabled: {enabled}")
- print(f"Graphiti Available: {status.get('available')}")
- print(f" Host: {status.get('host')}")
- print(f" Port: {status.get('port')}")
- print(f" Database: {status.get('database')}")
- print(f" LLM Provider: {status.get('llm_provider')}")
- print(f" Embedder Provider: {status.get('embedder_provider')}")
-
- if not status.get("available"):
- print(f" Reason: {status.get('reason')}")
- print(f" Errors: {status.get('errors')}")
-
- return enabled
- except Exception as e:
- print(f"[FAIL] Error checking Graphiti status: {e}")
- return False
-
-
-async def test_sentry_status():
- """Test Sentry configuration status.
-
- Returns True if:
- - Sentry is enabled and ready, OR
- - Sentry is properly disabled due to configuration (no DSN, not dev mode, SDK not installed)
-
- Only returns False if there's an unexpected error.
- """
- print("\n=== Testing Sentry Status ===")
-
- try:
- from core.sentry import init_sentry, is_enabled, is_initialized
-
- # Check if SENTRY_DEV is set
- sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
- sentry_dsn = os.environ.get("SENTRY_DSN", "")
-
- print(f"SENTRY_DSN set: {bool(sentry_dsn)}")
- print(f"SENTRY_DEV: {sentry_dev}")
-
- # Initialize Sentry
- init_sentry(component="memory-test")
-
- print(f"Sentry Initialized: {is_initialized()}")
- print(f"Sentry Enabled: {is_enabled()}")
-
- if is_enabled():
- print("[OK] Sentry is enabled and ready to capture events")
- else:
- # Sentry being disabled is OK - it just means configuration requires it
- if not sentry_dsn:
- print("[INFO] Sentry disabled - no SENTRY_DSN configured (expected)")
- elif not sentry_dev:
- print(
- "[INFO] Sentry disabled in dev mode - set SENTRY_DEV=true to enable"
- )
- else:
- print("[INFO] Sentry disabled - sentry-sdk may not be installed")
- print(
- "[OK] Sentry integration configured correctly (disabled by configuration)"
- )
-
- # Return True even if disabled - we're testing that the integration works,
- # not that Sentry is necessarily enabled
- return True
- except Exception as e:
- print(f"[FAIL] Error checking Sentry status: {e}")
- return False
-
-
-async def test_memory_save_flow():
- """Test the memory save flow end-to-end."""
- print("\n=== Testing Memory Save Flow ===")
-
- # Create temporary directories for testing
- with tempfile.TemporaryDirectory() as tmp_dir:
- tmp_path = Path(tmp_dir)
- spec_dir = tmp_path / "test_spec"
- spec_dir.mkdir(parents=True)
- project_dir = tmp_path / "project"
- project_dir.mkdir(parents=True)
-
- print(f"Test spec_dir: {spec_dir}")
- print(f"Test project_dir: {project_dir}")
-
- try:
- from agents.memory_manager import save_session_memory
-
- # Test memory save with sample data
- subtask_id = "test-subtask-1"
- session_num = 1
- success = True
- subtasks_completed = ["test-subtask-1"]
- discoveries = {
- "files_understood": {"test.py": "Test file for memory verification"},
- "patterns_found": ["Test pattern: Always verify imports"],
- "gotchas_encountered": ["Test gotcha: Check async/await usage"],
- }
-
- print("\nSaving test session memory...")
- print(f" subtask_id: {subtask_id}")
- print(f" session_num: {session_num}")
- print(f" success: {success}")
-
- result, storage_type = await save_session_memory(
- spec_dir=spec_dir,
- project_dir=project_dir,
- subtask_id=subtask_id,
- session_num=session_num,
- success=success,
- subtasks_completed=subtasks_completed,
- discoveries=discoveries,
- )
-
- print("\nMemory Save Result:")
- print(f" Success: {result}")
- print(f" Storage Type: {storage_type}")
-
- if result:
- print(f"[OK] Memory save succeeded using {storage_type} storage")
-
- # Verify file was created if file-based
- if storage_type == "file":
- memory_file = (
- spec_dir
- / "memory"
- / "session_insights"
- / f"session_{session_num:03d}.json"
- )
- if memory_file.exists():
- print(f"[OK] Memory file created: {memory_file}")
- else:
- print(f"[WARN] Memory file not found: {memory_file}")
-
- return True
- else:
- print("[FAIL] Memory save failed")
- return False
-
- except Exception as e:
- print(f"[FAIL] Error during memory save test: {e}")
- import traceback
-
- traceback.print_exc()
-
- # Test Sentry capture
- try:
- from core.sentry import capture_exception, is_enabled
-
- if is_enabled():
- capture_exception(
- e,
- operation="test_memory_save",
- context="verification_script",
- )
- print("[INFO] Exception captured to Sentry")
- except Exception:
- pass
-
- return False
-
-
-async def test_sentry_capture():
- """Test that Sentry capture works (only if Sentry is enabled)."""
- print("\n=== Testing Sentry Capture ===")
-
- try:
- from core.sentry import (
- capture_exception,
- capture_message,
- is_enabled,
- )
-
- if not is_enabled():
- print("[SKIP] Sentry not enabled - skipping capture test")
- print(" Set SENTRY_DSN and SENTRY_DEV=true to test Sentry capture")
- return True
-
- # Test capture_message
- print("Sending test message to Sentry...")
- capture_message(
- "Memory save verification script test message",
- level="info",
- test_type="verification",
- component="memory-test",
- )
- print("[OK] Test message sent to Sentry")
-
- # Test capture_exception
- print("Sending test exception to Sentry...")
- try:
- raise ValueError("Test exception for memory save verification")
- except Exception as e:
- capture_exception(
- e,
- operation="test_exception",
- context="verification_script",
- )
- print("[OK] Test exception sent to Sentry")
-
- print("\n[INFO] Check your Sentry dashboard for the test events")
- return True
-
- except Exception as e:
- print(f"[FAIL] Error testing Sentry capture: {e}")
- return False
-
-
-async def main():
- """Run all memory save verification tests."""
- print("=" * 60)
- print("Memory Save Verification Script")
- print("=" * 60)
- print(f"DEBUG: {os.environ.get('DEBUG', 'not set')}")
- print(f"SENTRY_DEV: {os.environ.get('SENTRY_DEV', 'not set')}")
- print(f"GRAPHITI_ENABLED: {os.environ.get('GRAPHITI_ENABLED', 'not set')}")
-
- results = {}
-
- # Run tests
- results["imports"] = await test_memory_imports()
- results["graphiti_status"] = await test_graphiti_status()
- results["sentry_status"] = await test_sentry_status()
- results["memory_save"] = await test_memory_save_flow()
- results["sentry_capture"] = await test_sentry_capture()
-
- # Summary
- print("\n" + "=" * 60)
- print("Test Summary")
- print("=" * 60)
-
- passed = 0
- failed = 0
- for test_name, result in results.items():
- status = "[OK]" if result else "[FAIL]"
- print(f" {status} {test_name}")
- if result:
- passed += 1
- else:
- failed += 1
-
- print(f"\nTotal: {passed} passed, {failed} failed")
-
- if failed > 0:
- print("\n[FAIL] Some tests failed")
- sys.exit(1)
- else:
- print("\n[OK] All tests passed")
- sys.exit(0)
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/apps/backend/test_discovery.py b/apps/backend/test_discovery.py
deleted file mode 100644
index 00f16fe0..00000000
--- a/apps/backend/test_discovery.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Backward compatibility shim - import from analysis.test_discovery instead."""
-
-from analysis.test_discovery import (
- FRAMEWORK_PATTERNS,
- TestDiscovery,
- TestDiscoveryResult,
- TestFramework,
- discover_tests,
- get_test_command,
- get_test_frameworks,
-)
-
-__all__ = [
- "TestFramework",
- "TestDiscoveryResult",
- "TestDiscovery",
- "discover_tests",
- "get_test_command",
- "get_test_frameworks",
- "FRAMEWORK_PATTERNS",
-]
diff --git a/package-lock.json b/package-lock.json
index 311e1536..78d1453e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -262,6 +262,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -826,6 +827,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -869,6 +871,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -908,6 +911,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -2194,6 +2198,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2215,6 +2220,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.4.0.tgz",
"integrity": "sha512-jn0phJ+hU7ZuvaoZE/8/Euw3gvHJrn2yi+kXrymwObEPVPjtwCmkvXDRQCWli+fCTTF/aSOtXaLr7CLIvv3LQg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
@@ -2227,6 +2233,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.4.0.tgz",
"integrity": "sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2242,6 +2249,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz",
"integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"import-in-the-middle": "^2.0.0",
@@ -2644,6 +2652,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.4.0.tgz",
"integrity": "sha512-RWvGLj2lMDZd7M/5tjkI/2VHMpXebLgPKvBUd9LRasEWR2xAynDwEYZuLvY9P2NGG73HF07jbbgWX2C9oavcQg==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.4.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2660,6 +2669,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.4.0.tgz",
"integrity": "sha512-WH0xXkz/OHORDLKqaxcUZS0X+t1s7gGlumr2ebiEgNZQl2b0upK2cdoD0tatf7l8iP74woGJ/Kmxe82jdvcWRw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.4.0",
"@opentelemetry/resources": "2.4.0",
@@ -2677,6 +2687,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=14"
}
@@ -4871,6 +4882,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -5173,6 +5185,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -5183,6 +5196,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -5452,6 +5466,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5484,6 +5499,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5954,6 +5970,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6924,6 +6941,7 @@
"integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"app-builder-lib": "26.4.0",
"builder-util": "26.3.4",
@@ -7081,6 +7099,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^24.9.0",
@@ -8439,6 +8458,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -8765,6 +8785,7 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -11251,6 +11272,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11441,6 +11463,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11450,6 +11473,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -12445,7 +12469,8 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -12624,6 +12649,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -12782,6 +12808,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13102,6 +13129,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -13694,6 +13722,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -14158,6 +14187,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
deleted file mode 100644
index b922e486..00000000
--- a/tests/test_discovery.py
+++ /dev/null
@@ -1,578 +0,0 @@
-#!/usr/bin/env python3
-"""
-Tests for the test_discovery module.
-
-Tests cover:
-- Framework detection for various languages
-- Package manager detection
-- Test directory discovery
-- Test file detection
-- Command extraction
-"""
-
-import json
-import tempfile
-from pathlib import Path
-
-import pytest
-
-# Add auto-claude to path for imports
-import sys
-sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
-
-from test_discovery import (
- TestFramework,
- TestDiscoveryResult,
- TestDiscovery,
- discover_tests,
- get_test_command,
- get_test_frameworks,
-)
-
-
-# =============================================================================
-# FIXTURES
-# =============================================================================
-
-
-@pytest.fixture
-def temp_dir():
- """Create a temporary directory for tests."""
- with tempfile.TemporaryDirectory() as tmpdir:
- yield Path(tmpdir)
-
-
-@pytest.fixture
-def discovery():
- """Create a TestDiscovery instance."""
- return TestDiscovery()
-
-
-# =============================================================================
-# PACKAGE MANAGER DETECTION
-# =============================================================================
-
-
-class TestPackageManagerDetection:
- """Tests for package manager detection."""
-
- def test_detect_npm(self, discovery, temp_dir):
- """Test npm detection via package-lock.json."""
- (temp_dir / "package-lock.json").write_text("{}")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "npm"
-
- def test_detect_yarn(self, discovery, temp_dir):
- """Test yarn detection via yarn.lock."""
- (temp_dir / "yarn.lock").write_text("")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "yarn"
-
- def test_detect_pnpm(self, discovery, temp_dir):
- """Test pnpm detection via pnpm-lock.yaml."""
- (temp_dir / "pnpm-lock.yaml").write_text("")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "pnpm"
-
- def test_detect_bun(self, discovery, temp_dir):
- """Test bun detection via bun.lockb."""
- (temp_dir / "bun.lockb").write_bytes(b"")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "bun"
-
- def test_detect_bun_text_lockfile(self, discovery, temp_dir):
- """Test bun detection via bun.lock (text format, Bun 1.2.0+)."""
- (temp_dir / "bun.lock").write_text("")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "bun"
-
- def test_detect_uv(self, discovery, temp_dir):
- """Test uv detection via uv.lock."""
- (temp_dir / "uv.lock").write_text("")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "uv"
-
- def test_detect_poetry(self, discovery, temp_dir):
- """Test poetry detection via poetry.lock."""
- (temp_dir / "poetry.lock").write_text("")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "poetry"
-
- def test_detect_cargo(self, discovery, temp_dir):
- """Test cargo detection via Cargo.lock."""
- (temp_dir / "Cargo.lock").write_text("")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "cargo"
-
- def test_detect_bundler(self, discovery, temp_dir):
- """Test bundler detection via Gemfile.lock."""
- (temp_dir / "Gemfile.lock").write_text("")
- result = discovery.discover(temp_dir)
- assert result.package_manager == "bundler"
-
-
-# =============================================================================
-# JAVASCRIPT FRAMEWORK DETECTION
-# =============================================================================
-
-
-class TestJSFrameworkDetection:
- """Tests for JavaScript test framework detection."""
-
- def test_detect_jest_from_dependencies(self, discovery, temp_dir):
- """Test Jest detection from package.json dependencies."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
-
- assert len(result.frameworks) > 0
- framework_names = [f.name for f in result.frameworks]
- assert "jest" in framework_names
-
- def test_detect_jest_version(self, discovery, temp_dir):
- """Test Jest version extraction."""
- pkg = {"devDependencies": {"jest": "^29.5.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
- jest = next(f for f in result.frameworks if f.name == "jest")
- assert jest.version == "29.5.0"
-
- def test_detect_jest_config_file(self, discovery, temp_dir):
- """Test Jest config file detection."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
- (temp_dir / "jest.config.js").write_text("module.exports = {}")
-
- result = discovery.discover(temp_dir)
- jest = next(f for f in result.frameworks if f.name == "jest")
- assert jest.config_file == "jest.config.js"
-
- def test_detect_vitest(self, discovery, temp_dir):
- """Test Vitest detection."""
- pkg = {"devDependencies": {"vitest": "^1.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "vitest" in framework_names
-
- def test_detect_playwright(self, discovery, temp_dir):
- """Test Playwright detection."""
- pkg = {"devDependencies": {"@playwright/test": "^1.40.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "playwright" in framework_names
-
- playwright = next(f for f in result.frameworks if f.name == "playwright")
- assert playwright.type == "e2e"
-
- def test_detect_cypress(self, discovery, temp_dir):
- """Test Cypress detection."""
- pkg = {"devDependencies": {"cypress": "^13.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "cypress" in framework_names
-
- cypress = next(f for f in result.frameworks if f.name == "cypress")
- assert cypress.type == "e2e"
-
- def test_detect_from_test_script(self, discovery, temp_dir):
- """Test framework detection from npm test script."""
- pkg = {
- "scripts": {"test": "vitest run"},
- "devDependencies": {},
- }
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
-
- # Should infer from script
- framework_names = [f.name for f in result.frameworks]
- assert "vitest" in framework_names or "npm_test" in framework_names
-
- def test_ignore_empty_test_script(self, discovery, temp_dir):
- """Test that default empty test script is ignored."""
- pkg = {
- "scripts": {"test": 'echo "Error: no test specified" && exit 1'},
- "devDependencies": {},
- }
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
- assert len(result.frameworks) == 0
-
-
-# =============================================================================
-# PYTHON FRAMEWORK DETECTION
-# =============================================================================
-
-
-class TestPythonFrameworkDetection:
- """Tests for Python test framework detection."""
-
- def test_detect_pytest_from_requirements(self, discovery, temp_dir):
- """Test pytest detection from requirements.txt."""
- (temp_dir / "requirements.txt").write_text("pytest==7.4.0\n")
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "pytest" in framework_names
-
- def test_detect_pytest_from_pyproject(self, discovery, temp_dir):
- """Test pytest detection from pyproject.toml."""
- pyproject = """
-[project]
-dependencies = ["pytest>=7.0.0"]
-
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-"""
- (temp_dir / "pyproject.toml").write_text(pyproject)
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "pytest" in framework_names
-
- def test_detect_pytest_from_conftest(self, discovery, temp_dir):
- """Test pytest detection from conftest.py presence."""
- (temp_dir / "conftest.py").write_text("import pytest\n")
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "pytest" in framework_names
-
- def test_detect_pytest_from_tests_conftest(self, discovery, temp_dir):
- """Test pytest detection from tests/conftest.py."""
- tests_dir = temp_dir / "tests"
- tests_dir.mkdir()
- (tests_dir / "conftest.py").write_text("import pytest\n")
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "pytest" in framework_names
-
- def test_detect_pytest_ini(self, discovery, temp_dir):
- """Test pytest.ini config file detection."""
- (temp_dir / "pytest.ini").write_text("[pytest]\ntestpaths = tests\n")
- (temp_dir / "requirements.txt").write_text("pytest\n")
-
- result = discovery.discover(temp_dir)
-
- pytest_fw = next(f for f in result.frameworks if f.name == "pytest")
- assert pytest_fw.config_file == "pytest.ini"
-
-
-# =============================================================================
-# OTHER LANGUAGE FRAMEWORK DETECTION
-# =============================================================================
-
-
-class TestOtherLanguageFrameworks:
- """Tests for Rust, Go, and Ruby framework detection."""
-
- def test_detect_cargo_test(self, discovery, temp_dir):
- """Test Rust cargo test detection."""
- (temp_dir / "Cargo.toml").write_text('[package]\nname = "test"')
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "cargo_test" in framework_names
-
- cargo = next(f for f in result.frameworks if f.name == "cargo_test")
- assert cargo.command == "cargo test"
-
- def test_detect_go_test(self, discovery, temp_dir):
- """Test Go test detection."""
- (temp_dir / "go.mod").write_text("module test")
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "go_test" in framework_names
-
- go = next(f for f in result.frameworks if f.name == "go_test")
- assert go.command == "go test ./..."
-
- def test_detect_rspec(self, discovery, temp_dir):
- """Test RSpec detection."""
- (temp_dir / "Gemfile").write_text('gem "rspec"')
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "rspec" in framework_names
-
- def test_detect_rspec_with_dotfile(self, discovery, temp_dir):
- """Test RSpec detection via .rspec file."""
- (temp_dir / "Gemfile").write_text('gem "rails"')
- (temp_dir / ".rspec").write_text("--format documentation\n")
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "rspec" in framework_names
-
- rspec = next(f for f in result.frameworks if f.name == "rspec")
- assert rspec.config_file == ".rspec"
-
- def test_detect_minitest(self, discovery, temp_dir):
- """Test Minitest detection."""
- (temp_dir / "Gemfile").write_text('gem "minitest"')
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "minitest" in framework_names
-
-
-# =============================================================================
-# TEST DIRECTORY DETECTION
-# =============================================================================
-
-
-class TestDirectoryDetection:
- """Tests for test directory detection."""
-
- def test_find_tests_directory(self, discovery, temp_dir):
- """Test finding 'tests' directory."""
- (temp_dir / "tests").mkdir()
-
- result = discovery.discover(temp_dir)
-
- assert "tests" in result.test_directories
-
- def test_find_test_directory(self, discovery, temp_dir):
- """Test finding 'test' directory."""
- (temp_dir / "test").mkdir()
-
- result = discovery.discover(temp_dir)
-
- assert "test" in result.test_directories
-
- def test_find_spec_directory(self, discovery, temp_dir):
- """Test finding 'spec' directory."""
- (temp_dir / "spec").mkdir()
-
- result = discovery.discover(temp_dir)
-
- assert "spec" in result.test_directories
-
- def test_find_dunder_tests_directory(self, discovery, temp_dir):
- """Test finding '__tests__' directory."""
- (temp_dir / "__tests__").mkdir()
-
- result = discovery.discover(temp_dir)
-
- assert "__tests__" in result.test_directories
-
-
-# =============================================================================
-# TEST FILE DETECTION
-# =============================================================================
-
-
-class TestFileDetection:
- """Tests for test file detection."""
-
- def test_detect_python_test_files(self, discovery, temp_dir):
- """Test detecting Python test files."""
- tests_dir = temp_dir / "tests"
- tests_dir.mkdir()
- (tests_dir / "test_main.py").write_text("def test_example(): pass")
-
- result = discovery.discover(temp_dir)
-
- assert result.has_tests is True
-
- def test_detect_js_test_files(self, discovery, temp_dir):
- """Test detecting JavaScript test files."""
- src_dir = temp_dir / "src"
- src_dir.mkdir()
- (src_dir / "app.test.js").write_text("test('example', () => {})")
-
- result = discovery.discover(temp_dir)
-
- assert result.has_tests is True
-
- def test_detect_ts_test_files(self, discovery, temp_dir):
- """Test detecting TypeScript test files."""
- (temp_dir / "component.spec.ts").write_text("describe('test', () => {})")
-
- result = discovery.discover(temp_dir)
-
- assert result.has_tests is True
-
- def test_no_tests_in_empty_project(self, discovery, temp_dir):
- """Test that empty project has no tests."""
- result = discovery.discover(temp_dir)
-
- assert result.has_tests is False
-
-
-# =============================================================================
-# SERIALIZATION
-# =============================================================================
-
-
-class TestSerialization:
- """Tests for result serialization."""
-
- def test_to_dict(self, discovery, temp_dir):
- """Test converting result to dictionary."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
- result_dict = discovery.to_dict(result)
-
- assert isinstance(result_dict, dict)
- assert "frameworks" in result_dict
- assert "test_command" in result_dict
- assert "test_directories" in result_dict
- assert "has_tests" in result_dict
-
- def test_framework_dict_structure(self, discovery, temp_dir):
- """Test framework dictionary structure."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
- (temp_dir / "jest.config.js").write_text("{}")
-
- result = discovery.discover(temp_dir)
- result_dict = discovery.to_dict(result)
-
- assert len(result_dict["frameworks"]) > 0
- framework = result_dict["frameworks"][0]
-
- assert "name" in framework
- assert "type" in framework
- assert "command" in framework
- assert "config_file" in framework
-
- def test_json_serializable(self, discovery, temp_dir):
- """Test that result is JSON serializable."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
- result_dict = discovery.to_dict(result)
-
- # Should not raise
- json_str = json.dumps(result_dict)
- assert isinstance(json_str, str)
-
-
-# =============================================================================
-# CONVENIENCE FUNCTIONS
-# =============================================================================
-
-
-class TestConvenienceFunctions:
- """Tests for convenience functions."""
-
- def test_discover_tests(self, temp_dir):
- """Test discover_tests function."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discover_tests(temp_dir)
-
- assert isinstance(result, TestDiscoveryResult)
-
- def test_get_test_command(self, temp_dir):
- """Test get_test_command function."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- cmd = get_test_command(temp_dir)
-
- assert "jest" in cmd
-
- def test_get_test_frameworks(self, temp_dir):
- """Test get_test_frameworks function."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- frameworks = get_test_frameworks(temp_dir)
-
- assert isinstance(frameworks, list)
- assert "jest" in frameworks
-
-
-# =============================================================================
-# EDGE CASES
-# =============================================================================
-
-
-class TestEdgeCases:
- """Tests for edge cases."""
-
- def test_invalid_package_json(self, discovery, temp_dir):
- """Test handling of invalid package.json."""
- (temp_dir / "package.json").write_text("not valid json")
-
- # Should not raise
- result = discovery.discover(temp_dir)
- assert isinstance(result, TestDiscoveryResult)
-
- def test_nonexistent_directory(self, discovery):
- """Test handling of non-existent directory."""
- fake_dir = Path("/nonexistent/path")
-
- # Should not raise
- result = discovery.discover(fake_dir)
- assert isinstance(result, TestDiscoveryResult)
- assert len(result.frameworks) == 0
-
- def test_multiple_frameworks(self, discovery, temp_dir):
- """Test detecting multiple frameworks."""
- pkg = {
- "devDependencies": {
- "jest": "^29.0.0",
- "@playwright/test": "^1.40.0",
- }
- }
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result = discovery.discover(temp_dir)
-
- framework_names = [f.name for f in result.frameworks]
- assert "jest" in framework_names
- assert "playwright" in framework_names
-
- def test_caching(self, discovery, temp_dir):
- """Test that results are cached."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- # First call
- result1 = discovery.discover(temp_dir)
-
- # Second call should use cache
- result2 = discovery.discover(temp_dir)
-
- assert result1 is result2
-
- def test_clear_cache(self, discovery, temp_dir):
- """Test cache clearing."""
- pkg = {"devDependencies": {"jest": "^29.0.0"}}
- (temp_dir / "package.json").write_text(json.dumps(pkg))
-
- result1 = discovery.discover(temp_dir)
- discovery.clear_cache()
- result2 = discovery.discover(temp_dir)
-
- assert result1 is not result2