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.
This commit is contained in:
Executable
+273
@@ -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 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()
|
||||
Reference in New Issue
Block a user