"""Fetch library sources — clone from GitHub or resolve local Quicklisp path.""" from __future__ import annotations import subprocess from pathlib import Path from cl_macros.ext.library_index import LibraryInfo REPOS_DIR = Path("data/repos") class SourceFetcher: def __init__(self, repos_dir: Path | None = None): self.repos_dir = repos_dir or REPOS_DIR def fetch(self, lib: LibraryInfo) -> Path: """Clone or update library, return path to source root.""" dest = self.repos_dir / lib.name dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists() and (dest / ".git").exists(): self._update(dest) else: self._clone(lib.repo, dest) return dest def get_source_files(self, lib: LibraryInfo) -> list[Path]: """Return all .lisp source files from a library.""" root = self.fetch(lib) src_root = root / lib.cl_source_dir if lib.cl_source_dir else root if not src_root.exists(): src_root = root return sorted( p for p in src_root.rglob("*.lisp") if not p.name.endswith("-test.lisp") and "t/" not in str(p.relative_to(root)) or self._is_main_test_dir(p, root) ) def get_test_files(self, lib: LibraryInfo) -> list[Path]: """Return test .lisp files from a library.""" root = self.fetch(lib) test_files = list(root.rglob("t/*.lisp")) + list(root.rglob("tests/*.lisp")) return sorted(set(test_files)) def get_all_lisp_files(self, lib: LibraryInfo) -> list[Path]: """Return all .lisp files (source + test).""" return sorted(set(self.get_source_files(lib) + self.get_test_files(lib))) def _clone(self, url: str, dest: Path) -> None: try: subprocess.run( ["git", "clone", "--depth", "1", url, str(dest)], check=True, capture_output=True, timeout=120, ) except subprocess.CalledProcessError as e: raise RuntimeError( f"Failed to clone {url}: {e.stderr.decode() if e.stderr else e}" ) from e def _update(self, dest: Path) -> None: try: subprocess.run( ["git", "-C", str(dest), "pull", "--ff-only"], check=True, capture_output=True, timeout=60, ) except subprocess.CalledProcessError: pass # Stale clone is better than nothing @staticmethod def _is_main_test_dir(p: Path, root: Path) -> bool: """Allow test files if they're in the main test directory.""" rel = p.relative_to(root) parts = rel.parts return len(parts) > 0 and parts[0] == "t"