179 lines
6.3 KiB
Python
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}"
|