"""Föderationsmodul für das Monorepo-CLI. Implementiert den FederationManager und die SubtreeSyncEngine für die Hub-and-Spoke-Topologie mit bidirektionaler Synchronisation zwischen Monorepo (Hub) und Team-Repos (Spokes) via Git-Subtrees. Das Monorepo ist der zentrale Hub (nur Andre sieht alles), Team-Repos sind unabhängige Spokes. Synchronisation via Git-Subtrees bewahrt vollständige Historie. """ from __future__ import annotations import logging import re import shutil import subprocess from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING import yaml from monorepo.models import ( ConflictInfo, ConflictStrategy, IsolationLeak, IsolationReport, SharedMirrorConfig, SyncResult, TeamRepoEntry, ) if TYPE_CHECKING: from monorepo.encryption import SecretEncryptionManager logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Ergebnis-Dataclasses # --------------------------------------------------------------------------- @dataclass class MirrorResult: """Ergebnis einer Shared-Bereich-Spiegelung.""" success: bool context: str mirrored_paths: list[str] = field(default_factory=list) errors: list[str] = field(default_factory=list) @dataclass class ConflictResult: """Ergebnis einer Konflikt-Auflösung.""" success: bool context: str strategy: str resolved_files: list[str] = field(default_factory=list) errors: list[str] = field(default_factory=list) @dataclass class MemberInfo: """Informationen über ein neues Team-Mitglied.""" name: str email: str role: str = "developer" @dataclass class MemberOnboardingResult: """Ergebnis des Team-Mitglied-Onboardings.""" success: bool context: str member_name: str team_repo_url: str = "" errors: list[str] = field(default_factory=list) # --------------------------------------------------------------------------- # Föderations-Konfiguration # --------------------------------------------------------------------------- @dataclass class FederationTopologyConfig: """Geladene Föderations-Topologie-Konfiguration aus team-repos.yaml.""" version: str = "1.0" topology: str = "hub-and-spoke" hub_owner: str = "andre" conflict_strategy: str = "team-wins" team_repos: list[TeamRepoEntry] = field(default_factory=list) # --------------------------------------------------------------------------- # SubtreeSyncEngine # --------------------------------------------------------------------------- class SubtreeSyncEngine: """Git-Subtree-basierte Synchronisation mit Historie-Bewahrung. Kapselt die git-subtree-Befehle (pull/push) und bietet Konflikterkennung vor der eigentlichen Synchronisation. Args: monorepo_root: Pfad zum Monorepo-Root-Verzeichnis. """ def __init__(self, monorepo_root: Path) -> None: self.monorepo_root = monorepo_root def subtree_pull( self, remote: str, prefix: str, branch: str ) -> SyncResult: """Zieht Änderungen vom Team_Repo per git subtree pull. Args: remote: URL des Remote-Repos. prefix: Subtree-Prefix (Pfad im Monorepo). branch: Branch im Remote-Repo. Returns: SyncResult mit Erfolgs-/Fehlerstatus. """ logger.info( "Subtree-Pull: remote=%s, prefix=%s, branch=%s", remote, prefix, branch, ) try: result = self._run_git([ "subtree", "pull", f"--prefix={prefix}", remote, branch, "--squash", ]) commits = self._count_commits(result.stdout) return SyncResult( success=True, context=prefix, direction="pull", commits_synced=commits, conflicts=[], timestamp=datetime.now(), ) except subprocess.CalledProcessError as e: if self._is_merge_conflict(e): conflicts = self._extract_conflicts(e) self._abort_merge() return SyncResult( success=False, context=prefix, direction="pull", commits_synced=0, conflicts=conflicts, timestamp=datetime.now(), ) return SyncResult( success=False, context=prefix, direction="pull", commits_synced=0, conflicts=[ConflictInfo( file_path="", conflict_type="content", source="team-repo", details=f"Subtree-Pull fehlgeschlagen: {e.stderr or e.stdout}", )], timestamp=datetime.now(), ) def subtree_push( self, remote: str, prefix: str, branch: str ) -> SyncResult: """Pusht Änderungen zum Team_Repo per git subtree push. Args: remote: URL des Remote-Repos. prefix: Subtree-Prefix (Pfad im Monorepo). branch: Branch im Remote-Repo. Returns: SyncResult mit Erfolgs-/Fehlerstatus. """ logger.info( "Subtree-Push: remote=%s, prefix=%s, branch=%s", remote, prefix, branch, ) try: result = self._run_git([ "subtree", "push", f"--prefix={prefix}", remote, branch, ]) commits = self._count_commits(result.stdout) return SyncResult( success=True, context=prefix, direction="push", commits_synced=commits, conflicts=[], timestamp=datetime.now(), ) except subprocess.CalledProcessError as e: return SyncResult( success=False, context=prefix, direction="push", commits_synced=0, conflicts=[ConflictInfo( file_path="", conflict_type="content", source="monorepo", details=f"Subtree-Push fehlgeschlagen: {e.stderr or e.stdout}", )], timestamp=datetime.now(), ) def detect_conflicts(self, context: str) -> list[ConflictInfo]: """Erkennt Merge-Konflikte vor der Synchronisation. Führt einen Dry-Run durch, indem ein temporärer Fetch und Diff gegen den Remote-Branch durchgeführt wird, ohne tatsächlich zu mergen. Args: context: Kontextordner (z.B. 'privat', 'dhive', 'bahn'). Returns: Liste von ConflictInfo-Objekten für erkannte Konflikte. Leere Liste wenn keine Konflikte erkannt werden. """ logger.info("Prüfe auf Konflikte für Kontext '%s'.", context) try: # Prüfe auf lokale uncommitted Änderungen im Prefix status_result = self._run_git([ "status", "--porcelain", "--", f"{context}/", ]) if status_result.stdout.strip(): # Es gibt uncommitted Änderungen – potenzielle Konflikte conflicts: list[ConflictInfo] = [] for line in status_result.stdout.strip().splitlines(): # Format: "XY filename" if len(line) >= 3: file_path = line[3:].strip() conflicts.append(ConflictInfo( file_path=file_path, conflict_type="content", source="monorepo", details="Uncommitted lokale Änderungen erkannt", )) return conflicts except subprocess.CalledProcessError: pass return [] def preserve_history(self, repo_path: Path) -> bool: """Verifiziert, dass die Git-Historie nach Sync vollständig ist. Prüft ob der Commit-Graph im gegebenen Pfad intakt ist und keine verwaisten Commits existieren. Args: repo_path: Pfad zum Repository (oder Subtree-Prefix). Returns: True wenn die Historie vollständig und intakt ist. """ try: result = self._run_git([ "log", "--oneline", "--", str(repo_path), ]) # Wenn wir Commits finden, ist die Historie vorhanden return bool(result.stdout.strip()) except subprocess.CalledProcessError: return False # ----------------------------------------------------------------------- # Interne Hilfsmethoden # ----------------------------------------------------------------------- def _run_git( self, args: list[str], cwd: Path | None = None ) -> subprocess.CompletedProcess[str]: """Führt einen Git-Befehl aus. Args: args: Git-Argumente (ohne 'git' Präfix). cwd: Arbeitsverzeichnis für den Befehl. Returns: CompletedProcess mit stdout/stderr. Raises: subprocess.CalledProcessError: Bei Fehler im Git-Befehl. """ cmd = ["git"] + args logger.debug("Ausführen: %s (cwd=%s)", " ".join(cmd), cwd or self.monorepo_root) return subprocess.run( cmd, cwd=cwd or self.monorepo_root, capture_output=True, text=True, check=True, ) def _is_merge_conflict(self, error: subprocess.CalledProcessError) -> bool: """Prüft ob ein CalledProcessError einen Merge-Konflikt anzeigt. Args: error: Der aufgetretene Fehler. Returns: True wenn der Fehler auf einen Merge-Konflikt hindeutet. """ conflict_indicators = [ "CONFLICT", "merge conflict", "Merge conflict", "Automatic merge failed", "fix conflicts", ] combined = (error.stdout or "") + (error.stderr or "") return any(indicator in combined for indicator in conflict_indicators) def _extract_conflicts( self, error: subprocess.CalledProcessError ) -> list[ConflictInfo]: """Extrahiert Konflikt-Details aus der Git-Fehlermeldung. Args: error: CalledProcessError mit Merge-Konflikt-Informationen. Returns: Liste von ConflictInfo-Objekten. """ conflicts: list[ConflictInfo] = [] combined = (error.stdout or "") + (error.stderr or "") for line in combined.splitlines(): if "CONFLICT" in line: # Typisches Format: "CONFLICT (content): Merge conflict in " conflict_type = "content" if "rename" in line.lower(): conflict_type = "rename" elif "delete" in line.lower() or "modify" in line.lower(): conflict_type = "delete-modify" # Dateinamen extrahieren file_path = "" if " in " in line: file_path = line.split(" in ", 1)[-1].strip() conflicts.append(ConflictInfo( file_path=file_path, conflict_type=conflict_type, # type: ignore[arg-type] source="team-repo", details=line.strip(), )) if not conflicts: conflicts.append(ConflictInfo( file_path="", conflict_type="content", source="team-repo", details=f"Merge-Konflikt erkannt: {combined[:200]}", )) return conflicts def _abort_merge(self) -> None: """Bricht einen laufenden Merge ab, um einen sauberen Zustand herzustellen.""" try: self._run_git(["merge", "--abort"]) except subprocess.CalledProcessError: # merge --abort kann fehlschlagen wenn kein Merge läuft logger.debug("merge --abort fehlgeschlagen (kein aktiver Merge).") def _count_commits(self, output: str) -> int: """Zählt synchronisierte Commits aus der Git-Ausgabe. Args: output: Git-Befehlsausgabe. Returns: Geschätzte Anzahl synchronisierter Commits. """ if not output: return 0 # Suche nach typischen Mustern wie "X commits" oder Commit-Hashes lines = output.strip().splitlines() commit_count = 0 for line in lines: # git pull Ausgabe enthält oft "X files changed, Y insertions" if "files changed" in line or "file changed" in line: commit_count = max(commit_count, 1) # Direkte Commit-Referenzen (kurze SHA) if line.strip() and len(line.strip().split()[0]) >= 7: parts = line.strip().split() if parts[0].isalnum() and len(parts[0]) <= 40: commit_count += 1 return max(commit_count, 1) if output.strip() else 0 # --------------------------------------------------------------------------- # FederationManager # --------------------------------------------------------------------------- class FederationManager: """Verwaltet die föderierte Repo-Struktur (Hub-and-Spoke). Das Monorepo ist der zentrale Hub, jedes Team-Repo ein unabhängiger Spoke. Synchronisation erfolgt bidirektional via Git-Subtrees, wobei die vollständige Historie bewahrt wird. Args: config_path: Pfad zur team-repos.yaml Konfigurationsdatei. monorepo_root: Pfad zum Monorepo-Root-Verzeichnis. encryption_manager: Optionaler SecretEncryptionManager für kontextspezifische Secret-Filterung (Integration in Task 15.2). """ VALID_CONTEXTS = {"privat", "dhive", "bahn"} def __init__( self, config_path: Path, monorepo_root: Path | None = None, encryption_manager: "SecretEncryptionManager | None" = None, ) -> None: self.config_path = config_path self.monorepo_root = monorepo_root or Path.cwd() self.encryption_manager = encryption_manager self.topology_config = self._load_team_repos_config(config_path) self.sync_engine = SubtreeSyncEngine(self.monorepo_root) # ------------------------------------------------------------------- # Öffentliche API # ------------------------------------------------------------------- def sync_from_team(self, context: str) -> SyncResult: """Synchronisiert Änderungen vom Team_Repo in den Monorepo-Kontextordner. Führt einen git subtree pull vom konfigurierten Team-Repo aus und integriert die Änderungen in den entsprechenden Kontextordner. Bei Merge-Konflikten wird die Synchronisation abgebrochen, der Konflikt protokolliert und dem Hub-Besitzer eine manuelle Auflösung ermöglicht. Args: context: Arbeitskontext ('privat', 'dhive', 'bahn'). Returns: SyncResult mit Erfolgs-/Fehlerstatus und ggf. Konflikten. """ team_entry = self._get_team_entry(context) if team_entry is None: return self._error_result( context, "pull", f"Kein Team-Repo für Kontext '{context}' konfiguriert.", ) # Prüfe ob Sync-Richtung erlaubt if team_entry.sync_direction == "hub-to-spoke": return self._error_result( context, "pull", f"Sync-Richtung für '{context}' ist 'hub-to-spoke', " f"Pull vom Team-Repo nicht erlaubt.", ) logger.info("Sync von Team-Repo '%s' → Monorepo/%s", team_entry.url, context) result = self.sync_engine.subtree_pull( remote=team_entry.url, prefix=context, branch=team_entry.branch, ) # Setze den korrekten Kontext im Ergebnis result.context = context if not result.success: logger.warning( "Sync von Team-Repo '%s' fehlgeschlagen: %d Konflikte.", context, len(result.conflicts), ) return result def sync_to_team(self, context: str) -> SyncResult: """Synchronisiert Änderungen vom Monorepo-Kontextordner zum Team_Repo. Führt einen git subtree push zum konfigurierten Team-Repo aus und überträgt lokale Änderungen aus dem Kontextordner. Args: context: Arbeitskontext ('privat', 'dhive', 'bahn'). Returns: SyncResult mit Erfolgs-/Fehlerstatus. """ team_entry = self._get_team_entry(context) if team_entry is None: return self._error_result( context, "push", f"Kein Team-Repo für Kontext '{context}' konfiguriert.", ) # Prüfe ob Sync-Richtung erlaubt if team_entry.sync_direction == "spoke-to-hub": return self._error_result( context, "push", f"Sync-Richtung für '{context}' ist 'spoke-to-hub', " f"Push zum Team-Repo nicht erlaubt.", ) logger.info("Sync von Monorepo/%s → Team-Repo '%s'", context, team_entry.url) result = self.sync_engine.subtree_push( remote=team_entry.url, prefix=context, branch=team_entry.branch, ) # Setze den korrekten Kontext im Ergebnis result.context = context if not result.success: logger.warning( "Push zum Team-Repo '%s' fehlgeschlagen.", context, ) return result def full_sync(self, context: str) -> SyncResult: """Bidirektionale Synchronisation mit Konflikt-Erkennung. Ablauf: 1. Konflikte vorab erkennen (detect_conflicts) 2. Pull vom Team-Repo (sync_from_team) 3. Bei Konflikt: Abbruch, Protokollierung, team-wins Strategie 4. Push zum Team-Repo (sync_to_team) Bei Merge-Konflikten wird die Synchronisation abgebrochen und dem Hub-Besitzer eine manuelle Auflösung ermöglicht. Args: context: Arbeitskontext ('privat', 'dhive', 'bahn'). Returns: SyncResult mit Gesamtergebnis der bidirektionalen Sync. """ team_entry = self._get_team_entry(context) if team_entry is None: return self._error_result( context, "full", f"Kein Team-Repo für Kontext '{context}' konfiguriert.", ) if team_entry.sync_direction not in ("bidirectional",): return self._error_result( context, "full", f"Bidirektionale Synchronisation für '{context}' nicht erlaubt " f"(konfiguriert: '{team_entry.sync_direction}').", ) logger.info("Full-Sync für Kontext '%s' gestartet.", context) total_commits = 0 # Phase 1: Konflikte vorab prüfen pre_conflicts = self.detect_conflicts(context) if pre_conflicts: logger.warning( "Vorab-Konflikte erkannt für '%s': %d Konflikte.", context, len(pre_conflicts), ) return SyncResult( success=False, context=context, direction="full", commits_synced=0, conflicts=pre_conflicts, timestamp=datetime.now(), ) # Phase 2: Pull vom Team-Repo pull_result = self.sync_from_team(context) if not pull_result.success: logger.warning("Full-Sync abgebrochen: Pull fehlgeschlagen für '%s'.", context) return SyncResult( success=False, context=context, direction="full", commits_synced=pull_result.commits_synced, conflicts=pull_result.conflicts, timestamp=datetime.now(), ) total_commits += pull_result.commits_synced # Phase 3: Push zum Team-Repo push_result = self.sync_to_team(context) if not push_result.success: logger.warning("Full-Sync: Push fehlgeschlagen für '%s'.", context) return SyncResult( success=False, context=context, direction="full", commits_synced=total_commits + push_result.commits_synced, conflicts=push_result.conflicts, timestamp=datetime.now(), ) total_commits += push_result.commits_synced logger.info( "Full-Sync für '%s' erfolgreich: %d Commits synchronisiert.", context, total_commits, ) return SyncResult( success=True, context=context, direction="full", commits_synced=total_commits, conflicts=[], timestamp=datetime.now(), ) def detect_conflicts(self, context: str) -> list[ConflictInfo]: """Erkennt Merge-Konflikte vor der Synchronisation. Delegiert an die SubtreeSyncEngine für die eigentliche Prüfung. Args: context: Arbeitskontext ('privat', 'dhive', 'bahn'). Returns: Liste von ConflictInfo-Objekten. Leer wenn keine Konflikte. """ team_entry = self._get_team_entry(context) if team_entry is None: return [ConflictInfo( file_path="", conflict_type="content", source="monorepo", details=f"Kein Team-Repo für Kontext '{context}' konfiguriert.", )] return self.sync_engine.detect_conflicts(context) def verify_isolation( self, team_repo_path: Path, context: str ) -> IsolationReport: """Prüft ob ein Team_Repo keine Referenzen auf andere Kontexte enthält. Durchsucht alle Dateien im Team-Repo nach Pfad-Referenzen, Umgebungsvariablen, Config-Referenzen und Kommentaren die auf andere Kontexte verweisen. Args: team_repo_path: Pfad zum zu prüfenden Team-Repo. context: Erwarteter Kontext des Team-Repos. Returns: IsolationReport mit gefundenen Leaks (falls vorhanden). """ leaks: list[IsolationLeak] = [] other_contexts = self.VALID_CONTEXTS - {context} if not team_repo_path.exists(): return IsolationReport(context=context, is_isolated=True, leaks=[]) # Scan all text files in the team repo for file_path in self._iter_text_files(team_repo_path): try: rel_path = str(file_path.relative_to(team_repo_path)) content = file_path.read_text(encoding="utf-8", errors="ignore") lines = content.splitlines() for line_num, line in enumerate(lines, start=1): for other_ctx in other_contexts: # 1. Pfad-Referenzen: z.B. "privat/", "dhive/project" leak = self._check_path_reference( line, other_ctx, rel_path, line_num ) if leak: leaks.append(leak) # 2. Env-Variablen: z.B. DHIVE_API_KEY, BAHN_TOKEN leak = self._check_env_var_reference( line, other_ctx, rel_path, line_num ) if leak: leaks.append(leak) # 3. Config-Referenzen: z.B. git-crypt-dhive, context: bahn leak = self._check_config_reference( line, other_ctx, rel_path, line_num ) if leak: leaks.append(leak) # 4. Kommentare zu anderen Kontexten leak = self._check_comment_reference( line, other_ctx, rel_path, line_num ) if leak: leaks.append(leak) except (OSError, UnicodeDecodeError): # Binärdateien oder nicht-lesbare Dateien überspringen continue is_isolated = len(leaks) == 0 return IsolationReport(context=context, is_isolated=is_isolated, leaks=leaks) def mirror_shared(self, context: str, paths: list[str]) -> MirrorResult: """Spiegelt ausgewählte shared-Dateien als Read-Only in das Team_Repo. Kopiert die angegebenen Pfade aus dem shared-Bereich des Monorepos in das Team_Repo-Verzeichnis (unter `.shared-mirror/`). Die Dateien werden als read-only markiert. Die Spiegelung respektiert die konfigurierte SharedMirrorConfig und Sync-Frequenz. Args: context: Ziel-Kontext für die Spiegelung. paths: Liste von Pfaden aus dem shared-Bereich (relativ zum Monorepo-Root). Returns: MirrorResult mit gespiegelten Pfaden oder Fehlern. """ team_entry = self._get_team_entry(context) if team_entry is None: return MirrorResult( success=False, context=context, errors=[f"Kein Team-Repo für Kontext '{context}' konfiguriert."], ) # Prüfe ob shared_mirror in der Konfiguration aktiviert ist if team_entry.shared_mirror is not None and not team_entry.shared_mirror.enabled: return MirrorResult( success=False, context=context, errors=[f"Shared-Mirror für Kontext '{context}' ist deaktiviert."], ) # Bestimme die erlaubten Pfade aus der Konfiguration allowed_paths: list[str] | None = None if team_entry.shared_mirror is not None: allowed_paths = team_entry.shared_mirror.paths mirrored: list[str] = [] errors: list[str] = [] # Zielverzeichnis für gespiegelte Dateien im Kontext-Ordner mirror_target = self.monorepo_root / context / ".shared-mirror" for path in paths: # Validiere dass der Pfad im shared-Bereich liegt if not path.startswith("shared/"): errors.append( f"Pfad '{path}' liegt nicht im shared-Bereich. " f"Nur Pfade unterhalb von 'shared/' können gespiegelt werden." ) continue # Prüfe ob der Pfad in den erlaubten Mirror-Pfaden enthalten ist if allowed_paths is not None: path_allowed = any( path.startswith(allowed.rstrip("/")) or path == allowed.rstrip("/") for allowed in allowed_paths ) if not path_allowed: errors.append( f"Pfad '{path}' ist nicht in der Mirror-Konfiguration " f"für Kontext '{context}' erlaubt." ) continue source = self.monorepo_root / path if not source.exists(): errors.append(f"Quellpfad '{path}' existiert nicht.") continue # Ziel-Pfad berechnen (Struktur beibehalten) relative_path = path # z.B. "shared/tools/common/script.sh" target = mirror_target / relative_path try: if source.is_file(): target.parent.mkdir(parents=True, exist_ok=True) # Kopiere Datei-Inhalt target.write_bytes(source.read_bytes()) # Read-Only setzen (entferne Schreibrechte) target.chmod(0o444) mirrored.append(path) elif source.is_dir(): # Rekursiv alle Dateien im Verzeichnis spiegeln for file_path in source.rglob("*"): if file_path.is_file(): rel = file_path.relative_to(self.monorepo_root) file_target = mirror_target / str(rel) file_target.parent.mkdir(parents=True, exist_ok=True) file_target.write_bytes(file_path.read_bytes()) file_target.chmod(0o444) mirrored.append(path) else: errors.append(f"Pfad '{path}' ist weder Datei noch Verzeichnis.") except OSError as e: errors.append(f"Fehler beim Spiegeln von '{path}': {e}") logger.info( "Shared-Mirror für '%s': %d Pfade gespiegelt, %d Fehler.", context, len(mirrored), len(errors), ) return MirrorResult( success=len(errors) == 0, context=context, mirrored_paths=mirrored, errors=errors, ) def prepare_team_repo(self, context: str) -> Path: """Erzeugt ein Team_Repo mit nur dem eigenen Kontext. Erstellt eine saubere Kopie des Kontextordners ohne Referenzen auf andere Kontexte. Secrets werden entschlüsselt oder mit einem Team-Schlüssel re-keyed, sodass nur die Secrets des eigenen Kontexts im Team-Repo enthalten sind. Args: context: Arbeitskontext für das Team-Repo. Returns: Pfad zum vorbereiteten Team-Repo. Raises: ValueError: Wenn der Kontext ungültig ist oder kein Team-Repo konfiguriert ist. """ if context not in self.VALID_CONTEXTS: raise ValueError( f"Ungültiger Kontext '{context}'. " f"Erlaubt: {', '.join(sorted(self.VALID_CONTEXTS))}" ) team_entry = self._get_team_entry(context) if team_entry is None: raise ValueError( f"Kein Team-Repo für Kontext '{context}' konfiguriert." ) # Zielverzeichnis für das vorbereitete Team-Repo team_repo_path = self.monorepo_root / ".team-repos" / context team_repo_path.mkdir(parents=True, exist_ok=True) # Quellverzeichnis des Kontexts context_source = self.monorepo_root / context if not context_source.exists(): logger.warning( "Kontextordner '%s' existiert nicht. " "Erstelle leeres Team-Repo.", context ) return team_repo_path # Dateien kopieren (nur eigener Kontext) self._copy_context_files(context_source, team_repo_path, context) # Secrets-Handling: Nur Secrets des eigenen Kontexts einbeziehen self._handle_team_secrets(team_repo_path, context) # Isolation verifizieren report = self.verify_isolation(team_repo_path, context) if not report.is_isolated: logger.warning( "Team-Repo für '%s' enthält %d Cross-Context-Leaks. " "Bereinigung wird durchgeführt.", context, len(report.leaks), ) self._sanitize_leaks(team_repo_path, report.leaks) return team_repo_path def resolve_conflict( self, context: str, strategy: str = "team-wins" ) -> ConflictResult: """Löst Sync-Konflikte auf (Standard: Team_Repo hat Vorrang). Verwendet `git checkout --theirs` für team-wins (Team_Repo ist Single Source of Truth) oder `git checkout --ours` für hub-wins. Bei strategy='manual' wird keine automatische Auflösung durchgeführt. Die Strategie 'team-wins' entspricht Requirement 10.6: Das Team_Repo hat bei Konflikten Vorrang als Single Source of Truth. Args: context: Arbeitskontext mit Konflikten. strategy: Auflösungsstrategie ('team-wins', 'hub-wins', 'manual'). Returns: ConflictResult mit aufgelösten Dateien oder Fehlern. """ valid_strategies = ("team-wins", "hub-wins", "manual") if strategy not in valid_strategies: return ConflictResult( success=False, context=context, strategy=strategy, errors=[ f"Ungültige Strategie '{strategy}'. " f"Erlaubt: {', '.join(valid_strategies)}" ], ) team_entry = self._get_team_entry(context) if team_entry is None: return ConflictResult( success=False, context=context, strategy=strategy, errors=[f"Kein Team-Repo für Kontext '{context}' konfiguriert."], ) if strategy == "manual": logger.info( "Manuelle Konflikt-Auflösung für '%s' angefordert. " "Keine automatische Auflösung durchgeführt.", context, ) return ConflictResult( success=True, context=context, strategy=strategy, resolved_files=[], errors=[], ) # Ermittle Dateien mit Konflikten (unmerged files) try: result = self.sync_engine._run_git( ["diff", "--name-only", "--diff-filter=U"] ) conflicted_files = [ f.strip() for f in result.stdout.strip().splitlines() if f.strip() and f.strip().startswith(f"{context}/") ] except subprocess.CalledProcessError: # Alternativ: ls-files --unmerged try: result = self.sync_engine._run_git( ["ls-files", "--unmerged"] ) # Extrahiere Dateinamen aus ls-files Ausgabe seen: set[str] = set() conflicted_files = [] for line in result.stdout.strip().splitlines(): # Format: " \t" if "\t" in line: file_path = line.split("\t", 1)[1].strip() if file_path.startswith(f"{context}/") and file_path not in seen: conflicted_files.append(file_path) seen.add(file_path) except subprocess.CalledProcessError as e: return ConflictResult( success=False, context=context, strategy=strategy, errors=[f"Konflikte konnten nicht ermittelt werden: {e}"], ) if not conflicted_files: logger.info("Keine Konflikte für Kontext '%s' gefunden.", context) return ConflictResult( success=True, context=context, strategy=strategy, resolved_files=[], errors=[], ) # Löse Konflikte auf basierend auf Strategie # team-wins: --theirs (Team_Repo hat Vorrang = Single Source of Truth) # hub-wins: --ours (Monorepo-Hub hat Vorrang) checkout_flag = "--theirs" if strategy == "team-wins" else "--ours" resolved: list[str] = [] errors: list[str] = [] for file_path in conflicted_files: try: self.sync_engine._run_git( ["checkout", checkout_flag, "--", file_path] ) # Stage die aufgelöste Datei self.sync_engine._run_git(["add", file_path]) resolved.append(file_path) logger.info( "Konflikt aufgelöst (%s): %s", strategy, file_path, ) except subprocess.CalledProcessError as e: error_msg = ( f"Konflikt-Auflösung für '{file_path}' fehlgeschlagen: " f"{e.stderr or e.stdout or str(e)}" ) errors.append(error_msg) logger.warning(error_msg) success = len(errors) == 0 logger.info( "Konflikt-Auflösung für '%s' (%s): %d gelöst, %d Fehler.", context, strategy, len(resolved), len(errors), ) return ConflictResult( success=success, context=context, strategy=strategy, resolved_files=resolved, errors=errors, ) def onboard_member( self, context: str, member_info: MemberInfo ) -> MemberOnboardingResult: """Erteilt Zugang nur zum Team_Repo ohne Kenntnis des Monorepos. Das neue Mitglied erhält ausschließlich Zugang zum Team-Repo seines Kontexts. Die Existenz anderer Kontexte und des übergeordneten Monorepos wird nicht offenbart. Der Onboarding-Prozess: 1. Validiert den Kontext und die Member-Info 2. Ermittelt die Team-Repo-URL (nur diese wird dem Mitglied mitgeteilt) 3. Gewährt Zugang via Repository-URL (ohne Monorepo-Details) 4. Respektiert die konfigurierte Sync-Frequenz Args: context: Arbeitskontext, zu dem Zugang erteilt werden soll. member_info: Informationen über das neue Team-Mitglied. Returns: MemberOnboardingResult mit Details. """ # Validiere den Kontext team_entry = self._get_team_entry(context) if team_entry is None: return MemberOnboardingResult( success=False, context=context, member_name=member_info.name, errors=[f"Kein Team-Repo für Kontext '{context}' konfiguriert."], ) # Validiere Member-Info if not member_info.name or not member_info.name.strip(): return MemberOnboardingResult( success=False, context=context, member_name=member_info.name, errors=["Mitglied-Name darf nicht leer sein."], ) if not member_info.email or not member_info.email.strip(): return MemberOnboardingResult( success=False, context=context, member_name=member_info.name, errors=["Mitglied-E-Mail darf nicht leer sein."], ) # Das Mitglied erhält ausschließlich die Team-Repo-URL. # Keine Informationen über: # - Die Existenz des Monorepos # - Andere Kontexte (privat/dhive/bahn) # - Die Hub-and-Spoke-Topologie # - Shared-Bereiche (diese werden ggf. als Mirror bereitgestellt) team_repo_url = team_entry.url logger.info( "Onboarding: Mitglied '%s' (%s) erhält Zugang zum Team-Repo '%s' " "(Kontext: %s, Rolle: %s, Sync-Frequenz: %s).", member_info.name, member_info.email, team_repo_url, context, member_info.role, team_entry.sync_frequency, ) return MemberOnboardingResult( success=True, context=context, member_name=member_info.name, team_repo_url=team_repo_url, ) # ------------------------------------------------------------------- # Private Hilfsmethoden für Team-Isolation (Task 15.2) # ------------------------------------------------------------------- def _iter_text_files(self, root: Path) -> list[Path]: """Iteriert über alle Text-Dateien in einem Verzeichnis (rekursiv). Überspringt .git-Verzeichnisse, Binärdateien und versteckte Ordner. Args: root: Startverzeichnis für die Suche. Returns: Liste von Pfaden zu Text-Dateien. """ text_extensions = { ".py", ".yaml", ".yml", ".json", ".toml", ".cfg", ".ini", ".md", ".txt", ".rst", ".sh", ".bash", ".zsh", ".fish", ".env", ".gitattributes", ".gitignore", ".gitmodules", ".dockerfile", ".xml", ".html", ".css", ".js", ".ts", ".example", ".sample", ".template", ".conf", } # Dateinamen die immer als Text behandelt werden (unabhängig von Extension) text_filenames = { ".gitattributes", ".gitignore", ".gitmodules", ".env", ".env.example", ".env.sample", "Makefile", "Dockerfile", "Jenkinsfile", } files: list[Path] = [] if not root.exists(): return files for item in root.rglob("*"): # Skip .git directories and hidden folders parts = item.relative_to(root).parts if any(p.startswith(".git") and p != ".gitattributes" for p in parts): continue if item.is_file(): # Include files with known text extensions, known filenames, # or no extension suffix = item.suffix.lower() name = item.name if ( suffix in text_extensions or suffix == "" or name in text_filenames or name.startswith(".env") ): files.append(item) return files def _check_path_reference( self, line: str, other_context: str, file_path: str, line_number: int ) -> IsolationLeak | None: """Prüft eine Zeile auf Pfad-Referenzen zu einem anderen Kontext. Erkennt Muster wie: privat/, dhive/project, bahn/src Args: line: Zu prüfende Textzeile. other_context: Fremder Kontext, auf den geprüft wird. file_path: Relativer Pfad der Datei (für Report). line_number: Zeilennummer (für Report). Returns: IsolationLeak bei Fund, None sonst. """ # Muster: Kontextname gefolgt von / (als Pfad-Beginn) # Aber nicht als Teil eines längeren Wortes pattern = rf'(? IsolationLeak | None: """Prüft eine Zeile auf Umgebungsvariablen-Referenzen eines anderen Kontexts. Erkennt Muster wie: DHIVE_API_KEY, BAHN_TOKEN, PRIVAT_SECRET Args: line: Zu prüfende Textzeile. other_context: Fremder Kontext, auf den geprüft wird. file_path: Relativer Pfad der Datei (für Report). line_number: Zeilennummer (für Report). Returns: IsolationLeak bei Fund, None sonst. """ # Muster: KONTEXT_ als Prefix in Großbuchstaben (typisch für Env-Variablen) ctx_upper = other_context.upper() pattern = rf'\b{re.escape(ctx_upper)}_[A-Z][A-Z0-9_]*\b' if re.search(pattern, line): return IsolationLeak( file_path=file_path, line_number=line_number, leaked_context=other_context, leak_type="env_var", ) return None def _check_config_reference( self, line: str, other_context: str, file_path: str, line_number: int ) -> IsolationLeak | None: """Prüft eine Zeile auf Konfigurations-Referenzen zu einem anderen Kontext. Erkennt Muster wie: git-crypt-dhive, context: bahn, kontext: privat Args: line: Zu prüfende Textzeile. other_context: Fremder Kontext, auf den geprüft wird. file_path: Relativer Pfad der Datei (für Report). line_number: Zeilennummer (für Report). Returns: IsolationLeak bei Fund, None sonst. """ # Muster: git-crypt-{context} oder filter=git-crypt-{context} git_crypt_pattern = rf'git-crypt-{re.escape(other_context)}\b' if re.search(git_crypt_pattern, line): return IsolationLeak( file_path=file_path, line_number=line_number, leaked_context=other_context, leak_type="config_ref", ) # Muster: context: oder kontext: (YAML-Config) context_pattern = rf'(?:context|kontext)\s*[:=]\s*["\']?{re.escape(other_context)}\b' if re.search(context_pattern, line, re.IGNORECASE): return IsolationLeak( file_path=file_path, line_number=line_number, leaked_context=other_context, leak_type="config_ref", ) return None def _check_comment_reference( self, line: str, other_context: str, file_path: str, line_number: int ) -> IsolationLeak | None: """Prüft eine Zeile auf Kommentar-Referenzen zu einem anderen Kontext. Erkennt Muster in Kommentaren: # Für dhive-Team, // bahn-Projekt usw. Args: line: Zu prüfende Textzeile. other_context: Fremder Kontext, auf den geprüft wird. file_path: Relativer Pfad der Datei (für Report). line_number: Zeilennummer (für Report). Returns: IsolationLeak bei Fund, None sonst. """ # Prüfe ob die Zeile ein Kommentar ist (Python, Shell, YAML, JS/TS) stripped = line.strip() is_comment = ( stripped.startswith("#") or stripped.startswith("//") or stripped.startswith("/*") or stripped.startswith("*") or stripped.startswith("