| """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 |
|
|
| @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" |
|
|