"""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/.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")