Files
govoplan/tools/release/govoplan_release/git_state.py
Albrecht Degering 05ae81d641
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 16s
Security Audit / security-audit (push) Failing after 11s
intermediate commit
2026-07-14 13:32:09 +02:00

179 lines
6.3 KiB
Python

"""Read-only Git and version metadata collection."""
from __future__ import annotations
import json
from pathlib import Path
import re
import subprocess
import tomllib
from .model import RepositorySnapshot, RepositorySpec, VersionSnapshot
from .workspace import resolve_repo_path
def collect_repository_snapshot(
spec: RepositorySpec,
*,
workspace_root: Path,
target_tag: str | None,
online: bool = False,
) -> RepositorySnapshot:
path = resolve_repo_path(spec, workspace_root)
if not path.exists():
return RepositorySnapshot(spec=spec, absolute_path=str(path), exists=False, is_git=False, errors=("repository directory is missing",))
if not (path / ".git").exists():
return RepositorySnapshot(spec=spec, absolute_path=str(path), exists=True, is_git=False, errors=("not a git repository",))
errors: list[str] = []
status_result = git(path, "status", "--porcelain", timeout=10)
if status_result.returncode != 0:
message = git_error(status_result)
if is_dubious_ownership_error(message):
return RepositorySnapshot(
spec=spec,
absolute_path=str(path),
exists=True,
is_git=True,
safe_directory_required=True,
safe_directory_command=safe_directory_command(path),
errors=(compact_git_error(message),),
)
return RepositorySnapshot(
spec=spec,
absolute_path=str(path),
exists=True,
is_git=True,
errors=(compact_git_error(message),),
)
branch = git_text(path, "branch", "--show-current")
has_head = git_success(path, "rev-parse", "--verify", "HEAD")
head = git_text(path, "rev-parse", "--short", "HEAD") if has_head else None
upstream = git_text(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") if has_head else None
ahead: int | None = None
behind: int | None = None
if upstream:
counts = git_text(path, "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
parts = counts.split()
if len(parts) == 2 and all(part.isdigit() for part in parts):
behind = int(parts[0])
ahead = int(parts[1])
local_target_tag_exists: bool | None = None
remote_target_tag_exists: bool | None = None
if target_tag:
local_target_tag_exists = git_success(path, "rev-parse", "-q", "--verify", f"refs/tags/{target_tag}")
if online:
remote_target_tag_exists = git_success(path, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{target_tag}", timeout=8)
try:
versions = collect_versions(path)
except Exception as exc: # noqa: BLE001 - surfaced in the dashboard.
errors.append(str(exc))
versions = VersionSnapshot()
return RepositorySnapshot(
spec=spec,
absolute_path=str(path),
exists=True,
is_git=True,
has_head=has_head,
head=head,
branch=branch or None,
upstream=upstream or None,
ahead=ahead,
behind=behind,
dirty_entries=tuple(line for line in status_result.stdout.splitlines() if line.strip()),
versions=versions,
local_target_tag_exists=local_target_tag_exists,
remote_target_tag_exists=remote_target_tag_exists,
remote_checked=online,
errors=tuple(errors),
)
def collect_versions(path: Path) -> VersionSnapshot:
return VersionSnapshot(
pyproject=read_pyproject_version(path),
package=read_json_version(path / "package.json"),
webui_package=read_json_version(path / "webui" / "package.json"),
manifests=read_manifest_versions(path),
)
def read_pyproject_version(path: Path) -> str | None:
pyproject = path / "pyproject.toml"
if not pyproject.exists():
return None
with pyproject.open("rb") as handle:
payload = tomllib.load(handle)
project = payload.get("project")
if isinstance(project, dict) and isinstance(project.get("version"), str):
return project["version"]
return None
def read_json_version(path: Path) -> str | None:
if not path.exists():
return None
payload = json.loads(path.read_text(encoding="utf-8"))
version = payload.get("version") if isinstance(payload, dict) else None
return version if isinstance(version, str) else None
def read_manifest_versions(path: Path) -> tuple[str, ...]:
src = path / "src"
if not src.exists():
return ()
versions: list[str] = []
for manifest in sorted(src.glob("**/backend/manifest.py")):
text = manifest.read_text(encoding="utf-8")
match = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)["\']', text)
if match is None:
match = re.search(r'(?m)^MODULE_VERSION\s*=\s*["\']([^"\']+)["\']', text)
if match is not None:
versions.append(match.group(1))
return tuple(versions)
def git_text(path: Path, *args: str, timeout: int = 10) -> str:
result = git(path, *args, timeout=timeout)
return result.stdout.strip() if result.returncode == 0 else ""
def git_success(path: Path, *args: str, timeout: int = 10) -> bool:
return git(path, *args, timeout=timeout).returncode == 0
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["git", *args],
cwd=path,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
return subprocess.CompletedProcess(["git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
def git_error(result: subprocess.CompletedProcess[str]) -> str:
return "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part)
def is_dubious_ownership_error(value: str) -> bool:
return "detected dubious ownership in repository" in value or "safe.directory" in value
def compact_git_error(value: str) -> str:
lines = [line.strip() for line in value.splitlines() if line.strip()]
return lines[0] if lines else "git command failed"
def safe_directory_command(path: Path) -> str:
return f"git config --global --add safe.directory {path}"