Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"""Unit-Tests für den RepoManager.
|
||||
|
||||
Testet die Einbindung und Synchronisation externer Repositories:
|
||||
- Subtree-Add und Submodule-Add
|
||||
- Sync-Ergebnismeldungen (Erfolg, Fehler, unbekannt)
|
||||
- Bidirektionale Sync für Upstream-Repos
|
||||
- Abbruch bei Merge-Konflikten
|
||||
- Konfigurationsmanagement (Laden/Speichern)
|
||||
|
||||
Validates: Requirements 4.1, 4.3, 4.4, 4.6, 4.7, 4.8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from monorepo.models import RepoEntry
|
||||
from monorepo.repos import RepoManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos_config(tmp_path: Path) -> Path:
|
||||
"""Erstellt eine temporäre repos.yaml mit Testdaten."""
|
||||
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": "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 empty_config(tmp_path: Path) -> Path:
|
||||
"""Erstellt eine leere repos.yaml."""
|
||||
config_path = tmp_path / "config" / "repos.yaml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump({"repos": []}, f)
|
||||
return config_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monorepo_root(tmp_path: Path) -> Path:
|
||||
"""Erstellt ein temporäres Monorepo-Verzeichnis."""
|
||||
root = tmp_path / "monorepo"
|
||||
root.mkdir()
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konfiguration laden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Testet das Laden der Repo-Konfiguration."""
|
||||
|
||||
def test_loads_repos_from_yaml(self, manager: RepoManager) -> None:
|
||||
"""Lädt alle Repos aus der Konfigurationsdatei."""
|
||||
assert len(manager.repos) == 2
|
||||
|
||||
def test_first_repo_is_readonly(self, manager: RepoManager) -> None:
|
||||
"""Erstes Repo ist als read-only konfiguriert."""
|
||||
assert manager.repos[0].name == "symphony-spec"
|
||||
assert manager.repos[0].mode == "read-only"
|
||||
|
||||
def test_second_repo_is_upstream(self, manager: RepoManager) -> None:
|
||||
"""Zweites Repo ist als upstream konfiguriert."""
|
||||
assert manager.repos[1].name == "db-wissensdatenbank"
|
||||
assert manager.repos[1].mode == "upstream"
|
||||
|
||||
def test_mechanism_defaults_to_subtree(self, tmp_path: Path) -> None:
|
||||
"""Mechanismus fällt auf 'subtree' zurück wenn nicht angegeben."""
|
||||
config_path = tmp_path / "repos.yaml"
|
||||
data = {
|
||||
"repos": [
|
||||
{
|
||||
"name": "test-repo",
|
||||
"url": "https://example.com/repo",
|
||||
"mode": "read-only",
|
||||
"target": "shared/test",
|
||||
"pinned": "main",
|
||||
}
|
||||
]
|
||||
}
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
|
||||
mgr = RepoManager(config_path=config_path, monorepo_root=tmp_path)
|
||||
assert mgr.repos[0].mechanism == "subtree"
|
||||
|
||||
def test_missing_config_file_returns_empty_list(self, tmp_path: Path) -> None:
|
||||
"""Fehlende Konfigurationsdatei ergibt leere Repo-Liste."""
|
||||
config_path = tmp_path / "nonexistent.yaml"
|
||||
mgr = RepoManager(config_path=config_path, monorepo_root=tmp_path)
|
||||
assert mgr.repos == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_repo: Subtree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddRepoSubtree:
|
||||
"""Testet das Einbinden eines Repos via Git-Subtree."""
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_subtree_add_calls_git_correctly(
|
||||
self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Subtree-Add ruft git subtree add mit korrekten Argumenten auf."""
|
||||
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
|
||||
entry = RepoEntry(
|
||||
name="test-repo",
|
||||
url="https://example.com/repo.git",
|
||||
mode="read-only",
|
||||
target="shared/refs/test",
|
||||
pinned="v2.0.0",
|
||||
mechanism="subtree",
|
||||
)
|
||||
|
||||
mock_git.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
|
||||
mgr.add_repo(entry)
|
||||
|
||||
mock_git.assert_called_once_with([
|
||||
"subtree",
|
||||
"add",
|
||||
"--prefix=shared/refs/test",
|
||||
"https://example.com/repo.git",
|
||||
"v2.0.0",
|
||||
"--squash",
|
||||
])
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_subtree_add_saves_to_config(
|
||||
self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Nach dem Hinzufügen wird das Repo in repos.yaml gespeichert."""
|
||||
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
|
||||
entry = RepoEntry(
|
||||
name="new-repo",
|
||||
url="https://example.com/new.git",
|
||||
mode="read-only",
|
||||
target="shared/new",
|
||||
pinned="main",
|
||||
mechanism="subtree",
|
||||
)
|
||||
|
||||
mock_git.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
|
||||
mgr.add_repo(entry)
|
||||
|
||||
# Konfigurationsdatei prüfen
|
||||
with open(empty_config, encoding="utf-8") as f:
|
||||
saved = yaml.safe_load(f)
|
||||
|
||||
assert len(saved["repos"]) == 1
|
||||
assert saved["repos"][0]["name"] == "new-repo"
|
||||
|
||||
def test_add_repo_fails_if_target_exists(
|
||||
self, empty_config: Path, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Fehler wenn das Zielverzeichnis bereits existiert."""
|
||||
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
|
||||
target = monorepo_root / "shared" / "existing"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
entry = RepoEntry(
|
||||
name="conflict-repo",
|
||||
url="https://example.com/repo.git",
|
||||
mode="read-only",
|
||||
target="shared/existing",
|
||||
pinned="main",
|
||||
mechanism="subtree",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="existiert bereits"):
|
||||
mgr.add_repo(entry)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_repo: Submodule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddRepoSubmodule:
|
||||
"""Testet das Einbinden eines Repos via Git-Submodule."""
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_submodule_add_calls_git_correctly(
|
||||
self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Submodule-Add ruft git submodule add und checkout auf."""
|
||||
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
|
||||
entry = RepoEntry(
|
||||
name="sub-repo",
|
||||
url="https://example.com/sub.git",
|
||||
mode="read-only",
|
||||
target="shared/refs/sub",
|
||||
pinned="v1.0.0",
|
||||
mechanism="submodule",
|
||||
)
|
||||
|
||||
# Target-Verzeichnis darf NICHT existieren (wird von git erstellt)
|
||||
# Aber _add_submodule braucht es für den checkout-Aufruf.
|
||||
# Wir simulieren, dass git submodule add das Verzeichnis erstellt:
|
||||
def side_effect(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
if args[0] == "submodule":
|
||||
# git submodule add erstellt das Verzeichnis
|
||||
(monorepo_root / "shared" / "refs" / "sub").mkdir(parents=True)
|
||||
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
|
||||
mock_git.side_effect = side_effect
|
||||
|
||||
mgr.add_repo(entry)
|
||||
|
||||
# Erstes Call: submodule add
|
||||
assert mock_git.call_count == 2
|
||||
first_call = mock_git.call_args_list[0]
|
||||
assert first_call[0][0] == [
|
||||
"submodule",
|
||||
"add",
|
||||
"https://example.com/sub.git",
|
||||
"shared/refs/sub",
|
||||
]
|
||||
|
||||
# Zweites Call: checkout pinned version
|
||||
second_call = mock_git.call_args_list[1]
|
||||
assert second_call[0][0] == ["checkout", "v1.0.0"]
|
||||
|
||||
def test_unknown_mechanism_raises_error(
|
||||
self, empty_config: Path, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Unbekannter Mechanismus führt zu ValueError."""
|
||||
mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root)
|
||||
entry = RepoEntry(
|
||||
name="bad-repo",
|
||||
url="https://example.com/bad.git",
|
||||
mode="read-only",
|
||||
target="shared/bad",
|
||||
pinned="main",
|
||||
mechanism="unknown", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unbekannter Mechanismus"):
|
||||
mgr.add_repo(entry)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync: Read-Only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncReadOnly:
|
||||
"""Testet die Synchronisation von Read-Only-Repos."""
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_sync_readonly_success(
|
||||
self, mock_git: MagicMock, manager: RepoManager
|
||||
) -> None:
|
||||
"""Erfolgreiche Synchronisation eines Read-Only-Repos."""
|
||||
mock_git.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="1 file changed, 10 insertions(+)", stderr=""
|
||||
)
|
||||
|
||||
result = manager.sync("symphony-spec")
|
||||
|
||||
assert result.success is True
|
||||
assert result.direction == "pull"
|
||||
assert result.commits_synced >= 1
|
||||
assert result.conflicts == []
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_sync_readonly_already_up_to_date(
|
||||
self, mock_git: MagicMock, manager: RepoManager
|
||||
) -> None:
|
||||
"""Repo ist bereits auf dem neuesten Stand."""
|
||||
mock_git.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="Already up to date.\n", stderr=""
|
||||
)
|
||||
|
||||
result = manager.sync("symphony-spec")
|
||||
|
||||
assert result.success is True
|
||||
assert result.commits_synced == 0
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_sync_readonly_network_error(
|
||||
self, mock_git: MagicMock, manager: RepoManager
|
||||
) -> None:
|
||||
"""Netzwerkfehler bei Synchronisation liefert Fehlerergebnis."""
|
||||
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
|
||||
assert result.direction == "pull"
|
||||
assert len(result.conflicts) == 1
|
||||
assert "fehlgeschlagen" in result.conflicts[0].details
|
||||
|
||||
def test_sync_unknown_repo_returns_failure(self, manager: RepoManager) -> None:
|
||||
"""Sync eines unbekannten Repos gibt Fehlermeldung zurück."""
|
||||
result = manager.sync("nonexistent-repo")
|
||||
|
||||
assert result.success is False
|
||||
assert "nicht in Konfiguration" in result.conflicts[0].details
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync: Upstream (bidirektional)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncUpstream:
|
||||
"""Testet die bidirektionale Synchronisation für Upstream-Repos."""
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_sync_upstream_success_pull_and_push(
|
||||
self, mock_git: MagicMock, manager: RepoManager
|
||||
) -> None:
|
||||
"""Erfolgreiche bidirektionale Sync (Pull + Push)."""
|
||||
mock_git.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="1 file changed", stderr=""
|
||||
)
|
||||
|
||||
result = manager.sync("db-wissensdatenbank")
|
||||
|
||||
assert result.success is True
|
||||
assert result.direction == "full"
|
||||
# Pull + Push → mindestens 2 Aufrufe
|
||||
assert mock_git.call_count == 2
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_sync_upstream_merge_conflict_aborts(
|
||||
self, mock_git: MagicMock, manager: RepoManager
|
||||
) -> None:
|
||||
"""Merge-Konflikt bei Upstream-Sync bricht ab."""
|
||||
# Erster Call (Pull) schlägt mit Merge-Konflikt fehl
|
||||
mock_git.side_effect = subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=["git", "subtree", "pull"],
|
||||
output="CONFLICT (content): Merge conflict in src/main.py\n"
|
||||
"Automatic merge failed; fix conflicts and then commit the result.",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
result = manager.sync("db-wissensdatenbank")
|
||||
|
||||
assert result.success is False
|
||||
assert len(result.conflicts) >= 1
|
||||
# Merge-Abort wurde aufgerufen
|
||||
assert mock_git.call_count >= 2 # pull + merge --abort
|
||||
|
||||
@patch("monorepo.repos.RepoManager._run_git")
|
||||
def test_sync_upstream_push_failure(
|
||||
self, mock_git: MagicMock, manager: RepoManager
|
||||
) -> None:
|
||||
"""Push-Fehler bei Upstream-Sync liefert Fehlerergebnis."""
|
||||
# Erstes Call (Pull) erfolgreich, zweites (Push) schlägt fehl
|
||||
success = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="Already up to date.\n", stderr=""
|
||||
)
|
||||
push_error = subprocess.CalledProcessError(
|
||||
returncode=128,
|
||||
cmd=["git", "subtree", "push"],
|
||||
output="",
|
||||
stderr="fatal: Authentication failed",
|
||||
)
|
||||
mock_git.side_effect = [success, push_error]
|
||||
|
||||
result = manager.sync("db-wissensdatenbank")
|
||||
|
||||
assert result.success is False
|
||||
assert "fehlgeschlagen" in result.conflicts[0].details
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge-Konflikt-Handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMergeConflictHandling:
|
||||
"""Testet das Verhalten bei Merge-Konflikten."""
|
||||
|
||||
def test_is_merge_conflict_detects_conflict(self, manager: RepoManager) -> None:
|
||||
"""Erkennt Merge-Konflikte anhand typischer Git-Meldungen."""
|
||||
error = subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=["git"],
|
||||
output="CONFLICT (content): Merge conflict in file.py",
|
||||
stderr="",
|
||||
)
|
||||
assert manager._is_merge_conflict(error) is True
|
||||
|
||||
def test_is_merge_conflict_not_a_conflict(self, manager: RepoManager) -> None:
|
||||
"""Erkennt Nicht-Konflikte korrekt."""
|
||||
error = subprocess.CalledProcessError(
|
||||
returncode=128,
|
||||
cmd=["git"],
|
||||
output="",
|
||||
stderr="fatal: unable to access remote",
|
||||
)
|
||||
assert manager._is_merge_conflict(error) is False
|
||||
|
||||
def test_extract_conflict_files(self, manager: RepoManager) -> None:
|
||||
"""Extrahiert Dateinamen aus Konfliktausgabe."""
|
||||
output = (
|
||||
"CONFLICT (content): Merge conflict in src/main.py\n"
|
||||
"CONFLICT (content): Merge conflict in tests/test_app.py\n"
|
||||
"Automatic merge failed"
|
||||
)
|
||||
files = manager._extract_conflict_files(output)
|
||||
assert "src/main.py" in files
|
||||
assert "tests/test_app.py" in files
|
||||
|
||||
def test_extract_conflict_files_empty_output(self, manager: RepoManager) -> None:
|
||||
"""Leere Ausgabe ergibt leere Dateiliste."""
|
||||
assert manager._extract_conflict_files("") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsmethoden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelperMethods:
|
||||
"""Testet interne Hilfsmethoden."""
|
||||
|
||||
def test_count_commits_files_changed(self, manager: RepoManager) -> None:
|
||||
"""Erkennt Commits anhand 'files changed' in Ausgabe."""
|
||||
output = " 3 files changed, 20 insertions(+), 5 deletions(-)"
|
||||
assert manager._count_commits_from_output(output) == 1
|
||||
|
||||
def test_count_commits_already_up_to_date(self, manager: RepoManager) -> None:
|
||||
"""'Already up to date' bedeutet 0 Commits."""
|
||||
output = "Already up to date.\n"
|
||||
assert manager._count_commits_from_output(output) == 0
|
||||
|
||||
def test_count_commits_empty_output(self, manager: RepoManager) -> None:
|
||||
"""Leere Ausgabe ergibt 0 Commits."""
|
||||
assert manager._count_commits_from_output("") == 0
|
||||
|
||||
def test_find_repo_exists(self, manager: RepoManager) -> None:
|
||||
"""Findet ein existierendes Repo."""
|
||||
entry = manager._find_repo("symphony-spec")
|
||||
assert entry is not None
|
||||
assert entry.url == "https://github.com/openai/symphony"
|
||||
|
||||
def test_find_repo_not_found(self, manager: RepoManager) -> None:
|
||||
"""Gibt None zurück wenn Repo nicht gefunden."""
|
||||
assert manager._find_repo("nonexistent") is None
|
||||
Reference in New Issue
Block a user