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:
2026-07-05 10:55:42 -07:00
parent 3c566540aa
commit add356dcf2
24 changed files with 1669 additions and 4 deletions

123
core/tests/test_git.py Normal file
View File

@@ -0,0 +1,123 @@
"""Tests for git working-copy status/sync (core/src/castle_core/git.py)."""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from castle_core import git as G
# Identity so commits succeed without touching the user's global git config.
_ENV = {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
}
def _git(cwd: Path, *args: str) -> None:
subprocess.run(
["git", "-C", str(cwd), *args],
check=True,
capture_output=True,
text=True,
env={**_base_env(), **_ENV},
)
def _base_env() -> dict[str, str]:
import os
return dict(os.environ)
def _commit(cwd: Path, fname: str, text: str) -> None:
(cwd / fname).write_text(text)
_git(cwd, "add", fname)
_git(cwd, "commit", "-m", f"add {fname}")
@pytest.fixture
def repos(tmp_path: Path):
"""An `upstream` repo and a `work` clone of it (tracking upstream/main)."""
upstream = tmp_path / "upstream"
upstream.mkdir()
_git(upstream, "init", "-q", "-b", "main")
_commit(upstream, "a.txt", "one")
work = tmp_path / "work"
_git(tmp_path, "clone", "-q", str(upstream), str(work))
return upstream, work
def test_non_repo_is_benign(tmp_path: Path) -> None:
assert G.is_git_repo(tmp_path / "nope") is False
st = G.git_status(tmp_path / "nope")
assert st.is_repo is False and st.branch is None
ok, out = G.pull(tmp_path / "nope")
assert ok is False and "not a git" in out
def test_status_clean_and_up_to_date(repos) -> None:
_, work = repos
st = G.git_status(work, fetch=True)
assert st.is_repo and st.branch == "main"
assert st.dirty is False
assert st.behind == 0 and st.ahead == 0
assert st.upstream and st.upstream.endswith("main")
def test_behind_then_pull_fast_forwards(repos) -> None:
upstream, work = repos
_commit(upstream, "b.txt", "two") # advance the remote
st = G.git_status(work, fetch=True)
assert st.behind == 1 and st.ahead == 0
ok, out = G.pull(work)
assert ok is True, out
assert (work / "b.txt").exists()
after = G.git_status(work, fetch=True)
assert after.behind == 0 and after.dirty is False
def test_conflicting_dirty_tree_blocks_pull(repos) -> None:
"""A pull that would overwrite a locally-modified file is refused (ff-only never
merges), leaving the working copy untouched with git's own message."""
upstream, work = repos
_commit(upstream, "a.txt", "upstream change") # remote touches a.txt...
(work / "a.txt").write_text("local uncommitted change") # ...so does the work tree
assert G.git_status(work, fetch=False).dirty is True
ok, out = G.pull(work)
assert ok is False and out # "local changes would be overwritten"
assert (work / "a.txt").read_text() == "local uncommitted change"
def test_diverged_branch_blocks_ff_pull(repos) -> None:
"""Local commits the remote doesn't have → --ff-only refuses (no merge commit)."""
upstream, work = repos
_commit(upstream, "b.txt", "remote two")
_commit(work, "c.txt", "local two") # work now has a commit upstream lacks
st = G.git_status(work, fetch=True)
assert st.behind == 1 and st.ahead == 1
ok, out = G.pull(work)
assert ok is False and out
def test_detached_head_reported(repos) -> None:
_, work = repos
head = subprocess.run(
["git", "-C", str(work), "rev-parse", "HEAD"],
capture_output=True,
text=True,
env={**_base_env(), **_ENV},
).stdout.strip()
_git(work, "checkout", "-q", head)
st = G.git_status(work, fetch=False)
assert st.detached is True and st.branch is None

View File

@@ -0,0 +1,106 @@
"""Tests for the relationship model (core/src/castle_core/relations.py)."""
from __future__ import annotations
import pytest
import castle_core.config as C
from castle_core import relations as R
from castle_core.manifest import ProgramSpec, Requirement, SystemdDeployment
def _dep(program: str) -> SystemdDeployment:
return SystemdDeployment.model_validate(
{
"manager": "systemd",
"program": program,
"run": {"launcher": "command", "argv": [program]},
}
)
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
return C.CastleConfig(
root=None,
gateway=C.GatewayConfig(port=9000),
repo=None,
programs=programs,
deployments=deployments,
)
def test_system_dependencies_is_the_system_requirement_alias() -> None:
"""`system_dependencies` surfaces as a {kind: system} requirement."""
cfg = _cfg(
{"t": ProgramSpec(id="t", system_dependencies=["pandoc"])}, {"t": _dep("t")}
)
reqs = R.requirements_of(cfg, "t")
assert [(r.kind, r.ref) for r in reqs] == [("system", "pandoc")]
def test_requirements_merge_program_and_deployment_deduped() -> None:
prog = ProgramSpec(
id="web",
system_dependencies=["pandoc"],
requires=[Requirement(kind="deployment", ref="api", bind="API_URL")],
)
dep = SystemdDeployment.model_validate(
{
"manager": "systemd",
"program": "web",
"run": {"launcher": "command", "argv": ["web"]},
"requires": [{"kind": "system", "ref": "pandoc"}],
} # dup of program's
)
cfg = _cfg(
{"web": prog, "api": ProgramSpec(id="api")}, {"web": dep, "api": _dep("api")}
)
kinds = {(r.kind, r.ref) for r in R.requirements_of(cfg, "web")}
assert kinds == {("system", "pandoc"), ("deployment", "api")} # deduped
def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
"""A {kind: deployment} requirement becomes an edge (with bind), and the target's
fan-in is the count of distinct dependents."""
consumer = ProgramSpec(
id="web", requires=[Requirement(kind="deployment", ref="api", bind="API_URL")]
)
consumer2 = ProgramSpec(
id="cli", requires=[Requirement(kind="deployment", ref="api")]
)
cfg = _cfg(
{"web": consumer, "cli": consumer2, "api": ProgramSpec(id="api")},
{"web": _dep("web"), "cli": _dep("cli"), "api": _dep("api")},
)
m = R.build_model(cfg, check=False)
edge = next(e for e in m.edges if e.src == "web" and e.dst == "api")
assert edge.kind == "deployment" and edge.bind == "API_URL"
api = next(n for n in m.nodes if n.name == "api")
assert api.depended_on_by == 2 # web + cli
def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) -> None:
"""`functional?` is derived: a missing system package is unmet; a present
deployment requirement is satisfied."""
prog = ProgramSpec(
id="web",
system_dependencies=["pandoc"],
requires=[Requirement(kind="deployment", ref="api")],
)
cfg = _cfg(
{"web": prog, "api": ProgramSpec(id="api")},
{"web": _dep("web"), "api": _dep("api")},
)
monkeypatch.setattr(R.shutil, "which", lambda _: None) # nothing installed
m = R.build_model(cfg, check=True)
web = next(n for n in m.nodes if n.name == "web")
assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied
assert web.functional is False
def test_missing_deployment_requirement_is_unmet() -> None:
prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
cfg = _cfg({"web": prog}, {"web": _dep("web")})
m = R.build_model(cfg, check=True)
web = next(n for n in m.nodes if n.name == "web")
assert web.unmet == ["deployment:ghost"] and web.functional is False