import shutil import subprocess from pathlib import Path from caelestia.utils.dots.manifest import Manifest from caelestia.utils.paths import dots_dir, get_config class SourceError(Exception): """Raised when a git operation against the dots clone fails.""" class DotsSource: _fetched_source: bool = False def __init__(self) -> None: cfg = get_config().get("dots", {}) self.url = cfg.get("url", "https://github.com/caelestia-dots/caelestia.git") self.branch = cfg.get("branch", "main") # Cache git blobs by (ref, relpath); objects are immutable for a given rev self._blob_cache: dict[tuple[str, str], bytes] = {} @property def remote_ref(self) -> str: return f"origin/{self.branch}" def exists(self) -> bool: return (dots_dir / ".git").is_dir() def working_path(self, relpath: str | Path) -> Path: """Get a Path relative to the dots dir.""" return dots_dir / relpath def ensure(self) -> None: """Clone the repo if absent, otherwise fetch the latest refs. If the configured url changed, the stale clone is removed and re-cloned from the new source. """ if self.exists(): if self.current_url() == self.url: if DotsSource._fetched_source: return self._git("fetch", "--prune", "origin", self.branch) DotsSource._fetched_source = True return shutil.rmtree(dots_dir) dots_dir.parent.mkdir(parents=True, exist_ok=True) self._run("git", "clone", "--branch", self.branch, self.url, str(dots_dir)) DotsSource._fetched_source = True def current_url(self) -> str: return self._git("remote", "get-url", "origin").strip() def checkout_tip(self) -> str: """Reset the working tree to the fetched tip and return its commit hash.""" self._git("reset", "--hard", self.remote_ref) return self.tip_rev() def tip_rev(self) -> str: return self._git("rev-parse", self.remote_ref).strip() def changed_files(self, base: str, head: str) -> list[str]: """Repo-relative paths that differ between two revisions.""" out = self._git("diff", "--name-only", base, head) return [line for line in out.splitlines() if line] def has_rev(self, rev: str) -> bool: """Whether `rev` resolves to a commit.""" try: self._git("rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}") return True except SourceError: return False def clean(self) -> None: """Remove all untracked files in the git repo.""" self._git("clean", "-fdx") # --- Accessors --- def manifest_at(self, ref: str) -> Manifest: return Manifest.parse(self.text_at(ref, "manifest.toml")) def text_at(self, ref: str, relpath: str) -> str: return self._git("show", f"{ref}:{relpath}") def blob_at(self, ref: str, relpath: str) -> bytes: key = (ref, relpath) if key not in self._blob_cache: self._blob_cache[key] = self._git_bytes("show", f"{ref}:{relpath}") return self._blob_cache[key] def files_at(self, ref: str, relpath: str) -> list[str]: """Repo-relative paths of all files under relpath at ref (the path itself if it is a file).""" out = self._git("ls-tree", "-r", "--name-only", ref, "--", relpath) return [line for line in out.splitlines() if line] # --- Helpers --- def _git(self, *args: str) -> str: # core.quotePath=false so non-ASCII paths come back verbatim, not octal-escaped return self._run("git", "-C", str(dots_dir), "-c", "core.quotePath=false", *args) def _git_bytes(self, *args: str) -> bytes: cmd = ["git", "-C", str(dots_dir), "-c", "core.quotePath=false", *args] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode != 0: raise SourceError(result.stderr.decode().strip() or f"git {' '.join(args)} failed") return result.stdout def _run(self, *cmd: str) -> str: result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if result.returncode != 0: raise SourceError(result.stderr.strip() or f"{' '.join(cmd)} failed") return result.stdout