feat: relationship model (requires/repos/predicates) + git sync
A derived, mostly-computed model of how programs, deployments, and repos relate,
plus the git-sync surfaces that motivated it. See docs/relationships.md.
Core:
- `requires: [{kind, ref, version?, bind?}]` on programs + deployments — one
precondition relation; `system_dependencies` is its `{kind: system}` alias.
kind fixes meaning + check (system=installed, deployment=exists).
- relations.py: derives repos (git toplevel / monorepo), fan-in, and the
predicates functional?/fresh?/deployed? — nothing stored.
- env is generated FROM a `{kind: deployment, bind}` requirement (target URL →
consumer env), never scraped back into one; explicit defaults.env still wins.
- git.py: working-copy status/pull, repo toplevel + remote url.
Surfaces:
- `castle graph` + GET /graph — the relationship diagnostic.
- GET /repos, /repos/{key}/git|sync — repo-scoped sync (a repo is the sync unit;
a monorepo backs several programs). GET /programs/{name}/git|sync + repo context.
- Dashboard: Graph screen, program-page git status + repo-aware Sync, and a
monorepo banner on Programs.
Governing principle: predicates are derived; encode only the non-derivable, as a
node or edge property. Pull-only sync — converge stays a separate step.
This commit is contained in:
159
core/src/castle_core/git.py
Normal file
159
core/src/castle_core/git.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Git working-copy status and sync for programs whose source is a git repo.
|
||||
|
||||
Programs that declare a ``repo:`` URL are cloned once (``castle program clone``);
|
||||
this module lets a running castle *see how far behind* a working copy is and pull
|
||||
later updates. It is intentionally pull-only — it touches files on disk and never
|
||||
builds, applies, or restarts anything. Making the running artifact reflect the new
|
||||
code (rebuild a frontend, restart a service) stays an explicit, separate step via
|
||||
``castle apply`` / ``castle restart``.
|
||||
|
||||
Plain ``git`` via subprocess (matching ``castle program clone``); no GitPython.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Bound network calls (fetch/pull) so an unreachable remote can't hang a request.
|
||||
_FETCH_TIMEOUT = 20.0
|
||||
_PULL_TIMEOUT = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitStatus:
|
||||
"""A program working copy's git state. ``ahead``/``behind`` are relative to the
|
||||
upstream tracking branch and reflect the *last fetch* (``git_status(fetch=True)``
|
||||
refreshes them). ``None`` counts mean "no upstream to compare against"."""
|
||||
|
||||
is_repo: bool
|
||||
branch: str | None = None
|
||||
upstream: str | None = None
|
||||
dirty: bool = False
|
||||
ahead: int | None = None
|
||||
behind: int | None = None
|
||||
detached: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _git(
|
||||
source: Path, *args: str, timeout: float | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run ``git -C <source> <args>`` capturing text output."""
|
||||
return subprocess.run(
|
||||
["git", "-C", str(source), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def is_git_repo(source: Path | None) -> bool:
|
||||
"""True when ``source`` is inside a git working tree."""
|
||||
if not source or not Path(source).is_dir():
|
||||
return False
|
||||
try:
|
||||
r = _git(
|
||||
Path(source), "rev-parse", "--is-inside-work-tree", timeout=_FETCH_TIMEOUT
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return r.returncode == 0 and r.stdout.strip() == "true"
|
||||
|
||||
|
||||
def toplevel(source: Path | None) -> str | None:
|
||||
"""The absolute path of the git working copy ``source`` lives in, or None.
|
||||
|
||||
The natural identity of a *repo*: several programs whose sources share a
|
||||
toplevel are the same working copy (a monorepo). Adopted single-program repos
|
||||
are their own toplevel — the N=1 case."""
|
||||
if not source or not Path(source).is_dir():
|
||||
return None
|
||||
try:
|
||||
r = _git(Path(source), "rev-parse", "--show-toplevel", timeout=_FETCH_TIMEOUT)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return r.stdout.strip() or None if r.returncode == 0 else None
|
||||
|
||||
|
||||
def remote_url(source: Path | None) -> str | None:
|
||||
"""The ``origin`` remote URL of the working copy, or None (no remote)."""
|
||||
if not is_git_repo(source):
|
||||
return None
|
||||
r = _git(Path(source), "remote", "get-url", "origin") # type: ignore[arg-type]
|
||||
return r.stdout.strip() or None if r.returncode == 0 else None
|
||||
|
||||
|
||||
def git_status(source: Path | None, fetch: bool = True) -> GitStatus:
|
||||
"""The working copy's branch/dirty/ahead/behind state.
|
||||
|
||||
``fetch=True`` runs ``git fetch`` first (bounded, tolerant of an offline remote)
|
||||
so ``behind`` reflects the real remote; on fetch failure the counts fall back to
|
||||
the last-known values and ``error`` carries the reason. Never raises — a
|
||||
non-repo returns ``GitStatus(is_repo=False)`` so callers can just hide the UI.
|
||||
"""
|
||||
if not is_git_repo(source):
|
||||
return GitStatus(is_repo=False)
|
||||
src = Path(source) # type: ignore[arg-type]
|
||||
st = GitStatus(is_repo=True)
|
||||
|
||||
# Branch (or detached HEAD).
|
||||
branch = _git(src, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
||||
if branch == "HEAD":
|
||||
st.detached = True
|
||||
else:
|
||||
st.branch = branch
|
||||
|
||||
# Dirty working tree (staged, unstaged, or untracked).
|
||||
st.dirty = bool(_git(src, "status", "--porcelain").stdout.strip())
|
||||
|
||||
# Best-effort refresh from the remote; failure is non-fatal (offline, no remote).
|
||||
if fetch:
|
||||
try:
|
||||
fr = _git(src, "fetch", "--quiet", timeout=_FETCH_TIMEOUT)
|
||||
if fr.returncode != 0:
|
||||
st.error = (fr.stderr or fr.stdout).strip() or "git fetch failed"
|
||||
except subprocess.TimeoutExpired:
|
||||
st.error = "git fetch timed out"
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
st.error = str(e)
|
||||
|
||||
# Upstream tracking branch, then the ahead/behind split against it.
|
||||
up = _git(src, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||
if up.returncode == 0 and up.stdout.strip():
|
||||
st.upstream = up.stdout.strip()
|
||||
counts = _git(src, "rev-list", "--left-right", "--count", "@{u}...HEAD")
|
||||
if counts.returncode == 0:
|
||||
parts = counts.stdout.split()
|
||||
if len(parts) == 2:
|
||||
st.behind, st.ahead = int(parts[0]), int(parts[1])
|
||||
return st
|
||||
|
||||
|
||||
def head(source: Path | None) -> str | None:
|
||||
"""The working copy's current commit sha, or None if unavailable. Lets a caller
|
||||
tell whether a ``pull`` actually advanced the tree (before != after)."""
|
||||
if not is_git_repo(source):
|
||||
return None
|
||||
r = _git(Path(source), "rev-parse", "HEAD") # type: ignore[arg-type]
|
||||
return r.stdout.strip() or None if r.returncode == 0 else None
|
||||
|
||||
|
||||
def pull(source: Path | None) -> tuple[bool, str]:
|
||||
"""Fast-forward the working copy to its upstream (``git pull --ff-only``).
|
||||
|
||||
``--ff-only`` is deliberate: it refuses to merge, so a dirty or diverged tree
|
||||
fails cleanly with git's own message instead of creating a merge commit. Returns
|
||||
``(ok, combined_output)``.
|
||||
"""
|
||||
if not is_git_repo(source):
|
||||
return False, "not a git repository"
|
||||
try:
|
||||
r = _git(Path(source), "pull", "--ff-only", timeout=_PULL_TIMEOUT) # type: ignore[arg-type]
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "git pull timed out"
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return False, str(e)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return r.returncode == 0, out
|
||||
Reference in New Issue
Block a user