"""Repository discovery, offline snapshots and exact source fingerprints.""" from __future__ import annotations from dataclasses import dataclass import hashlib import os from pathlib import Path import stat import sys from .common import META_ROOT, digest, identifier, read_json from .process import require_capture @dataclass(frozen=True) class Repository: name: str path: Path aliases: tuple[str, ...] = () @dataclass(frozen=True) class Project: name: str repositories: tuple[Repository, ...] config: dict def load_project(workspace_root: Path, project: Path | None = None) -> Project: root = workspace_root.resolve() payload = read_json(project or META_ROOT / "repositories.json") if not isinstance(payload, dict): raise ValueError("Project manifest must be an object") if project: from .validation import validate_project validate_project(payload, root) records = payload.get("repositories") if not isinstance(records, list) or not records or len(records) > 256: raise ValueError("Project manifest requires 1–256 repositories") repos, names = [], set() for record in records: if not isinstance(record, dict) or not isinstance(record.get("path"), str): raise ValueError("Invalid repository record") if not record["path"] or ( project and set(record) - {"name", "path", "aliases"} ): raise ValueError("Repository requires a nonempty path and known fields") name = identifier(record.get("name")) raw = Path(record["path"]) if raw.is_absolute() or ".." in raw.parts: raise ValueError("Repository paths must be relative within the workspace") path = (root / raw).resolve() if not path.is_relative_to(root): raise ValueError("Repository path escapes the workspace") aliases = ( record.get("aliases", []) if project else [name.removeprefix("govoplan-")] ) if not isinstance(aliases, list) or any( not isinstance(alias, str) for alias in aliases ): raise ValueError("Repository aliases must be strings") if len(aliases) != len(set(aliases)): raise ValueError("Duplicate repository alias") keys = {name, *[identifier(alias) for alias in aliases]} if names.intersection(keys): raise ValueError("Repository names and aliases must be unambiguous") names.update(keys) repos.append(Repository(name, path, tuple(aliases))) return Project( payload.get("name", "Project" if project else "GovOPlaN"), tuple(repos), payload ) def inspect_repository(repo: Repository) -> dict: # Reuse the established read-only model, including distinct Git errors. sys.path.insert(0, str(META_ROOT / "tools/release")) if str( META_ROOT / "tools/release" ) not in sys.path else None from govoplan_release.git_state import collect_repository_snapshot from govoplan_release.model import RepositorySpec unsafe = unsafe_git_environment() if unsafe: return { "name": repo.name, "path": str(repo.path), "exists": repo.path.exists(), "is_git": (repo.path / ".git").exists(), "branch": None, "head": None, "upstream": None, "ahead": None, "behind": None, "remote_checked": False, "dirty_entries": [], "errors": ["Git environment overrides prevent scoped inspection"], "safe_directory_required": False, } snapshot = collect_repository_snapshot( RepositorySpec( name=repo.name, category="module", subtype="", remote="", path=str(repo.path), ), workspace_root=repo.path.parent, target_tag=None, online=False, ) return { "name": repo.name, "path": str(repo.path), "exists": snapshot.exists, "is_git": snapshot.is_git, "branch": snapshot.branch, "head": snapshot.head, "upstream": snapshot.upstream, "ahead": snapshot.ahead, "behind": snapshot.behind, "remote_checked": False, "dirty_entries": list(snapshot.dirty_entries), "errors": list(snapshot.errors), "safe_directory_required": snapshot.safe_directory_required, } def selected_repositories( project: Project, names: list[str], changed: bool = False ) -> list[Repository]: selected = list(project.repositories) if names: wanted = set(names) known = {key for repo in selected for key in (repo.name, *repo.aliases)} if wanted - known: raise ValueError( "Unknown repository filter: " + ", ".join(sorted(wanted - known)) ) selected = [ repo for repo in selected if wanted.intersection((repo.name, *repo.aliases)) ] if changed: result = [] for repo in selected: state = inspect_repository(repo) if ( state["errors"] or state["dirty_entries"] or state["ahead"] or (state["head"] and not state["upstream"]) ): result.append(repo) selected = result return selected def unsafe_git_environment() -> set[str]: return { key for key in os.environ if key.startswith("GIT_") and key not in {"GIT_OPTIONAL_LOCKS", "GIT_TERMINAL_PROMPT", "GIT_PAGER"} } def git_bytes(path: Path, *argv: str, allow_failure: bool = False) -> bytes: if unsafe_git_environment(): raise ValueError( "Git environment overrides prevent a scoped source fingerprint" ) result = require_capture( ["git", "--no-pager", "-C", str(path), *argv], timeout=30, max_stdout=32 * 1024 * 1024, env={**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}, ) if result.returncode: if allow_failure: return b"unborn" raise ValueError( f"Could not fingerprint Git state in {path.name}; inspect context first" ) return result.stdout def source_fingerprint(project: Project) -> str: """Bind HEAD, index and every tracked/untracked working file, including hidden changes.""" overall = hashlib.sha256() overall.update(digest(project.config).encode()) for repo in sorted(project.repositories, key=lambda item: item.name): overall.update(repo.name.encode() + b"\0" + str(repo.path).encode() + b"\0") if not repo.path.exists(): overall.update(b"missing\0") continue if not (repo.path / ".git").exists(): raise ValueError( f"Cannot establish source identity for non-Git repository {repo.name}" ) overall.update( git_bytes( repo.path, "rev-parse", "--verify", "HEAD", allow_failure=True ).strip() ) overall.update(git_bytes(repo.path, "ls-files", "--stage", "-z")) overall.update(git_bytes(repo.path, "ls-files", "-v", "-z")) tracked = git_bytes(repo.path, "ls-files", "--cached", "-z") untracked = git_bytes( repo.path, "ls-files", "--others", "--exclude-standard", "-z" ) for encoded_name in sorted( set(tracked.split(b"\0") + untracked.split(b"\0")) - {b""} ): name = os.fsdecode(encoded_name) relative = Path(name) if relative.is_absolute() or ".." in relative.parts: raise ValueError("Unsafe repository file name") path = repo.path / relative overall.update(encoded_name + b"\0") if not path.exists() and not path.is_symlink(): overall.update(b"deleted\0") continue metadata = path.lstat() overall.update(str(stat.S_IMODE(metadata.st_mode)).encode() + b"\0") if path.is_symlink(): overall.update(os.fsencode(os.readlink(path))) elif path.is_file(): if not path.resolve().is_relative_to(repo.path): raise ValueError("Fingerprint input escapes its repository") maximum = 64 * 1024 * 1024 descriptor = os.open( path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0), ) with os.fdopen(descriptor, "rb") as handle: before = os.fstat(handle.fileno()) if not stat.S_ISREG(before.st_mode) or before.st_size > maximum: raise ValueError( f"Fingerprint input must be a bounded regular file in {repo.name}" ) file_hash, count = hashlib.sha256(), 0 for chunk in iter( lambda: handle.read(min(1024 * 1024, maximum - count + 1)), b"" ): count += len(chunk) if count > maximum: raise ValueError("File grew beyond fingerprint limit") file_hash.update(chunk) after = os.fstat(handle.fileno()) if ( before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns, ) != ( after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns, ): raise ValueError( "File changed while calculating source identity" ) overall.update(file_hash.digest()) else: raise ValueError( "Unsupported changed-file type; cannot establish source identity" ) overall.update(b"\0") return overall.hexdigest()