368 lines
20 KiB
Python
368 lines
20 KiB
Python
"""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")
|