feat(bot): Коммит — навигация по дереву репозитория загрузок
- UploadsRepo: локальный клон, fetch не чаще fetch_interval, ls-tree -z (пробелы/кириллица), cat-file, последний коммит по пути; git без shell, ssh с -F /dev/null (без конфига хоста) - «📝 Коммит»: папки/файлы с размерами, ⬆️ Вверх, страницы по 20, карточка файла с последним коммитом, «👁 Показать» (<pre> или документом); в кнопках только индексы - ключ для контейнера: GIT_SSH_KEY_B64 → временный файл 0600 (смонтированный файл хоста контейнерному uid не прочитать); volume /data для клона - тесты на настоящем bare-репозитории во временной папке Refs #8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+163
@@ -0,0 +1,163 @@
|
||||
"""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
|
||||
self._dir = settings.clone_dir
|
||||
self._lock = asyncio.Lock()
|
||||
self._fetched_at: float | None = None
|
||||
self._key_path = self._materialize_key()
|
||||
|
||||
@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)
|
||||
Reference in New Issue
Block a user