feat(devkit): add resumable workspace automation and UI review tooling
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m30s

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.
This commit is contained in:
2026-09-09 02:03:17 +02:00
parent 14b19fbead
commit 2ffdb23f69
67 changed files with 17306 additions and 94 deletions
+273
View File
@@ -0,0 +1,273 @@
"""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()