from __future__ import annotations import subprocess from pathlib import Path import pytest import pyro_mcp.project_startup as project_startup from pyro_mcp.project_startup import ( ProjectStartupSource, describe_project_startup_source, materialize_project_startup_source, resolve_project_startup_source, ) def _git(repo: Path, *args: str) -> str: result = subprocess.run( # noqa: S603 ["git", "-c", "commit.gpgsign=false", *args], cwd=repo, check=True, capture_output=True, text=True, ) return result.stdout.strip() def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path: root.mkdir() _git(root, "init") _git(root, "config", "user.name", "Pyro Tests") _git(root, "config", "user.email", "pyro-tests@example.com") (root / filename).write_text(content, encoding="utf-8") _git(root, "add", filename) _git(root, "commit", "-m", "init") return root def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None: repo = _make_repo(tmp_path / "repo") nested = repo / "src" / "pkg" nested.mkdir(parents=True) resolved = resolve_project_startup_source(cwd=nested) assert resolved == ProjectStartupSource( kind="project_path", origin_ref=str(repo.resolve()), resolved_path=repo.resolve(), ) def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None: repo = _make_repo(tmp_path / "repo") nested = repo / "nested" nested.mkdir() resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path) assert resolved == ProjectStartupSource( kind="project_path", origin_ref=str(repo.resolve()), resolved_path=repo.resolve(), ) def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None: repo = _make_repo(tmp_path / "repo") with pytest.raises(ValueError, match="mutually exclusive"): resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git") with pytest.raises(ValueError, match="requires --repo-url"): resolve_project_startup_source(repo_ref="main") with pytest.raises(ValueError, match="cannot be combined"): resolve_project_startup_source(project_path=repo, no_project_source=True) def test_resolve_project_startup_source_handles_explicit_none_and_empty_values( tmp_path: Path, ) -> None: repo = _make_repo(tmp_path / "repo") outside = tmp_path / "outside" outside.mkdir() assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None assert resolve_project_startup_source(cwd=outside) is None with pytest.raises(ValueError, match="must not be empty"): resolve_project_startup_source(repo_url=" ", cwd=repo) with pytest.raises(ValueError, match="must not be empty"): resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ") def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path( tmp_path: Path, ) -> None: missing = tmp_path / "missing" file_path = tmp_path / "note.txt" file_path.write_text("hello\n", encoding="utf-8") with pytest.raises(ValueError, match="does not exist"): resolve_project_startup_source(project_path=missing, cwd=tmp_path) with pytest.raises(ValueError, match="must be a directory"): resolve_project_startup_source(project_path=file_path, cwd=tmp_path) def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo( tmp_path: Path, ) -> None: plain = tmp_path / "plain" plain.mkdir() resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path) assert resolved == ProjectStartupSource( kind="project_path", origin_ref=str(plain.resolve()), resolved_path=plain.resolve(), ) def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None: repo = _make_repo(tmp_path / "repo", content="one\n") first_commit = _git(repo, "rev-parse", "HEAD") (repo / "note.txt").write_text("two\n", encoding="utf-8") _git(repo, "add", "note.txt") _git(repo, "commit", "-m", "update") source = ProjectStartupSource( kind="repo_url", origin_ref=str(repo.resolve()), repo_ref=first_commit, ) with materialize_project_startup_source(source) as clone_dir: assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n" def test_materialize_project_startup_source_validates_project_source_and_clone_failures( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: with pytest.raises(RuntimeError, match="missing a resolved path"): with materialize_project_startup_source( ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None) ): pass source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") def _clone_failure( command: list[str], *, cwd: Path | None = None, ) -> subprocess.CompletedProcess[str]: del cwd return subprocess.CompletedProcess(command, 1, "", "clone failed") monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure) with pytest.raises(RuntimeError, match="failed to clone repo_url"): with materialize_project_startup_source(source): pass def test_materialize_project_startup_source_reports_checkout_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: repo = _make_repo(tmp_path / "repo", content="one\n") source = ProjectStartupSource( kind="repo_url", origin_ref=str(repo.resolve()), repo_ref="missing-ref", ) original_run_git = project_startup._run_git def _checkout_failure( command: list[str], *, cwd: Path | None = None, ) -> subprocess.CompletedProcess[str]: if command[:2] == ["git", "checkout"]: return subprocess.CompletedProcess(command, 1, "", "checkout failed") return original_run_git(command, cwd=cwd) monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure) with pytest.raises(RuntimeError, match="failed to checkout repo_ref"): with materialize_project_startup_source(source): pass def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None: repo = _make_repo(tmp_path / "repo") project_description = describe_project_startup_source( ProjectStartupSource( kind="project_path", origin_ref=str(repo.resolve()), resolved_path=repo.resolve(), ) ) repo_description = describe_project_startup_source( ProjectStartupSource( kind="repo_url", origin_ref="https://example.com/repo.git", repo_ref="main", ) ) assert project_description == f"the current project at {repo.resolve()}" assert repo_description == "the clean clone source https://example.com/repo.git at ref main" def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None: assert describe_project_startup_source(None) is None assert ( describe_project_startup_source( ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") ) == "the clean clone source https://example.com/repo.git" ) def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( project_startup, "_run_git", lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""), ) assert project_startup._detect_git_root(Path.cwd()) is None