#!/usr/bin/env python3 import os import subprocess import sys from pathlib import Path ROOT = Path(".").resolve() TEST_DIRS = [ROOT / "tests", ROOT / "test"] CPP_EXTENSIONS = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"} TEST_PATTERNS = [ "test_{name}.cpp", "test_{name}.h", "{name}_test.cpp", "{name}_test.h", "{name}test.cpp", "{name}test.h", "{name}gtest.cpp", "{name}gtest.h", ] def run(cmd: str) -> str: return subprocess.check_output(cmd, shell=True, text=True).strip() def git_ref_exists(ref: str) -> bool: try: subprocess.check_call( f"git rev-parse --verify --quiet '{ref}'", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return True except subprocess.CalledProcessError: return False def is_pull_request_build() -> bool: return bool(os.environ.get("CHANGE_ID")) def resolve_target_ref() -> str | None: raw = os.environ.get("CHANGE_TARGET", "").strip() candidates = [] if raw: candidates.append(raw) if not raw.startswith("origin/"): candidates.append(f"origin/{raw}") candidates.extend(["origin/main", "main"]) seen = set() for ref in candidates: if ref and ref not in seen: seen.add(ref) if git_ref_exists(ref): return ref return None def changed_files(): target = resolve_target_ref() if target: print(f"Vergleiche gegen Ziel-Ref: {target}") out = run(f"git diff --name-only {target}...HEAD") return [line.strip() for line in out.splitlines() if line.strip()] if is_pull_request_build(): print("ERROR: Kein gültiger Ziel-Branch-Ref für PR-Build gefunden.") sys.exit(1) if git_ref_exists("HEAD~1"): print("Kein Ziel-Branch-Ref gefunden, verwende HEAD~1 für Branch-Build.") out = run("git diff --name-only HEAD~1..HEAD") return [line.strip() for line in out.splitlines() if line.strip()] print("Keine Vergleichsbasis gefunden, behandle alle versionierten Dateien als geändert.") out = run("git ls-files") return [line.strip() for line in out.splitlines() if line.strip()] def is_cpp_file(path: Path) -> bool: return path.suffix.lower() in CPP_EXTENSIONS def require_clang_format(): if not (ROOT / ".clang-format").exists(): print("ERROR: .clang-format fehlt im Repository-Root.") sys.exit(1) def check_format(path: Path): subprocess.check_call(f"clang-format --dry-run --Werror '{path}'", shell=True) def candidate_names(path: Path): stem = path.stem.lower() names = {stem} for suffix in ["_p", "_impl", "_private", "_priv", "_ui"]: if stem.endswith(suffix): names.add(stem[:-len(suffix)]) return sorted(n for n in names if n) def iter_test_files(): for test_dir in TEST_DIRS: if test_dir.exists(): yield from (p for p in test_dir.rglob("*") if p.is_file()) def has_related_test(path: Path) -> bool: test_files = list(iter_test_files()) if not test_files: return False test_names = [p.name.lower() for p in test_files] for name in candidate_names(path): for pattern in TEST_PATTERNS: if pattern.format(name=name).lower() in test_names: return True for test_name in test_names: if name in test_name and ("test" in test_name or "gtest" in test_name): return True return False def main(): require_clang_format() files = changed_files() if not files: print("Keine geänderten Dateien erkannt.") return for rel in files: path = ROOT / rel if not path.exists(): continue if is_cpp_file(path): print(f"Prüfe: {rel}") try: check_format(path) except subprocess.CalledProcessError: print(f"ERROR: Formatfehler in {rel}") sys.exit(1) if not has_related_test(path): print(f"ERROR: Kein passender Test unter tests/ oder test/ für {rel} gefunden.") sys.exit(1) print("Policy-Checks erfolgreich.") if __name__ == "__main__": main()