bifroest/app/data_sources/connectors/git_repo.py
2026-10-27 08:34:17 +01:00

368 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Lesender Zugriff auf ein Repository über einen lokalen Bare-Klon (→ Konzept 7.5, 7.8).
- Ein Klon pro Quelle im Cache; vor einem Run wird einmal aktualisiert und der
Commit festgehalten. Alle Tools eines Runs lesen genau diesen Commit.
- Es gibt kein Arbeitsverzeichnis: gelesen wird mit Git-Plumbing (ls-tree,
cat-file, grep, log, blame, show). Dadurch laufen keine Hooks, Filter oder
Checkout-Mechanismen aus dem Repository.
- Jeder Aufruf ohne Shell, mit Zeitlimit, ohne fremde Git-Konfiguration;
Argumente aus dem Modell werden vorher geprüft, Pfade als literale Pathspecs.
"""
import fcntl
import re
import shutil
import subprocess
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from app.core.config import Settings
from app.core.errors import Invalid
from app.data_sources.connectors.git import BRANCH, GitConfig, GitConnector
from app.tools.registry import ToolError
SHA = re.compile(r"^[0-9a-f]{7,40}$")
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" # leerer Baum: Diff des ersten Commits
class MergeConflict(Exception):
"""Zusammenführen geht nicht konfliktfrei; tree enthält die Dateien mit Konfliktmarkern."""
def __init__(self, tree: str, files: list[str]):
super().__init__(f"Konflikte in {', '.join(files)}")
self.tree, self.files = tree, files
class GitError(ToolError):
"""Fehler beim Lesen; die Meldung ist für das Modell bzw. die UI bestimmt."""
def check_path(path: str, allow_empty: bool = True) -> str:
"""Relativer Pfad im Repository; nie mit .. oder führendem -. Ein führendes / meint die Wurzel.
Verzeichnisse namens .git (auch GIT~1) sind ausgeschlossen: Sie würden beim Auschecken auf
Rechnern der Entwickler Git-Hooks oder -Konfiguration einschleusen."""
path = path.strip().strip("/")
if not path:
if allow_empty:
return ""
raise Invalid("Pfad fehlt.")
parts = PurePosixPath(path).parts
if path.startswith("-") or any(p in ("..", ".") or p.lower().rstrip(". ") in (".git", "git~1") for p in parts) \
or "\x00" in path or len(path) > 1000:
raise Invalid(f"Ungültiger Pfad: {path}")
return str(PurePosixPath(path))
def check_sha(value: str) -> str:
value = value.strip().lower()
if not SHA.match(value):
raise Invalid("Commit bitte als SHA angeben (7–40 Hex-Zeichen).")
return value
@dataclass(frozen=True)
class Entry:
kind: str # blob | tree | commit (Submodul)
path: str
size: int | None
class GitRepo:
def __init__(self, connector: GitConnector, directory: Path):
self.connector, self.directory = connector, directory
self.settings: Settings = connector.settings
# --- Klon und Aktualisierung ---
@contextmanager
def locked(self) -> Iterator[None]:
"""Nur ein Prozess klont oder aktualisiert gleichzeitig (mehrere Worker teilen den Cache)."""
self.directory.parent.mkdir(parents=True, exist_ok=True)
with open(self.directory.with_suffix(".lock"), "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
def sync(self, branch: str | None = None) -> str:
"""Klonen bzw. aktualisieren und den aktuellen Commit des Branches liefern (Standard: Ziel-Branch der
Quelle; ein anderer Branch z. B. für einen Chat, der auf einem Feature-Branch arbeitet)."""
config: GitConfig = self.connector.config
branch = branch or config.branch
if not BRANCH.match(branch):
raise Invalid("Ungültiger Branch-Name.")
with self.locked():
cloned = False
if not (self.directory / "HEAD").exists():
shutil.rmtree(self.directory, ignore_errors=True)
self.remote("clone", "--bare", "--single-branch", "--no-tags", "--branch", config.branch,
"--", config.url, str(self.directory))
cloned = True
if not cloned or branch != config.branch:
self.remote("-C", str(self.directory), "fetch", "--no-tags", "--prune", "--", config.url,
f"+refs/heads/{branch}:refs/heads/{branch}")
self.check_size()
return self.run("rev-parse", "--verify", "--end-of-options", f"refs/heads/{branch}^{{commit}}").strip()
def fetch_heads(self) -> None:
"""Alle Branches des Servers nach refs/remotes/origin holen (Übersicht „Repository“); entfernte
Branches verschwinden auch hier."""
config: GitConfig = self.connector.config
with self.locked():
if not (self.directory / "HEAD").exists():
shutil.rmtree(self.directory, ignore_errors=True)
self.remote("clone", "--bare", "--single-branch", "--no-tags", "--branch", config.branch,
"--", config.url, str(self.directory))
self.remote("-C", str(self.directory), "fetch", "--no-tags", "--prune", "--", config.url,
"+refs/heads/*:refs/remotes/origin/*")
self.check_size()
def remote(self, *args: str) -> None:
try:
result = self.connector.git(*args)
except subprocess.TimeoutExpired as e:
raise GitError(f"Keine Antwort vom Git-Server nach {self.settings.git_timeout_seconds} Sekunden.") from e
if result.returncode != 0:
raise GitError(self.connector.clean(result.stderr) or "Git-Server nicht erreichbar.")
def check_size(self) -> None:
output = self.run("count-objects", "-v")
sizes = dict(line.split(": ", 1) for line in output.splitlines() if ": " in line)
kib = int(sizes.get("size-pack", 0)) + int(sizes.get("size", 0))
if kib > self.settings.git_max_repo_mb * 1024:
shutil.rmtree(self.directory, ignore_errors=True)
raise GitError(f"Repository ist größer als {self.settings.git_max_repo_mb} MB.")
# --- Lesen ---
def run(self, *args: str, ok_codes: tuple[int, ...] = (0,), input: bytes | None = None,
env: dict[str, str] | None = None) -> str:
command = ["-C", str(self.directory), "-c", "core.quotepath=off", *args]
try:
result = subprocess.run(["git", *command], env=self.connector.environment() | (env or {}),
capture_output=True, input=input, timeout=self.settings.git_timeout_seconds,
check=False)
except subprocess.TimeoutExpired as e:
raise GitError("Zeitlimit überschritten; bitte die Anfrage eingrenzen.") from e
if result.returncode not in ok_codes:
message = result.stderr.decode("utf-8", "replace").strip().splitlines()
raise GitError(message[-1][:300] if message else f"git beendet mit Code {result.returncode}.")
return result.stdout.decode("utf-8", "replace")
def entries(self, commit: str, path: str = "", recursive: bool = False) -> list[Entry]:
args = ["ls-tree", "-l", "-z"] + (["-r"] if recursive else []) + ["--end-of-options", commit]
if path:
args += ["--", f":(literal){path}/" if not recursive else f":(literal){path}"]
result = []
for record in self.run(*args).split("\0"):
if not record:
continue
meta, name = record.split("\t", 1)
_, kind, _, size = meta.split()
result.append(Entry(kind, name, int(size) if size.isdigit() else None))
return result
def kind(self, commit: str, path: str) -> str | None:
output = self.run("cat-file", "-t", "--end-of-options", f"{commit}:{path}", ok_codes=(0, 128))
return output.strip() or None
def read(self, commit: str, path: str) -> bytes:
size = int(self.run("cat-file", "-s", "--end-of-options", f"{commit}:{path}").strip())
if size > self.settings.git_max_file_kb * 1024:
raise GitError(f"Datei ist größer als {self.settings.git_max_file_kb} KB; "
"bitte mit search gezielt suchen.")
command = ["git", "-C", str(self.directory), "cat-file", "blob", "--end-of-options", f"{commit}:{path}"]
return subprocess.run(command, env=self.connector.environment(), capture_output=True, check=True,
timeout=self.settings.git_timeout_seconds).stdout
# --- Schreiben (→ Konzept 7.5): Commit lokal bauen, erst nach Freigabe pushen ---
def mode(self, commit: str, path: str) -> str | None:
output = self.run("ls-tree", "--end-of-options", commit, "--", f":(literal){path}")
return output.split()[0] if output.strip() else None
def build_commit(self, base: str, files: dict[str, bytes | None], message: str, date: str) -> str:
"""Neuen Commit auf base bauen: Datei -> Inhalt (None = löschen). Ohne Arbeitsverzeichnis,
über einen temporären Index; der Klon selbst und seine Branches bleiben unverändert."""
with tempfile.TemporaryDirectory() as tmp:
env = {"GIT_INDEX_FILE": str(Path(tmp) / "index"),
"GIT_AUTHOR_NAME": self.settings.git_author_name,
"GIT_AUTHOR_EMAIL": self.settings.git_author_email,
"GIT_COMMITTER_NAME": self.settings.git_author_name,
"GIT_COMMITTER_EMAIL": self.settings.git_author_email,
# Fester Zeitpunkt: derselbe Vorschlag ergibt immer denselben Commit (nachbaubar)
"GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date}
self.run("read-tree", "--end-of-options", base, env=env)
for path, content in files.items():
if content is None:
# Mode 0 entfernt den Eintrag; anders als --force-remove ohne Arbeitsverzeichnis (Bare-Klon)
self.run("update-index", "--index-info", input=f"0 {'0' * 40}\t{path}\n".encode(), env=env)
continue
blob = self.run("hash-object", "-w", "--stdin", input=content, env=env).strip()
mode = self.mode(base, path) or "100644"
if mode not in ("100644", "100755"):
raise GitError(f"{path} ist keine normale Datei (Symlink oder Submodul).")
self.run("update-index", "--add", "--cacheinfo", f"{mode},{blob},{path}", env=env)
tree = self.run("write-tree", env=env).strip()
if tree == self.run("rev-parse", "--verify", "--end-of-options", f"{base}^{{tree}}").strip():
raise GitError("Die Änderung ändert nichts am Repository.")
return self.run("commit-tree", tree, "-p", base, input=message.encode(), env=env).strip()
def apply_patch(self, base: str, patch: str) -> dict[str, bytes | None]:
"""Unified Diff auf base anwenden, nur in einem temporären Index (git apply --cached).
Ergebnis: Datei -> neuer Inhalt (None = gelöscht); Umbenennungen als Löschen und Anlegen."""
with tempfile.TemporaryDirectory() as tmp:
env = {"GIT_INDEX_FILE": str(Path(tmp) / "index")}
self.run("read-tree", "--end-of-options", base, env=env)
text = patch if patch.endswith("\n") else patch + "\n"
try:
self.run("apply", "--cached", "--recount", "--whitespace=nowarn", "-", input=text.encode(), env=env)
except GitError as e:
raise GitError(f"Patch lässt sich nicht anwenden: {e}") from e
tree = self.run("write-tree", env=env).strip()
raw = self.run("diff-tree", "-r", "-z", "--no-renames", "--raw", "--end-of-options", base, tree)
parts = raw.split("\0")
files: dict[str, bytes | None] = {}
for meta, path in zip(parts[0::2], parts[1::2], strict=False): # -z endet mit leerem Feld
if not meta.startswith(":"):
continue
_, new_mode, _, new_sha, status = meta[1:].split()
if status == "D":
files[path] = None
continue
if new_mode not in ("100644", "100755"):
raise GitError(f"{path}: Patch legt keine normale Datei an (Symlink oder Submodul).")
files[path] = subprocess.run(["git", "-C", str(self.directory), "cat-file", "blob", new_sha],
env=self.connector.environment(), capture_output=True, check=True,
timeout=self.settings.git_timeout_seconds).stdout
if not files:
raise GitError("Der Patch ändert nichts.")
return files
def merge_base(self, a: str, b: str) -> str:
return self.run("merge-base", "--end-of-options", a, b).strip()
def merge(self, head: str, other: str, message: str, date: str,
resolutions: dict[str, bytes | None] | None = None) -> str:
"""other in head zusammenführen, ohne Arbeitsverzeichnis (git merge-tree). Konflikte müssen in
resolutions gelöst sein (Datei -> Inhalt, None = löschen), sonst MergeConflict. Fester Zeitpunkt:
nachbaubar wie build_commit."""
resolutions = resolutions or {}
output = self.run("merge-tree", "--write-tree", "--name-only", "--no-messages", "-z", head, other,
ok_codes=(0, 1))
tree, *conflicts = [p for p in output.split("\0") if p]
conflicts = list(dict.fromkeys(conflicts))
if set(conflicts) - set(resolutions):
raise MergeConflict(tree, [p for p in conflicts if p not in resolutions])
if unknown := set(resolutions) - set(conflicts):
raise GitError(f"Keine Konfliktdatei: {', '.join(sorted(unknown))}")
with tempfile.TemporaryDirectory() as tmp:
env = {"GIT_INDEX_FILE": str(Path(tmp) / "index"),
"GIT_AUTHOR_NAME": self.settings.git_author_name,
"GIT_AUTHOR_EMAIL": self.settings.git_author_email,
"GIT_COMMITTER_NAME": self.settings.git_author_name,
"GIT_COMMITTER_EMAIL": self.settings.git_author_email,
"GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date}
if resolutions:
self.run("read-tree", "--end-of-options", tree, env=env)
for path, content in resolutions.items():
if content is None:
self.run("update-index", "--index-info", input=f"0 {'0' * 40}\t{path}\n".encode(), env=env)
continue
if any(line.startswith(("<<<<<<< ", ">>>>>>> ")) for line in content.decode("utf-8", "replace")
.splitlines()):
raise GitError(f"{path} enthält noch Konfliktmarker.")
blob = self.run("hash-object", "-w", "--stdin", input=content, env=env).strip()
mode = self.mode(tree, path) or "100644"
if mode not in ("100644", "100755"):
raise GitError(f"{path} ist keine normale Datei (Symlink oder Submodul).")
self.run("update-index", "--add", "--cacheinfo", f"{mode},{blob},{path}", env=env)
tree = self.run("write-tree", env=env).strip()
return self.run("commit-tree", tree, "-p", head, "-p", other, input=message.encode(), env=env).strip()
def exit_code(self, *args: str) -> int:
"""Für git-Befehle, deren Antwort der Exit-Code ist (0 = ja, 1 = nein)."""
try:
result = subprocess.run(["git", "-C", str(self.directory), *args], env=self.connector.environment(),
capture_output=True, timeout=self.settings.git_timeout_seconds, check=False)
except subprocess.TimeoutExpired as e:
raise GitError("Zeitlimit überschritten.") from e
if result.returncode > 1:
raise GitError(f"git {args[0]} beendet mit Code {result.returncode}.")
return result.returncode
def is_ancestor(self, ancestor: str, commit: str) -> bool:
return self.exit_code("merge-base", "--is-ancestor", ancestor, commit) == 0
def has(self, commit: str) -> bool:
return bool(self.run("cat-file", "-t", "--end-of-options", commit, ok_codes=(0, 128)).strip())
def patch(self, base: str, commit: str) -> str:
return self.run("diff", "--no-color", "--no-ext-diff", "--no-textconv", "--stat", "--patch",
"--end-of-options", base, commit)
def changed_files(self, base: str, commit: str) -> list[str]:
return [p for p in self.run("diff", "--name-only", "-z", "--end-of-options", base, commit).split("\0") if p]
def applies_to(self, head: str, commit: str) -> bool:
"""Lässt sich commit konfliktfrei mit dem aktuellen Stand head zusammenführen?"""
return self.exit_code("merge-tree", "--write-tree", "--no-messages", head, commit) == 0
def remote_head(self, branch: str) -> str | None:
"""Aktueller Commit eines Branches auf dem Server (None = gibt es nicht)."""
try:
result = self.connector.git("ls-remote", "--heads", "--", self.connector.config.url,
f"refs/heads/{branch}")
except subprocess.TimeoutExpired as e:
raise GitError("Git-Server antwortet nicht.") from e
if result.returncode != 0:
raise GitError(self.connector.clean(result.stderr) or "Git-Server nicht erreichbar.")
line = result.stdout.strip().splitlines()
return line[0].split()[0] if line else None
def push(self, commit: str, branch: str, expected: str | None = None) -> None:
"""Commit auf einen Branch pushen, nie mit Überschreiben fremder Stände: Ohne expected darf
der Branch noch nicht existieren, sonst muss er genau auf expected stehen (Lease). Der Push
selbst ist kein Force-Push; nur Nachfolger des bisherigen Stands werden angenommen."""
with self.locked():
self.remote("-C", str(self.directory), "push", "--porcelain",
f"--force-with-lease=refs/heads/{branch}:{expected or ''}", "--", self.connector.config.url,
f"{commit}:refs/heads/{branch}")
def delete_branch(self, branch: str, expected: str) -> None:
"""Branch auf dem Server löschen, nur wenn er genau auf expected steht (Lease)."""
with self.locked():
self.remote("-C", str(self.directory), "push", "--porcelain",
f"--force-with-lease=refs/heads/{branch}:{expected}", "--", self.connector.config.url,
f":refs/heads/{branch}")
def keep(self, ref: str, commit: str) -> None:
"""Lokale Referenz setzen, damit git gc die Commits eines Chat-Branches nicht entfernt."""
self.run("update-ref", "--end-of-options", ref, commit)
def drop(self, ref: str) -> None:
self.run("update-ref", "-d", "--end-of-options", ref, ok_codes=(0, 1))
def fetch_branch(self, branch: str, ref: str) -> bool:
"""Gepushten Branch vom Server in eine lokale Referenz holen; False, wenn es ihn nicht gibt."""
try:
with self.locked():
self.remote("-C", str(self.directory), "fetch", "--no-tags", "--", self.connector.config.url,
f"+refs/heads/{branch}:{ref}")
except GitError:
return False
return True
class RepoCache:
"""Ein Klon je Quelle unter git_cache_dir/<source-id>.git."""
def __init__(self, settings: Settings):
self.root = Path(settings.git_cache_dir)
def repo(self, source_id: int, connector: GitConnector) -> GitRepo:
return GitRepo(connector, self.root / f"{source_id}.git")