- make demo-repo: локальный bare-репозиторий загрузок из infra/uploads-template - make run: бот на этой машине (SQLite, настройки из .env) - fix: при относительном clone_dir клон уезжал в data/data/uploads (git clone запускается из родительской папки) — путь теперь абсолютный; тест на это - data/ в .gitignore Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
6.0 KiB
Python
169 lines
6.0 KiB
Python
"""Local clone of the uploads repo and read-only queries against `origin/<branch>` (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)
|