"""Übersicht „Repository“: was neu ist, Branches, Commit-Details und Chats mit Bezug darauf. Liest aus dem Klon der Plattform (RepoCache), der beim Öffnen höchstens einmal je REFRESH_SECONDS aktualisiert wird; dabei holt er alle Branches des Servers nach refs/remotes/origin. Geschrieben wird hier nichts ins Repository; Chats mit Bezug starten wie jeder andere Chat und unterliegen denselben Regeln. """ import logging import time from dataclasses import dataclass, field from datetime import date, datetime from sqlalchemy import select from sqlalchemy.orm import Session from app.changes import service as changes from app.changes.models import ChatBranch from app.core.config import Settings from app.core.crypto import Vault from app.core.database import now from app.core.errors import Invalid, NotFound from app.data_sources import service as sources from app.data_sources.connectors.git import BRANCH from app.data_sources.connectors.git_repo import EMPTY_TREE, GitError, GitRepo, RepoCache, check_path, check_sha from app.data_sources.connectors.pull_requests import PullRequest from app.data_sources.models import DataSource from app.identity import Principal from app.repo.models import RepoVisit from app.workspaces import service as workspaces log = logging.getLogger(__name__) REFRESH_SECONDS = 60 MAX_COMMITS = 200 MAX_BRANCHES = 100 MAX_DIFF_CHARS = 300_000 MAX_ENTRIES = 500 MAX_FILE_LINES = 5000 SEP, END = "\x1f", "\x1e" # Zuletzt aktualisiert je Quelle (je Prozess); mehrere Prozesse aktualisieren schlimmstenfalls einmal mehr _refreshed: dict[int, float] = {} def git_sources(session: Session, workspace_id: int) -> list[DataSource]: return list(session.scalars(select(DataSource).where( DataSource.workspace_id == workspace_id, DataSource.kind == "git", DataSource.status == "ok") .order_by(DataSource.name))) def available(session: Session, workspace_id: int) -> bool: return bool(git_sources(session, workspace_id)) def source_for(session: Session, principal: Principal, workspace_id: int, name: str | None) -> DataSource: """Gewählte Quelle (Name) oder die erste; nur Mitglieder des Workspace.""" workspaces.get(session, principal, workspace_id) found = git_sources(session, workspace_id) if not found: raise NotFound chosen = next((s for s in found if s.name == name), None) if name else found[0] if chosen is None: raise NotFound return chosen def open_repo(source: DataSource, settings: Settings, vault: Vault, force: bool = False) -> tuple[GitRepo, str]: """Klon holen bzw. aktualisieren (gedrosselt) und den Stand des Ziel-Branches liefern.""" repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault)) branch = source.config["branch"] fresh = time.monotonic() - _refreshed.get(source.id, -1e9) < REFRESH_SECONDS if force or not fresh or not (repo.directory / "HEAD").exists(): head = repo.sync() repo.fetch_heads() _refreshed[source.id] = time.monotonic() return repo, head return repo, repo.run("rev-parse", "--verify", "--end-of-options", f"refs/heads/{branch}^{{commit}}").strip() def refreshed_at(source_id: int) -> float | None: """Sekunden seit der letzten Aktualisierung in diesem Prozess (für die Anzeige).""" at = _refreshed.get(source_id) return None if at is None else time.monotonic() - at # --- Neu: Commits auf dem Ziel-Branch --- @dataclass class Commit: sha: str author: str email: str date: datetime subject: str files: int = 0 insertions: int = 0 deletions: int = 0 new: bool = False # seit dem letzten Besuch @property def short(self) -> str: return self.sha[:10] @dataclass class Day: day: date commits: list[Commit] = field(default_factory=list) def log_commits(repo: GitRepo, ref: str, since: date | None = None, author: str = "", path: str = "", limit: int = MAX_COMMITS) -> list[Commit]: args = ["log", "--no-color", "--first-parent", f"--max-count={limit}", f"--format={END}%H{SEP}%an{SEP}%ae{SEP}%aI{SEP}%s", "--shortstat"] if since: args.append(f"--since={since.isoformat()}") if author: args += ["--fixed-strings", f"--author={author}", "-i"] args += ["--end-of-options", ref] if path: args += ["--", check_path(path)] output = repo.run(*args) commits = [] for block in output.split(END)[1:]: head, _, stat = block.partition("\n") sha, name, email, when, subject = (head.split(SEP) + [""] * 5)[:5] commit = Commit(sha, name, email, datetime.fromisoformat(when), subject) for part in stat.strip().split(","): part = part.strip() number = int(part.split(" ", 1)[0]) if part[:1].isdigit() else 0 if "file" in part: commit.files = number elif "insertion" in part: commit.insertions = number elif "deletion" in part: commit.deletions = number commits.append(commit) return commits def parent(repo: GitRepo, sha: str) -> str: """Erster Elternteil (für „diesen Zeitraum zusammenfassen“); leer beim ersten Commit.""" return repo.run("rev-parse", "--verify", "--quiet", "--end-of-options", f"{sha}^", ok_codes=(0, 1)).strip() def by_day(commits: list[Commit]) -> list[Day]: days: list[Day] = [] for commit in commits: day = commit.date.date() if not days or days[-1].day != day: days.append(Day(day)) days[-1].commits.append(commit) return days @dataclass class News: """Seit dem letzten Besuch: Zahl der Commits und geänderten Dateien; None beim ersten Besuch.""" since: datetime | None base: str | None commits: int files: int def news(session: Session, repo: GitRepo, user_id: int, source_id: int, head: str) -> News: visit = session.get(RepoVisit, (user_id, source_id)) if visit is None or visit.commit == head: return News(visit.seen if visit else None, visit.commit if visit else None, 0, 0) if not repo.has(visit.commit) or not repo.is_ancestor(visit.commit, head): return News(visit.seen, None, 0, 0) # Verlauf umgeschrieben: kein sinnvoller Vergleich count = int(repo.run("rev-list", "--count", "--first-parent", "--end-of-options", f"{visit.commit}..{head}") .strip() or 0) return News(visit.seen, visit.commit, count, len(repo.changed_files(visit.commit, head))) def mark_new(commits: list[Commit], base: str | None) -> None: if not base: return for commit in commits: if commit.sha == base: break commit.new = True def remember(session: Session, user_id: int, source_id: int, head: str) -> None: visit = session.get(RepoVisit, (user_id, source_id)) if visit is None: session.add(RepoVisit(user_id=user_id, source_id=source_id, commit=head)) elif visit.commit != head: visit.commit, visit.seen = head, now() # --- Branches --- @dataclass class Branch: name: str sha: str date: datetime author: str subject: str ahead: int # Commits, die das Ziel nicht hat behind: int # Commits des Ziels, die hier fehlen target: bool = False chat_id: int | None = None chat_title: str = "" pr_state: str = "" pr_url: str = "" def branches(session: Session, repo: GitRepo, source: DataSource, workspace_id: int) -> list[Branch]: target_name = source.config["branch"] output = repo.run("for-each-ref", "--sort=-committerdate", f"--count={MAX_BRANCHES}", f"--format=%(refname:lstrip=3){SEP}%(objectname){SEP}%(committerdate:iso-strict){SEP}" f"%(authorname){SEP}%(contents:subject)", "refs/remotes/origin/") target = f"refs/remotes/origin/{target_name}" has_target = bool(repo.run("rev-parse", "--verify", "--quiet", "--end-of-options", target, ok_codes=(0, 1)).strip()) chats = {b.name: b for b in session.scalars(select(ChatBranch).where( ChatBranch.workspace_id == workspace_id, ChatBranch.source_id == source.id).order_by(ChatBranch.id))} from app.chat.models import ChatSession titles = dict(session.execute(select(ChatSession.id, ChatSession.title).where( ChatSession.id.in_([b.chat_session_id for b in chats.values()]))).all()) if chats else {} result = [] for line in output.splitlines(): name, sha, when, author, subject = (line.split(SEP) + [""] * 5)[:5] if not name or name == "HEAD": continue ahead = behind = 0 if has_target and name != target_name: counts = repo.run("rev-list", "--left-right", "--count", "--end-of-options", f"{target}...refs/remotes/origin/{name}").split() behind, ahead = (int(counts[0]), int(counts[1])) if len(counts) == 2 else (0, 0) chat = chats.get(name) result.append(Branch(name, sha, datetime.fromisoformat(when), author, subject, ahead, behind, name == target_name, chat.chat_session_id if chat else None, titles.get(chat.chat_session_id, "") if chat else "", chat.pr_state if chat else "", chat.pr_url or "" if chat else "")) result.sort(key=lambda b: (not b.target, -b.date.timestamp())) return result def branch_names(repo: GitRepo) -> list[str]: """Namen der Branches auf dem Server (Stand der letzten Aktualisierung), für die Auswahl.""" output = repo.run("for-each-ref", f"--count={MAX_BRANCHES}", "--format=%(refname:lstrip=3)", "refs/remotes/origin/") return sorted(n for n in output.splitlines() if n and n != "HEAD") def check_branch(repo: GitRepo, name: str) -> str: """Branch muss auf dem Server existieren (Stand der letzten Aktualisierung).""" if not BRANCH.match(name): raise Invalid("Ungültiger Branch-Name.") sha = repo.run("rev-parse", "--verify", "--quiet", "--end-of-options", f"refs/remotes/origin/{name}^{{commit}}", ok_codes=(0, 1)).strip() if not sha: raise Invalid(f"Den Branch {name} gibt es auf dem Git-Server nicht (mehr).") return sha # --- Ein Commit --- @dataclass class CommitDetail: commit: Commit body: str parents: list[str] files: list[str] diff: str truncated: bool def commit_detail(repo: GitRepo, sha: str) -> CommitDetail: sha = check_sha(sha) if not repo.has(sha): raise NotFound head = repo.run("show", "--no-patch", f"--format=%H{SEP}%an{SEP}%ae{SEP}%aI{SEP}%s{SEP}%P{SEP}%b", "--end-of-options", sha) full, name, email, when, subject, parents, body = (head.split(SEP, 6) + [""] * 7)[:7] parent_list = parents.split() base = parent_list[0] if parent_list else EMPTY_TREE diff = repo.run("diff", "--no-color", "--end-of-options", base, full) commit = Commit(full, name, email, datetime.fromisoformat(when), subject) return CommitDetail(commit, body.strip(), parent_list, repo.changed_files(base, full), diff[:MAX_DIFF_CHARS], len(diff) > MAX_DIFF_CHARS) # --- Pull Requests --- @dataclass class PullRequestRow: pr: PullRequest chat_id: int | None = None chat_title: str = "" def pull_requests(session: Session, source: DataSource, settings: Settings, vault: Vault, workspace_id: int) -> list[PullRequestRow] | None: """Offene Pull Requests aus dem Git-Server; None, wenn an der Quelle kein Anbieter eingerichtet ist.""" if source.config.get("pr_provider", "none") == "none": return None found = changes.prs(source, settings, vault).open() branches = {b.name: b for b in session.scalars(select(ChatBranch).where( ChatBranch.workspace_id == workspace_id, ChatBranch.source_id == source.id))} from app.chat.models import ChatSession titles = dict(session.execute(select(ChatSession.id, ChatSession.title).where( ChatSession.id.in_([b.chat_session_id for b in branches.values()]))).all()) if branches else {} rows = [] for pr in found: chat = branches.get(pr.source) rows.append(PullRequestRow(pr, chat.chat_session_id if chat else None, titles.get(chat.chat_session_id, "") if chat else "")) return rows # --- Vergleich zweier Branches (Diff eines Pull Requests) --- @dataclass class Compare: base: str # Ziel-Branch head: str # Branch mit den Änderungen merge_base: str head_sha: str commits: list[Commit] files: list[str] diff: str truncated: bool def compare(repo: GitRepo, base: str, head: str) -> Compare: """Änderungen von head gegenüber base ab dem gemeinsamen Stand, wie im Pull Request.""" base_sha, head_sha = check_branch(repo, base), check_branch(repo, head) start = repo.merge_base(base_sha, head_sha) commits = log_commits(repo, f"{start}..{head_sha}", limit=MAX_COMMITS) diff = repo.patch(start, head_sha) return Compare(base, head, start, head_sha, commits, repo.changed_files(start, head_sha), diff[:MAX_DIFF_CHARS], len(diff) > MAX_DIFF_CHARS) # --- Dateien --- def resolve_ref(repo: GitRepo, source: DataSource, head: str, ref: str) -> tuple[str, str]: """Branch-Name und Commit für die Dateiansicht; leer = Ziel-Branch.""" if not ref or ref == source.config["branch"]: return source.config["branch"], head return ref, check_branch(repo, ref) @dataclass class TreeEntry: name: str path: str kind: str # blob | tree | commit (Submodul) size: int | None def tree(repo: GitRepo, commit: str, path: str) -> list[TreeEntry]: path = check_path(path) if path and repo.kind(commit, path) != "tree": raise NotFound entries = [TreeEntry(e.path.rsplit("/", 1)[-1], e.path, e.kind, e.size) for e in repo.entries(commit, path)[:MAX_ENTRIES]] return sorted(entries, key=lambda e: (e.kind != "tree", e.name.lower())) @dataclass class FileView: path: str size: int lines: list[str] = field(default_factory=list) binary: bool = False too_large: str = "" truncated: bool = False history: list[Commit] = field(default_factory=list) def file_view(repo: GitRepo, commit: str, path: str) -> FileView: path = check_path(path, allow_empty=False) if repo.kind(commit, path) != "blob": raise NotFound size = int(repo.run("cat-file", "-s", "--end-of-options", f"{commit}:{path}").strip()) view = FileView(path, size, history=log_commits(repo, commit, path=path, limit=20)) try: data = repo.read(commit, path) except GitError as e: view.too_large = str(e) return view if b"\0" in data[:8000]: view.binary = True return view lines = data.decode("utf-8", "replace").splitlines() view.lines, view.truncated = lines[:MAX_FILE_LINES], len(lines) > MAX_FILE_LINES return view def crumbs(path: str) -> list[tuple[str, str]]: """Pfad als (Name, Teilpfad) für die Navigation.""" parts = [p for p in path.split("/") if p] return [(part, "/".join(parts[:i + 1])) for i, part in enumerate(parts)] def safe(fn, *args, **kwargs): """Git-Fehler als lesbare Meldung statt Fehlerseite.""" try: return fn(*args, **kwargs), "" except GitError as e: log.warning("Repository nicht lesbar", extra={"event": "repo_read_failed", "error": str(e)}) return None, str(e)