"""Lesende Git-Tools für das Modell (→ Konzept 7.5, 7.8, 10.7: Risikoklasse read). Ausgaben sind schlichter Text mit Pfaden und Zeilennummern, damit das Modell gezielt nachlesen kann. Alle Limits sind serverseitig. """ import re from datetime import date from pathlib import PurePosixPath from typing import Annotated from pydantic import Field, field_validator from app.core.errors import Invalid from app.data_sources.connectors.git_repo import GitError, GitRepo, check_path, check_sha from app.tools.context import ToolContext from app.tools.registry import Args, Tool, register SourceField = Annotated[str | None, Field( default=None, description="Name der Git-Quelle; nur nötig, wenn der Workspace mehrere hat.")] def open_repo(context: ToolContext, source: str | None) -> tuple[GitRepo, str]: """Repository der Quelle und der für diesen Run festgehaltene Commit.""" return context.git(source) def check_glob(value: str) -> str: value = value.strip() or "**" if value.startswith(("-", "/", ":")) or ".." in PurePosixPath(value).parts or len(value) > 300: raise ValueError("Ungültiges Muster.") return value # --- list_dir --- class ListDirArgs(Args): source: SourceField path: str = Field("", description="Verzeichnis relativ zur Wurzel; leer = Wurzel.") _path = field_validator("path")(lambda cls, v: check_path(v)) def list_dir(context: ToolContext, args: ListDirArgs) -> str: repo, commit = open_repo(context, args.source) if args.path and repo.kind(commit, args.path) != "tree": raise GitError(f"Verzeichnis {args.path} gibt es nicht.") entries = repo.entries(commit, args.path) limit = 500 lines = [f"{'d' if e.kind == 'tree' else 's' if e.kind == 'commit' else 'f'} " f"{e.path}{'/' if e.kind == 'tree' else ''}{f' ({e.size} B)' if e.size is not None else ''}" for e in entries[:limit]] header = f"{args.path or '/'} @ {commit[:12]} – {len(entries)} Einträge (d = Verzeichnis, f = Datei, s = Submodul)" if len(entries) > limit: lines.append(f"… {len(entries) - limit} weitere; bitte ein Unterverzeichnis wählen.") return "\n".join([header, *lines]) # --- read_file --- class ReadFileArgs(Args): source: SourceField path: str = Field(description="Dateipfad relativ zur Wurzel.") start_line: int = Field(1, ge=1, description="Erste Zeile (ab 1).") max_lines: int = Field(400, ge=1, le=2000, description="Höchstens so viele Zeilen.") _path = field_validator("path")(lambda cls, v: check_path(v, allow_empty=False)) def decode(data: bytes, path: str) -> str: if b"\0" in data[:8000]: raise GitError(f"{path} ist eine Binärdatei.") return data.decode("utf-8", "replace") def read_file(context: ToolContext, args: ReadFileArgs) -> str: repo, commit = open_repo(context, args.source) if repo.kind(commit, args.path) != "blob": raise GitError(f"Datei {args.path} gibt es nicht.") lines = decode(repo.read(commit, args.path), args.path).splitlines() start = args.start_line - 1 chosen = lines[start:start + args.max_lines] if not chosen and lines: raise GitError(f"{args.path} hat nur {len(lines)} Zeilen.") end = start + len(chosen) body = "\n".join(f"{start + i + 1:>5} {line}" for i, line in enumerate(chosen)) more = f"\n… weiter ab Zeile {end + 1} (insgesamt {len(lines)})" if end < len(lines) else "" return f"{args.path} @ {commit[:12]}, Zeilen {start + 1}–{end} von {len(lines)}\n{body}{more}" # --- search --- class SearchArgs(Args): source: SourceField pattern: str = Field(min_length=1, max_length=500, description="Regulärer Ausdruck (POSIX ERE, wie grep -E).") path_glob: str = Field("**", description="Nur Dateien, die passen, z. B. 'src/**/*.py'.") ignore_case: bool = False context_lines: int = Field(2, ge=0, le=5, description="Zeilen Kontext vor und nach jedem Treffer.") max_results: int = Field(50, ge=1, le=200) _glob = field_validator("path_glob")(lambda cls, v: check_glob(v)) MAX_CONTEXT_FILES = 20 def search(context: ToolContext, args: SearchArgs) -> str: repo, commit = open_repo(context, args.source) command = ["grep", "-n", "-z", "-I", "-E", "--no-color"] + (["-i"] if args.ignore_case else []) command += ["-e", args.pattern, "--end-of-options", commit, "--", f":(glob){args.path_glob}"] output = repo.run(*command, ok_codes=(0, 1)) hits: dict[str, list[int]] = {} total = 0 for record in output.splitlines(): parts = record.split("\0") if len(parts) < 3: continue path, number = parts[0].split(":", 1)[1], int(parts[1]) total += 1 if sum(len(v) for v in hits.values()) < args.max_results: hits.setdefault(path, []).append(number) if not hits: return f"Keine Treffer für /{args.pattern}/ in {args.path_glob} @ {commit[:12]}." blocks = [] for index, (path, numbers) in enumerate(hits.items()): if args.context_lines and index < MAX_CONTEXT_FILES: try: lines = decode(repo.read(commit, path), path).splitlines() except GitError: lines = [] else: lines = [] if not lines: blocks.append("\n".join(f"{path}:{n}" for n in numbers)) continue shown: list[int] = sorted({i for n in numbers for i in range(max(1, n - args.context_lines), min(len(lines), n + args.context_lines) + 1)}) rendered, previous = [f"== {path}"], 0 for i in shown: if previous and i > previous + 1: rendered.append(" …") rendered.append(f"{i:>5}{'>' if i in numbers else ' '} {lines[i - 1]}") previous = i blocks.append("\n".join(rendered)) shown_count = sum(len(v) for v in hits.values()) header = f"{total} Treffer in {args.path_glob} @ {commit[:12]}" + ( f", die ersten {shown_count} gezeigt; bitte eingrenzen." if total > shown_count else "") return "\n\n".join([header, *blocks]) # --- find_files --- class FindFilesArgs(Args): source: SourceField glob: str = Field("**", description="Pfadmuster, z. B. '**/*.md' oder 'src/**/test_*.py'.") min_bytes: int | None = Field(None, ge=0) max_bytes: int | None = Field(None, ge=0) max_results: int = Field(200, ge=1, le=1000) _glob = field_validator("glob")(lambda cls, v: check_glob(v)) def find_files(context: ToolContext, args: FindFilesArgs) -> str: repo, commit = open_repo(context, args.source) matches = [e for e in repo.entries(commit, recursive=True) if e.kind == "blob" and PurePosixPath(e.path).full_match(args.glob) and (args.min_bytes is None or (e.size or 0) >= args.min_bytes) and (args.max_bytes is None or (e.size or 0) <= args.max_bytes)] lines = [f"{e.path} ({e.size} B)" for e in matches[:args.max_results]] header = f"{len(matches)} Dateien für {args.glob} @ {commit[:12]}" if len(matches) > args.max_results: header += f", die ersten {args.max_results} gezeigt" return "\n".join([header, *lines]) # --- log --- class LogArgs(Args): source: SourceField path: str = Field("", description="Nur Commits, die diesen Pfad ändern; leer = alle.") author: str | None = Field(None, max_length=200, description="Teil von Name oder E-Mail (kein Regex).") since: date | None = Field(None, description="Ab Datum (JJJJ-MM-TT).") until: date | None = Field(None, description="Bis Datum (JJJJ-MM-TT).") max_count: int = Field(20, ge=1, le=100) _path = field_validator("path")(lambda cls, v: check_path(v)) FIELD, RECORD = "\x1f", "\x1e" def log(context: ToolContext, args: LogArgs) -> str: repo, commit = open_repo(context, args.source) command = ["log", f"--max-count={args.max_count}", "--no-color", "--fixed-strings", f"--format={RECORD}%H{FIELD}%an <%ae>{FIELD}%ad{FIELD}%s", "--date=short", "--shortstat"] if args.author: command.append(f"--author={args.author}") if args.since: command.append(f"--since={args.since.isoformat()}") if args.until: command.append(f"--until={args.until.isoformat()}") command += ["--end-of-options", commit, "--"] + ([f":(literal){args.path}"] if args.path else []) entries = [] for record in repo.run(*command).split(RECORD)[1:]: head, _, stat = record.partition("\n") sha, author, day, subject = head.split(FIELD, 3) entries.append(f"{sha[:12]} {day} {author}\n {subject}\n {stat.strip()}".rstrip()) if not entries: return f"Keine Commits für diese Filter @ {commit[:12]}." return f"{len(entries)} Commits (neueste zuerst) @ {commit[:12]}\n" + "\n".join(entries) # --- blame --- class BlameArgs(Args): source: SourceField path: str start_line: int = Field(1, ge=1) end_line: int = Field(100, ge=1) _path = field_validator("path")(lambda cls, v: check_path(v, allow_empty=False)) def blame(context: ToolContext, args: BlameArgs) -> str: if args.end_line < args.start_line or args.end_line - args.start_line >= 300: raise Invalid("Bereich: end_line ≥ start_line, höchstens 300 Zeilen.") repo, commit = open_repo(context, args.source) if repo.kind(commit, args.path) != "blob": raise GitError(f"Datei {args.path} gibt es nicht.") # commit stammt aus dem Snapshot (nie vom Modell); blame kennt --end-of-options nicht wie die anderen output = repo.run("blame", "--porcelain", "-L", f"{args.start_line},{args.end_line}", commit, "--", args.path) commits: dict[str, dict[str, str]] = {} lines, current, number = [], "", "" for line in output.splitlines(): if line.startswith("\t"): info = commits[current] lines.append(f"{current[:8]} {info.get('day', ''):10} {info.get('author', '')[:20]:20} " f"{number:>5} {line[1:]}") elif re.match(r"^[0-9a-f]{40} ", line): current, _, number = line.split()[:3] commits.setdefault(current, {}) elif line.startswith("author "): commits[current]["author"] = line[7:] elif line.startswith("author-time "): commits[current]["day"] = date.fromtimestamp(int(line[12:])).isoformat() return f"{args.path} @ {commit[:12]}, Zeilen {args.start_line}–{args.end_line}\n" + "\n".join(lines) # --- diff / show_commit --- MAX_PATCH = 60_000 def cut(text: str) -> str: return text if len(text) <= MAX_PATCH else text[:MAX_PATCH] + "\n… gekürzt; bitte mit path eingrenzen." class DiffArgs(Args): source: SourceField from_commit: str = Field(description="Älterer Commit (SHA).") to_commit: str | None = Field(None, description="Neuerer Commit (SHA); leer = aktueller Stand.") path: str = Field("", description="Nur diesen Pfad vergleichen; leer = alles.") _path = field_validator("path")(lambda cls, v: check_path(v)) _from = field_validator("from_commit")(lambda cls, v: check_sha(v)) _to = field_validator("to_commit")(lambda cls, v: check_sha(v) if v else None) def diff(context: ToolContext, args: DiffArgs) -> str: repo, commit = open_repo(context, args.source) target = args.to_commit or commit command = ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--stat", "--patch", "--end-of-options", args.from_commit, target, "--"] output = repo.run(*command + ([f":(literal){args.path}"] if args.path else [])) return cut(f"Diff {args.from_commit[:12]}..{target[:12]}\n{output}" if output else "Keine Unterschiede.") class ShowCommitArgs(Args): source: SourceField commit: str = Field(description="Commit (SHA).") _commit = field_validator("commit")(lambda cls, v: check_sha(v)) def show_commit(context: ToolContext, args: ShowCommitArgs) -> str: repo, _ = open_repo(context, args.source) output = repo.run("show", "--no-color", "--no-ext-diff", "--no-textconv", "--stat", "--patch", "--format=commit %H%nAuthor: %an <%ae>%nDate: %ad%n%n%B", "--date=iso", "--end-of-options", args.commit) return cut(output) for tool in ( Tool("git_list_dir", "git", "Inhalt eines Verzeichnisses im Repository auflisten.", ListDirArgs, "read", list_dir), Tool("git_read_file", "git", "Datei lesen, mit Zeilennummern; lange Dateien abschnittsweise.", ReadFileArgs, "read", read_file), Tool("git_search", "git", "Im Repository nach einem regulären Ausdruck suchen (wie git grep), " "mit Pfadfilter und Kontextzeilen.", SearchArgs, "read", search), Tool("git_find_files", "git", "Dateien nach Pfadmuster und Größe finden.", FindFilesArgs, "read", find_files), Tool("git_log", "git", "Commit-Historie, filterbar nach Pfad, Autor und Zeitraum.", LogArgs, "read", log), Tool("git_blame", "git", "Zeige für Zeilen einer Datei, wann und von wem sie zuletzt geändert wurden.", BlameArgs, "read", blame), Tool("git_diff", "git", "Änderungen zwischen zwei Commits anzeigen.", DiffArgs, "read", diff), Tool("git_show_commit", "git", "Einen Commit mit Nachricht und Änderungen anzeigen.", ShowCommitArgs, "read", show_commit), ): register(tool)