Initial monorepo structure

This commit is contained in:
2026-06-30 20:37:40 +02:00
commit 2f2b295531
121 changed files with 39171 additions and 0 deletions
@@ -0,0 +1,438 @@
"""Unit-Tests für den Read-Only-Schutzmechanismus und Sync-Fehlerprotokollierung.
Testet:
- protect_readonly installiert pre-commit Hook
- Hook verhindert Commits auf geschützte Verzeichnisse
- Fehlermeldung enthält Repo-Name und Read-Only-Status
- install_all_readonly_hooks schützt alle read-only Repos
- log_sync_error protokolliert Fehler in Audit-Datei
- Bestehende Hooks werden nicht überschrieben
Validates: Requirements 4.2, 4.5, 4.9
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
import yaml
from monorepo.models import RepoEntry
from monorepo.repos import RepoManager, _HOOK_END_MARKER, _HOOK_START_MARKER
@pytest.fixture
def repos_config(tmp_path: Path) -> Path:
"""Erstellt eine repos.yaml mit Read-Only- und Upstream-Repos."""
config_path = tmp_path / "config" / "repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"repos": [
{
"name": "symphony-spec",
"url": "https://github.com/openai/symphony",
"mode": "read-only",
"target": "shared/references/symphony",
"pinned": "v1.0.0",
"mechanism": "subtree",
},
{
"name": "another-readonly",
"url": "https://github.com/example/lib",
"mode": "read-only",
"target": "shared/references/lib",
"pinned": "main",
"mechanism": "subtree",
},
{
"name": "db-wissensdatenbank",
"url": "https://gitlab.example.com/db-wissen",
"mode": "upstream",
"target": "bahn/db-wissensdatenbank",
"pinned": "main",
"mechanism": "subtree",
},
]
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
return config_path
@pytest.fixture
def monorepo_root(tmp_path: Path) -> Path:
"""Erstellt ein Monorepo-Verzeichnis mit .git/hooks Struktur."""
root = tmp_path / "monorepo"
root.mkdir()
(root / ".git" / "hooks").mkdir(parents=True)
return root
@pytest.fixture
def manager(repos_config: Path, monorepo_root: Path) -> RepoManager:
"""Erstellt einen RepoManager mit Testkonfiguration."""
return RepoManager(config_path=repos_config, monorepo_root=monorepo_root)
# ---------------------------------------------------------------------------
# protect_readonly: Hook-Installation
# ---------------------------------------------------------------------------
class TestProtectReadonly:
"""Testet die Installation des Read-Only-Schutzmechanismus."""
def test_creates_pre_commit_hook(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""protect_readonly erstellt einen pre-commit Hook."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
def test_hook_contains_protected_path(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook enthält den geschützten Pfad."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "shared/references/symphony" in content
def test_hook_contains_error_message_template(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook enthält die Fehlermeldung mit Read-Only-Status."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "FEHLER:" in content
assert "read-only" in content
assert "Schreibzugriff" in content
def test_hook_is_executable(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook hat Ausführungsrechte (nur relevant auf Unix-Systemen)."""
import os
import sys
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
if sys.platform != "win32":
mode = hook_path.stat().st_mode
# Prüfe Owner-Execute-Bit
assert mode & 0o100
else:
# Auf Windows existiert das Execute-Bit-Konzept nicht,
# aber die chmod-Operation soll keinen Fehler werfen.
assert hook_path.exists()
def test_hook_uses_git_diff_cached(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook verwendet 'git diff --cached --name-only'."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert "git diff --cached --name-only" in content
def test_hook_starts_with_shebang(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Der Hook beginnt mit einem Shell-Shebang."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
assert content.startswith("#!/bin/sh")
def test_raises_error_for_path_outside_monorepo(
self, manager: RepoManager, tmp_path: Path
) -> None:
"""ValueError wenn Pfad außerhalb des Monorepo-Roots liegt."""
outside_path = tmp_path / "outside" / "some-repo"
outside_path.mkdir(parents=True)
with pytest.raises(ValueError, match="liegt nicht innerhalb"):
manager.protect_readonly(outside_path)
# ---------------------------------------------------------------------------
# protect_readonly: Bestehenden Hook aktualisieren
# ---------------------------------------------------------------------------
class TestProtectReadonlyUpdate:
"""Testet das Aktualisieren eines bestehenden pre-commit Hooks."""
def test_appends_to_existing_hook(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Fügt Schutzabschnitt an bestehenden Hook an."""
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
hook_path.write_text("#!/bin/sh\necho 'existing hook'\n", encoding="utf-8")
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
content = hook_path.read_text(encoding="utf-8")
assert "existing hook" in content
assert "PROTECTED_PATHS" in content
def test_updates_existing_readonly_section(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Ersetzt bestehenden Read-Only-Abschnitt statt ihn zu duplizieren."""
target1 = monorepo_root / "shared" / "references" / "symphony"
target1.mkdir(parents=True)
target2 = monorepo_root / "shared" / "references" / "lib"
target2.mkdir(parents=True)
# Erster Aufruf: Symphony schützen
manager.protect_readonly(target1)
# Zweiter Aufruf: Lib schützen (erweitert den bestehenden Abschnitt)
manager.protect_readonly(target2)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
# Beide Pfade sollen im Hook sein
assert "shared/references/symphony" in content
assert "shared/references/lib" in content
# Nur ein Start-Marker (kein Duplizieren)
assert content.count(_HOOK_START_MARKER) == 1
assert content.count(_HOOK_END_MARKER) == 1
def test_does_not_duplicate_path(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Fügt denselben Pfad nicht doppelt hinzu."""
target = monorepo_root / "shared" / "references" / "symphony"
target.mkdir(parents=True)
manager.protect_readonly(target)
manager.protect_readonly(target)
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
# Der Pfad soll nur einmal im PROTECTED_PATHS-Array vorkommen
assert content.count('"shared/references/symphony"') == 1
# ---------------------------------------------------------------------------
# install_all_readonly_hooks
# ---------------------------------------------------------------------------
class TestInstallAllReadonlyHooks:
"""Testet die Installation für alle read-only Repos."""
def test_installs_hooks_for_all_readonly_repos(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Installiert Schutz für alle read-only konfigurierten Repos."""
# Erstelle die Target-Verzeichnisse
(monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True)
(monorepo_root / "shared" / "references" / "lib").mkdir(parents=True)
manager.install_all_readonly_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
assert hook_path.exists()
content = hook_path.read_text(encoding="utf-8")
# Beide read-only Repos sollen geschützt sein
assert "shared/references/symphony" in content
assert "shared/references/lib" in content
def test_does_not_protect_upstream_repos(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Upstream-Repos werden NICHT als read-only geschützt."""
(monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True)
(monorepo_root / "shared" / "references" / "lib").mkdir(parents=True)
manager.install_all_readonly_hooks()
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
content = hook_path.read_text(encoding="utf-8")
# Upstream-Repo darf NICHT im Schutzbereich sein
assert "bahn/db-wissensdatenbank" not in content
def test_no_hook_when_no_readonly_repos(
self, tmp_path: Path
) -> None:
"""Kein Hook wenn keine read-only Repos konfiguriert sind."""
config_path = tmp_path / "config" / "repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"repos": [
{
"name": "upstream-only",
"url": "https://example.com/repo.git",
"mode": "upstream",
"target": "bahn/repo",
"pinned": "main",
"mechanism": "subtree",
}
]
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
root = tmp_path / "monorepo"
root.mkdir()
(root / ".git" / "hooks").mkdir(parents=True)
mgr = RepoManager(config_path=config_path, monorepo_root=root)
mgr.install_all_readonly_hooks()
hook_path = root / ".git" / "hooks" / "pre-commit"
assert not hook_path.exists()
# ---------------------------------------------------------------------------
# log_sync_error: Fehlerprotokollierung (Req 4.9)
# ---------------------------------------------------------------------------
class TestLogSyncError:
"""Testet die Sync-Fehlerprotokollierung."""
def test_creates_audit_directory(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Erstellt das .audit-Verzeichnis falls nicht vorhanden."""
manager.log_sync_error("test-repo", "Network timeout", "pull")
assert (monorepo_root / ".audit").exists()
def test_creates_sync_errors_log(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Erstellt die sync-errors.log Datei."""
manager.log_sync_error("test-repo", "Connection refused", "push")
log_path = monorepo_root / ".audit" / "sync-errors.log"
assert log_path.exists()
def test_log_contains_repo_name(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält den Repo-Namen."""
manager.log_sync_error("symphony-spec", "Auth failed", "pull")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
assert "repo=symphony-spec" in content
def test_log_contains_direction(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält die Sync-Richtung."""
manager.log_sync_error("test-repo", "Merge conflict", "full")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
assert "direction=full" in content
def test_log_contains_error_reason(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält den Fehlergrund."""
manager.log_sync_error("test-repo", "fatal: unable to access", "pull")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
assert "error=fatal: unable to access" in content
def test_log_contains_timestamp(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Log-Eintrag enthält einen ISO-Zeitstempel."""
manager.log_sync_error("test-repo", "Network error", "pull")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
# ISO-Timestamp Format: [YYYY-MM-DDTHH:MM:SS]
assert content.startswith("[20")
assert "T" in content.split("]")[0]
def test_appends_to_existing_log(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Mehrere Fehler werden untereinander protokolliert."""
manager.log_sync_error("repo-1", "Error 1", "pull")
manager.log_sync_error("repo-2", "Error 2", "push")
log_path = monorepo_root / ".audit" / "sync-errors.log"
content = log_path.read_text(encoding="utf-8")
lines = [line for line in content.strip().splitlines() if line]
assert len(lines) == 2
assert "repo-1" in lines[0]
assert "repo-2" in lines[1]
# ---------------------------------------------------------------------------
# Integration: _handle_sync_error schreibt auch in Audit-Log
# ---------------------------------------------------------------------------
class TestSyncErrorIntegration:
"""Testet dass Sync-Fehler automatisch in die Audit-Datei geschrieben werden."""
def test_sync_error_writes_to_audit_log(
self, manager: RepoManager, monorepo_root: Path
) -> None:
"""Ein Netzwerkfehler bei Sync wird in sync-errors.log protokolliert."""
from unittest.mock import patch
with patch("monorepo.repos.RepoManager._run_git") as mock_git:
mock_git.side_effect = subprocess.CalledProcessError(
returncode=128,
cmd=["git", "subtree", "pull"],
output="",
stderr="fatal: unable to access 'https://...': Could not resolve host",
)
result = manager.sync("symphony-spec")
assert result.success is False
log_path = monorepo_root / ".audit" / "sync-errors.log"
assert log_path.exists()
content = log_path.read_text(encoding="utf-8")
assert "repo=symphony-spec" in content
assert "Could not resolve host" in content