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

311 lines
13 KiB
Python
Raw 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.

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