Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
274 lines
10 KiB
Python
Executable File
274 lines
10 KiB
Python
Executable File
"""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()
|