"""Local clone of the uploads repo and read-only queries against `origin/` (ARCHITECTURE ยง2.3). git runs as a subprocess with an argument list โ€” never through a shell โ€” and with the bot's own deploy key. """ import asyncio import base64 import logging import os import tempfile import time from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Literal from app.config import GitSettings log = logging.getLogger(__name__) EntryKind = Literal["dir", "file"] class GitError(Exception): pass @dataclass(frozen=True) class Entry: path: str # repo-relative, "/"-separated kind: EntryKind size: int # bytes; 0 for directories @property def name(self) -> str: return self.path.rsplit("/", 1)[-1] @dataclass(frozen=True) class CommitInfo: sha: str author: str date: datetime subject: str class UploadsRepo: def __init__(self, settings: GitSettings) -> None: self._s = settings # absolute: git clone runs from the parent dir, a relative path would land one level deeper self._dir = settings.clone_dir.resolve() self._lock = asyncio.Lock() self._fetched_at: float | None = None self._key_path = self._materialize_key() @property def branch(self) -> str: return self._s.branch @property def ref(self) -> str: return f"origin/{self._s.branch}" @property def path(self) -> Path: return self._dir def _materialize_key(self) -> Path | None: if self._s.ssh_key_b64 is None: return self._s.ssh_key_path key = base64.b64decode(self._s.ssh_key_b64.get_secret_value()) fd, name = tempfile.mkstemp(prefix="tgit-ssh-") # created 0600, owned by the bot's user with os.fdopen(fd, "wb") as f: f.write(key if key.endswith(b"\n") else key + b"\n") return Path(name) def _env(self) -> dict[str, str]: env = {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR")} env["GIT_TERMINAL_PROMPT"] = "0" if self._key_path is not None: known_hosts = self._dir.parent / "known_hosts" env["GIT_SSH_COMMAND"] = ( # -F /dev/null: ignore the host's ~/.ssh/config โ€” otherwise a personal key configured there for the # same host gets offered too and silently masks a broken deploy key (happened in #7). f"ssh -F /dev/null -i {self._key_path} -o IdentitiesOnly=yes -o BatchMode=yes " f"-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={known_hosts}" ) return env async def git(self, *args: str, cwd: Path | None = None) -> bytes: proc = await asyncio.create_subprocess_exec( "git", *args, cwd=cwd or self._dir, env=self._env(), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: out, err = await asyncio.wait_for(proc.communicate(), timeout=self._s.timeout) except TimeoutError: proc.kill() await proc.wait() raise GitError(f"git {args[0]}: timeout") from None if proc.returncode != 0: raise GitError(f"git {args[0]}: {err.decode(errors='replace').strip()[-500:]}") return out async def ensure(self) -> None: """Clone on first use; afterwards just make sure the clone is there.""" async with self._lock: if (self._dir / ".git").is_dir(): return self._dir.parent.mkdir(parents=True, exist_ok=True) log.info("cloning uploads repo into %s", self._dir) await self.git( "clone", "--branch", self._s.branch, self._s.uploads_url, str(self._dir), cwd=self._dir.parent ) self._fetched_at = time.monotonic() async def refresh(self, force: bool = False) -> None: """git fetch, but not more often than fetch_interval unless forced.""" await self.ensure() async with self._lock: now = time.monotonic() if not force and self._fetched_at is not None and now - self._fetched_at < self._s.fetch_interval: return await self.git("fetch", "--prune", "origin") self._fetched_at = now async def head(self) -> str: return (await self.git("rev-parse", self.ref)).decode().strip() async def list_dir(self, path: str = "") -> list[Entry]: """Directories first, then files, each alphabetically.""" spec = f"{self.ref}:{path}" if path else self.ref try: out = await self.git("ls-tree", "-z", "-l", spec) except GitError as e: raise GitError(f"no such directory: /{path}") from e entries: list[Entry] = [] for record in out.split(b"\0"): if not record: continue meta, name = record.split(b"\t", 1) _mode, kind, _obj, size = meta.split() full = f"{path}/{name.decode()}" if path else name.decode() if kind == b"tree": entries.append(Entry(full, "dir", 0)) elif kind == b"blob": entries.append(Entry(full, "file", int(size))) return sorted(entries, key=lambda e: (e.kind != "dir", e.name.lower())) async def read(self, path: str) -> bytes: return await self.git("cat-file", "blob", f"{self.ref}:{path}") async def size(self, path: str) -> int | None: try: return int((await self.git("cat-file", "-s", f"{self.ref}:{path}")).decode().strip()) except GitError: return None async def last_commit(self, path: str) -> CommitInfo | None: out = await self.git("log", "-1", "--format=%H%x00%an%x00%aI%x00%s", self.ref, "--", path) if not out.strip(): return None sha, author, date, subject = out.decode().rstrip("\n").split("\0", 3) return CommitInfo(sha=sha, author=author, date=datetime.fromisoformat(date), subject=subject)