Files
zemion 2ffdb23f69
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m30s
feat(devkit): add resumable workspace automation and UI review tooling
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.
2026-09-09 02:03:17 +02:00

274 lines
10 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 1256 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()