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:
2026-10-15 19:10:30 +03:00
co-authored by Claude Opus 5.5
parent 219bebd5a2
commit dbb17bd774
17 changed files with 692 additions and 12 deletions
View File
+163
View File
@@ -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)