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

589 lines
29 KiB
Python

"""Chat-Branches: Commits der KI, Push und Pull Request auf Klick (→ Git-Workflow).
- Die KI committet mit commit_change auf den Branch ihres Chats (ai/c<chat>-<titel>).
Er entsteht beim ersten Commit und bleibt im Klon der Plattform.
- Pushen, Pull Request erstellen, als bereit markieren und Commits zurücknehmen
löst nur ein Mitglied des Workspace aus, nie die KI.
- Gepusht wird nie mit Überschreiben: neuer Branch nur, wenn es ihn noch nicht gibt,
sonst nur, wenn er auf dem zuletzt übertragenen Stand steht.
- Der Worker fragt offene Pull Requests regelmäßig ab; nach Merge oder Ablehnung
ist der Branch geschlossen und der nächste Commit beginnt einen neuen.
"""
import logging
import re
import unicodedata
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import audit
from app.changes import checks
from app.changes.models import DECLINED, MERGED, OPEN, BranchCommit, ChatBranch
from app.chat.models import ChatSession
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import Database, now
from app.core.errors import Invalid, NotFound
from app.data_sources import service as sources
from app.data_sources.connectors import pull_requests
from app.data_sources.connectors.git_repo import GitError, GitRepo, MergeConflict, RepoCache, check_path
from app.data_sources.models import DataSource
from app.identity import Principal
from app.identity.models import User
from app.workspaces import service as workspaces
log = logging.getLogger(__name__)
MAX_DIFF_CHARS = 300_000
# --- Commits bauen (auch zum Nachbauen nach Verlust des Klons) ---
def build(repo: GitRepo, parent: str, changes: list[dict[str, Any]], message: str, date: str,
settings: Settings) -> str:
if len(changes) > settings.git_max_change_files:
raise GitError(f"Höchstens {settings.git_max_change_files} Dateien je Commit.")
files: dict[str, bytes | None] = {}
for change in changes:
path = check_path(change["path"])
if not path or path in files:
raise GitError(f"Pfad {change['path']!r} ist leer oder doppelt.")
if change.get("delete"):
if repo.kind(parent, path) != "blob":
raise GitError(f"{path} gibt es nicht als Datei.")
files[path] = None
elif change.get("replace"):
if repo.kind(parent, path) != "blob":
raise GitError(f"{path} gibt es nicht als Datei; für neue Dateien content verwenden.")
text = repo.read(parent, path).decode("utf-8")
for item in change["replace"]:
count = text.count(item["old"])
if count != 1:
raise GitError(f"{path}: Der zu ersetzende Text kommt {count}-mal vor, nötig ist genau einmal.")
text = text.replace(item["old"], item["new"])
files[path] = text.encode()
else:
if repo.kind(parent, path) == "tree":
raise GitError(f"{path} ist ein Verzeichnis.")
files[path] = change["content"].encode()
if files[path] is not None and len(files[path]) > settings.git_max_file_kb * 1024:
raise GitError(f"{path} wird größer als {settings.git_max_file_kb} KB.")
return repo.build_commit(parent, files, message, date)
def from_patch(repo: GitRepo, parent: str, patch: str) -> list[dict[str, Any]]:
"""Patch in Änderungen mit vollem Inhalt umsetzen; so gespeichert, baut build den Commit exakt nach."""
result = []
for path, content in repo.apply_patch(parent, patch).items():
if content is None:
result.append({"path": path, "delete": True})
continue
try:
result.append({"path": path, "content": content.decode("utf-8")})
except UnicodeDecodeError as e:
raise GitError(f"{path}: Binärdateien lassen sich nicht per Patch ändern.") from e
return result
def resolved(changes: list[dict[str, Any]]) -> dict[str, bytes | None]:
return {c["path"]: None if c.get("delete") else c["content"].encode() for c in changes}
def restore(repo: GitRepo, branch: ChatBranch, commits: list[BranchCommit], settings: Settings) -> None:
"""Commits des Branches im Klon sicherstellen: vorhanden, vom Server holen (wenn gepusht)
oder aus den gespeicherten Änderungen exakt nachbauen."""
if repo.has(branch.head):
return
if branch.pushed and repo.fetch_branch(branch.name, branch.ref) and repo.has(branch.head):
return
parent = branch.base
if not repo.has(parent):
raise GitError("Der Ausgangsstand des Branches ist im Repository nicht mehr vorhanden.")
for commit in commits:
if commit.merged:
if not repo.has(commit.merged):
raise GitError("Der abgeglichene Stand des Ziel-Branches ist im Repository nicht mehr vorhanden.")
sha = repo.merge(parent, commit.merged, commit.message, commit.date, resolved(commit.changes))
else:
sha = build(repo, parent, commit.changes, commit.message, commit.date, settings)
if sha != commit.sha:
raise GitError("Der Branch lässt sich nicht exakt nachbauen.")
parent = sha
if parent != branch.head:
raise GitError("Der Branch lässt sich nicht exakt nachbauen.")
repo.keep(branch.ref, branch.head)
def history(session: Session, branch_id: int) -> list[BranchCommit]:
return list(session.scalars(select(BranchCommit).where(BranchCommit.branch_id == branch_id)
.order_by(BranchCommit.id)))
def active(session: Session, chat_id: int, source_id: int, lock: bool = False) -> ChatBranch | None:
query = select(ChatBranch).where(ChatBranch.chat_session_id == chat_id, ChatBranch.source_id == source_id,
ChatBranch.closed.is_(None))
return session.scalar(query.with_for_update() if lock else query)
def head(db: Database, repo: GitRepo, chat_id: int, source_id: int, settings: Settings) -> str | None:
"""Stand, den die lesenden Tools in diesem Chat sehen: der Chat-Branch, falls es einen gibt."""
with db.session() as session:
branch = active(session, chat_id, source_id)
if branch is None:
return None
restore(repo, branch, history(session, branch.id), settings)
return branch.head
def slug(title: str) -> str:
text = title.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", text).strip("-")[:40].strip("-") or "aenderung"
def branch_name(session: Session, chat: ChatSession, source_id: int) -> str:
name = f"ai/c{chat.id}-{slug(chat.title)}"
earlier = session.scalar(select(func.count()).select_from(ChatBranch).where(
ChatBranch.chat_session_id == chat.id, ChatBranch.source_id == source_id)) or 0
return name if not earlier else f"{name}-{earlier + 1}"
@dataclass(frozen=True)
class Committed:
branch: str
sha: str
files: list[str]
stat: str
warnings: list[str]
def commit(db: Database, repo: GitRepo, source: DataSource, *, chat_id: int, workspace_id: int, user_id: int | None,
run_id: Any, start: str, changes: list[dict[str, Any]], message: str, settings: Settings,
patch: str = "", target: str | None = None) -> Committed:
"""Einen Commit der KI auf den Branch des Chats; legt den Branch beim ersten Commit an.
start ist der Stand des Ziel-Branches, auf dem ein neuer Branch beginnt. Statt changes
geht auch ein Unified Diff (patch). Die statische Prüfung warnt nur. target: Ausgangs-Branch, wenn der
Chat nicht auf dem Ziel-Branch der Quelle arbeitet."""
date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+0000")
with db.session() as session:
branch = active(session, chat_id, source.id, lock=True)
if branch is not None:
restore(repo, branch, history(session, branch.id), settings)
parent = branch.head if branch else start
if patch:
changes = from_patch(repo, parent, patch)
sha = build(repo, parent, changes, message, date, settings)
files = repo.changed_files(parent, sha)
warnings = checks.check({c["path"]: c["content"].encode() for c in changes if "content" in c}
| {c["path"]: repo.read(sha, c["path"]) for c in changes if c.get("replace")})
if branch is None:
chat = session.get(ChatSession, chat_id)
branch = ChatBranch(workspace_id=workspace_id, chat_session_id=chat_id, source_id=source.id,
name=branch_name(session, chat, source.id), target=target or source.config["branch"],
base=start, head=start, created_by=user_id)
session.add(branch)
session.flush()
session.add(BranchCommit(branch_id=branch.id, sha=sha, parent=parent, message=message, changes=changes,
date=date, files=files, run_id=run_id, warnings=warnings))
branch.head, branch.updated = sha, now()
repo.keep(branch.ref, sha)
audit.record(session, "branch.committed", user_id=user_id, workspace_id=workspace_id, target_kind="branch",
target_id=branch.id, branch=branch.name, commit=sha, files=len(files))
name = branch.name
stat = repo.run("diff", "--no-color", "--stat", "--end-of-options", parent, sha).strip()
return Committed(name, sha, files, stat, warnings)
class Behind(Invalid):
"""Abgleich mit dem Ziel-Branch hat Konflikte; files mit Konfliktmarkern in conflicts."""
def __init__(self, target: str, conflicts: dict[str, str]):
super().__init__(f"Konflikte mit {target} in: {', '.join(conflicts)}.")
self.target, self.conflicts = target, conflicts
def merge_target(db: Database, repo: GitRepo, source: DataSource, *, chat_id: int, user_id: int | None,
run_id: Any, settings: Settings, resolutions: list[dict[str, Any]] | None = None) -> Committed:
"""Aktuellen Stand des Ziel-Branches in den Chat-Branch zusammenführen (Merge-Commit, kein Rebase:
bereits übertragene Commits bleiben gültig). Bei Konflikten Behind mit den Dateien samt Markern;
mit resolutions (Pfad + content bzw. delete) entsteht der Merge-Commit mit diesen Lösungen."""
with db.session() as session:
branch = active(session, chat_id, source.id)
target_branch = branch.target if branch else None
target = repo.sync(target_branch)
date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+0000")
resolutions = resolutions or []
with db.session() as session:
branch = active(session, chat_id, source.id, lock=True)
if branch is None:
raise Invalid("Zu diesem Chat gibt es noch keinen Branch.")
check_open(branch)
restore(repo, branch, history(session, branch.id), settings)
if repo.is_ancestor(target, branch.head):
raise Invalid(f"Der Branch ist bereits auf dem aktuellen Stand von {branch.target}.")
message = f"Merge {branch.target} in {branch.name}"
try:
sha = repo.merge(branch.head, target, message, date, resolved(resolutions))
except MergeConflict as e:
conflicts = {}
for path in e.files:
try:
conflicts[path] = repo.read(e.tree, path).decode("utf-8", "replace")
except GitError:
conflicts[path] = "(nicht lesbar, z. B. gelöscht auf einer Seite oder zu groß)"
raise Behind(branch.target, conflicts) from e
files = repo.changed_files(branch.head, sha)
warnings = checks.check({c["path"]: c["content"].encode() for c in resolutions if "content" in c})
session.add(BranchCommit(branch_id=branch.id, sha=sha, parent=branch.head, merged=target, message=message,
changes=resolutions, date=date, files=files, run_id=run_id, warnings=warnings))
parent = branch.head
branch.head, branch.updated = sha, now()
repo.keep(branch.ref, sha)
audit.record(session, "branch.merged_target", user_id=user_id, workspace_id=branch.workspace_id,
target_kind="branch", target_id=branch.id, branch=branch.name, commit=sha, target=target,
resolved=len(resolutions))
name = branch.name
stat = repo.run("diff", "--no-color", "--stat", "--end-of-options", parent, sha).strip()
return Committed(name, sha, files, stat, warnings)
def sync_target(db: Database, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> Committed:
"""Knopf „Mit Ziel-Branch abgleichen“: nur konfliktfrei; Konflikte löst die KI (merge_target)."""
with db.session() as session:
t = load(session, principal, settings, vault, branch_id, lock=False)
chat_id, source = t.branch.chat_session_id, t.source
session.expunge(source)
repo = t.repo
return merge_target(db, repo, source, chat_id=chat_id, user_id=principal.user_id, run_id=None, settings=settings)
# --- Aktionen eines Menschen ---
@dataclass
class Target:
"""Branch mit allem, was für eine Aktion gebraucht wird."""
branch: ChatBranch
source: DataSource
repo: GitRepo
chat: ChatSession
def load(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int,
lock: bool = True) -> Target:
"""Branch für eine Aktion: nur Mitglieder des Workspace, nur offene Branches."""
query = select(ChatBranch).where(ChatBranch.id == branch_id)
branch = session.scalar(query.with_for_update() if lock else query)
if branch is None:
raise NotFound
workspaces.get(session, principal, branch.workspace_id)
source = session.get(DataSource, branch.source_id)
repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault))
return Target(branch, source, repo, session.get(ChatSession, branch.chat_session_id))
def prs(source: DataSource, settings: Settings, vault: Vault) -> pull_requests.PullRequestProvider:
token = vault.decrypt(source.secret) if source.secret else None
return pull_requests.provider(source.config.get("pr_provider", "none"), source.config["url"], token,
settings.pr_timeout_seconds, source.config.get("username", ""))
def check_open(branch: ChatBranch) -> None:
if branch.closed is not None:
raise Invalid("Der Pull Request dieses Branches ist abgeschlossen; neue Änderungen entstehen auf einem "
"neuen Branch.")
def push(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> ChatBranch:
"""Branch pushen bzw. neue Commits in einen offenen Pull Request übertragen."""
t = load(session, principal, settings, vault, branch_id)
check_open(t.branch)
if not t.branch.unpushed:
raise Invalid("Es gibt keine neuen Commits zum Übertragen.")
transfer(session, t, principal)
return t.branch
def transfer(session: Session, t: Target, principal: Principal) -> None:
branch = t.branch
t.repo.sync()
restore(t.repo, branch, history(session, branch.id), t.repo.settings)
try:
t.repo.push(branch.head, branch.name, expected=branch.pushed)
except GitError as e:
remote = t.repo.remote_head(branch.name)
if remote == branch.head: # schon übertragen, z. B. nach einem Abbruch
pass
elif remote is not None and remote != branch.pushed:
raise Invalid(f"Branch {branch.name} hat auf dem Server einen anderen Stand (z. B. Commits von "
"außerhalb der Plattform). Es wurde nichts überschrieben.") from e
else:
raise
previous, branch.pushed, branch.updated = branch.pushed, branch.head, now()
audit.record(session, "branch.pushed", user_id=principal.user_id, workspace_id=branch.workspace_id,
target_kind="branch", target_id=branch.id, branch=branch.name, commit=branch.head,
previous=previous)
def description(t: Target, text: str, author: str, settings: Settings, commits: list[BranchCommit]) -> str:
lines = [text.strip()] if text.strip() else []
lines += ["", "---", f"Erstellt in Bifröst (AI Workspace) von {author} aus dem Chat „{t.chat.title}“. "
"Die Änderungen hat die KI vorgeschlagen; geprüft werden sie in diesem Review."]
if settings.public_url:
lines.append(f"Chat: {settings.public_url.rstrip('/')}/chats/{t.chat.id}")
if t.chat.plan:
from app.chat import plan
lines += ["", "Plan:", plan.as_text(t.chat.plan)]
lines += ["", "Commits:"] + [f"- {c.sha[:10]} {c.message.splitlines()[0]}" for c in commits[-30:]]
return "\n".join(lines).strip()
def create_pull_request(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int,
title: str, text: str, draft: bool) -> ChatBranch:
t = load(session, principal, settings, vault, branch_id)
check_open(t.branch)
branch = t.branch
if branch.pr_id:
raise Invalid("Für diesen Branch gibt es schon einen Pull Request.")
if branch.head == branch.base:
raise Invalid("Der Branch enthält noch keine Änderung.")
title = " ".join(title.split())[:200]
if len(title) < 3:
raise Invalid("Der Titel braucht mindestens 3 Zeichen.")
if branch.unpushed:
transfer(session, t, principal)
provider = prs(t.source, settings, vault)
user = session.get(User, principal.user_id)
pr = provider.find(branch.name) or provider.create(
branch.name, branch.target, title,
description(t, text[:5000], user.display_name, settings, history(session, branch.id)), draft=draft)
if pr is None:
# Ohne Anbieter: der gepushte Branch ist das Ergebnis
audit.record(session, "branch.pull_request_skipped", user_id=principal.user_id,
workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id)
return branch
branch.pr_id, branch.pr_url, branch.pr_state, branch.pr_draft = pr.id, pr.url, pr.state, pr.draft
branch.pr_info = {"version": pr.version, "reviewers": pr.reviewers, "comments": 0, "by": principal.user_id,
"title": title}
branch.pr_checked = now()
audit.record(session, "branch.pull_request_created", user_id=principal.user_id, workspace_id=branch.workspace_id,
target_kind="branch", target_id=branch.id, branch=branch.name, pull_request=pr.id, draft=pr.draft)
return branch
def publish(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> ChatBranch:
"""Entwurf als bereit zum Review markieren."""
t = load(session, principal, settings, vault, branch_id)
check_open(t.branch)
branch = t.branch
if not branch.pr_id or not branch.pr_draft:
raise Invalid("Es gibt keinen Entwurf, der bereit gemeldet werden könnte.")
provider = prs(t.source, settings, vault)
current = provider.get(branch.pr_id)
pr = provider.publish(branch.pr_id, current.version if current else branch.pr_info.get("version", 0))
if pr is not None:
branch.pr_draft, branch.pr_info = pr.draft, {**branch.pr_info, "version": pr.version}
audit.record(session, "branch.pull_request_ready", user_id=principal.user_id, workspace_id=branch.workspace_id,
target_kind="branch", target_id=branch.id, pull_request=branch.pr_id)
return branch
def undo(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> ChatBranch | None:
"""Letzten, noch nicht übertragenen Commit zurücknehmen. Ohne Commits verschwindet der Branch."""
t = load(session, principal, settings, vault, branch_id)
check_open(t.branch)
branch = t.branch
commits = history(session, branch.id)
if not commits or branch.head == branch.pushed:
raise Invalid("Übertragene Commits lassen sich hier nicht zurücknehmen; das geht nur im Review "
"bzw. mit einem weiteren Commit.")
last = commits[-1]
session.delete(last)
audit.record(session, "branch.commit_undone", user_id=principal.user_id, workspace_id=branch.workspace_id,
target_kind="branch", target_id=branch.id, commit=last.sha)
if last.parent == branch.base and not branch.pushed:
t.repo.drop(branch.ref)
session.delete(branch)
return None
branch.head, branch.updated = last.parent, now()
t.repo.keep(branch.ref, branch.head)
return branch
# --- Rückkanal: Status des Pull Requests ---
def refresh(session: Session, settings: Settings, vault: Vault, branch: ChatBranch) -> list[str]:
"""Stand aus Bitbucket übernehmen; liefert Anlässe für Benachrichtigungen."""
source = session.get(DataSource, branch.source_id)
provider = prs(source, settings, vault)
pr = provider.get(branch.pr_id)
branch.pr_checked = now()
if pr is None:
return []
events = []
comments = [c for c in provider.comments(branch.pr_id)] if pr.state == OPEN else []
known = branch.pr_info.get("comments", 0)
if pr.state == OPEN and len(comments) > known:
events.append("comments")
if pr.state != branch.pr_state and pr.state in (MERGED, DECLINED):
events.append(pr.state.lower())
branch.closed = now()
clean_up(session, settings, vault, branch, source)
branch.pr_state, branch.pr_draft = pr.state, pr.draft
branch.pr_info = {**branch.pr_info, "version": pr.version, "reviewers": pr.reviewers,
"comments": len(comments) if pr.state == OPEN else known,
"new_comments": max(len(comments) - known, 0) if pr.state == OPEN else 0}
return events
def clean_up(session: Session, settings: Settings, vault: Vault, branch: ChatBranch, source: DataSource) -> None:
"""Abgeschlossenen Branch im Git-Server löschen (Einstellung der Quelle, Standard an).
Nur wenn er noch auf dem zuletzt übertragenen Stand steht: Commits von außen bleiben
erhalten. In der Plattform bleiben Branch, Commits und Diff sichtbar. Fehler halten den
Abschluss nicht auf, sie stehen im Log und in der Ansicht.
"""
if not source.config.get("delete_branches", True) or not branch.pushed:
return
repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault))
try:
repo.sync()
remote = repo.remote_head(branch.name)
if remote is None:
result = "gone" # z. B. beim Merge in Bitbucket gelöscht
elif remote != branch.pushed:
result = "changed"
else:
repo.delete_branch(branch.name, branch.pushed)
result = "deleted"
audit.record(session, "branch.deleted", workspace_id=branch.workspace_id, target_kind="branch",
target_id=branch.id, branch=branch.name, commit=branch.pushed)
except GitError as e:
log.warning("Branch %s nicht gelöscht", branch.name, extra={"event": "branch_delete_failed",
"error": str(e)})
result = "failed"
branch.pr_info = {**branch.pr_info, "remote_branch": result}
def poll(db: Database, settings: Settings) -> int:
"""Offene Pull Requests abfragen (Worker, im Aufräum-Takt). Fehler je Branch nur ins Log."""
vault = Vault(settings.secret_key)
due = now() - timedelta(seconds=settings.pr_poll_seconds)
with db.session() as session:
ids = list(session.scalars(select(ChatBranch.id).where(
ChatBranch.pr_id.is_not(None), ChatBranch.closed.is_(None),
(ChatBranch.pr_checked.is_(None)) | (ChatBranch.pr_checked < due)).limit(20)))
count = 0
for branch_id in ids:
try:
with db.session() as session:
branch = session.get(ChatBranch, branch_id, with_for_update=True)
if branch is None or branch.closed is not None:
continue
for event in refresh(session, settings, vault, branch):
notify_event(session, branch, event)
count += 1
except Exception:
log.warning("Pull Request von Branch %s nicht abfragbar", branch_id, exc_info=True)
with db.session() as session:
branch = session.get(ChatBranch, branch_id)
if branch is not None:
branch.pr_checked = now()
return count
def notify_event(session: Session, branch: ChatBranch, event: str) -> None:
from app.notify import service as notify
from app.workspaces.models import Workspace
chat = session.get(ChatSession, branch.chat_session_id)
workspace = session.get(Workspace, branch.workspace_id)
title = notify.clean(branch.pr_info.get("title") or branch.name)
text = {"merged": "wurde gemergt", "declined": "wurde abgelehnt",
"comments": f"hat {branch.pr_info.get('new_comments', 0)} neue Kommentare"}[event]
subject = f"Pull Request #{branch.pr_id} {text}: {title}"
body = (f"Pull Request #{branch.pr_id} „{title}“ im Workspace „{workspace.name}“ {text}.\n\n"
f"Pull Request: {branch.pr_url}\nChat: {notify.link(f'/chats/{chat.id}')}"
+ ("\n\nIm Chat lassen sich die Kommentare mit „Kommentare einarbeiten“ übernehmen."
if event == "comments" else ""))
for user_id in {chat.created_by, branch.pr_info.get("by")} - {None}:
user = session.get(User, user_id)
if user is not None:
notify.email(session, user, f"pull_request.{event}", subject, body, workspace.id)
if event != "comments":
notify.webhook(session, workspace, f"pull_request.{event}", f"{subject} ({branch.pr_url})")
# --- Anzeige ---
@dataclass
class View:
branch: ChatBranch
source: DataSource
commits: list[BranchCommit]
files: list[str]
diff: str
conflict: bool
truncated: bool
behind: bool = False # Ziel-Branch hat neue Commits, die im Chat-Branch fehlen
def views(session: Session, settings: Settings, vault: Vault, chat_id: int) -> list[View]:
"""Branches des Chats für die Ansicht „Änderungen“: offene und der zuletzt geschlossene je Quelle."""
branches = list(session.scalars(select(ChatBranch).where(ChatBranch.chat_session_id == chat_id)
.order_by(ChatBranch.id.desc())))
shown, result = set(), []
for branch in branches:
if branch.source_id in shown:
continue
shown.add(branch.source_id)
source = session.get(DataSource, branch.source_id)
commits = history(session, branch.id)
files, diff, conflict, truncated, behind = [], "", False, False, False
try:
repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault))
if (repo.directory / "HEAD").exists():
restore(repo, branch, commits, settings)
target = repo.run("rev-parse", "--verify", "--end-of-options",
f"refs/heads/{branch.target}^{{commit}}", ok_codes=(0, 128)).strip()
# Nur die eigenen Änderungen zeigen, auch nach einem Abgleich (Diff ab dem gemeinsamen Stand).
# Abgeschlossene Branches: ab dem zuletzt abgeglichenen Stand, sonst wäre nach dem Merge
# der Branch selbst der gemeinsame Stand und der Diff leer.
if target and branch.closed is None:
start = repo.merge_base(target, branch.head)
else:
start = next((c.merged for c in reversed(commits) if c.merged), branch.base)
files = repo.changed_files(start, branch.head)
diff = repo.patch(start, branch.head)
truncated = len(diff) > MAX_DIFF_CHARS
diff = diff[:MAX_DIFF_CHARS]
if target and branch.closed is None:
behind = not repo.is_ancestor(target, branch.head)
conflict = behind and not repo.applies_to(target, branch.head)
except (GitError, Invalid):
log.warning("Änderungen von Branch %s nicht lesbar", branch.id, exc_info=True)
result.append(View(branch, source, commits, files, diff, conflict, truncated, behind))
return list(reversed(result))
def bases(chat: ChatSession) -> dict[int, str]:
"""Ausgangs-Branch je Quelle eines Chats, der auf einem bestehenden Branch arbeitet."""
return {int(k): v for k, v in (chat.git_base or {}).items()}
def prompt_notes(session: Session, chat_id: int) -> str:
"""Stand der Chat-Branches für den System-Prompt."""
lines = []
for branch in session.scalars(select(ChatBranch).where(ChatBranch.chat_session_id == chat_id,
ChatBranch.closed.is_(None))):
source = session.get(DataSource, branch.source_id)
count = session.scalar(select(func.count()).select_from(BranchCommit)
.where(BranchCommit.branch_id == branch.id)) or 0
state = ("noch nicht übertragen" if not branch.pushed else
"übertragen" + (", mit neuen, noch nicht übertragenen Commits" if branch.unpushed else ""))
pr = f"; Pull Request #{branch.pr_id} ({'Entwurf' if branch.pr_draft else 'offen'})" if branch.pr_id else ""
lines.append(f"- Quelle {source.name}: Branch {branch.name} mit {count} Commit(s), {state}{pr}")
return "\n".join(lines)