311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""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)
|