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

View File

@@ -0,0 +1,78 @@
import { GitBranch, Loader2, RefreshCw } from "lucide-react"
import type { GitStatus } from "@/types"
interface GitSyncRowProps {
status: GitStatus
program: string // the program whose page this is (excluded from "shared by")
loading: boolean // a fetch/refresh of the status is in flight
syncing: boolean // a git pull is in flight
onSync: () => void
}
// The "Git" value cell in the Program Info card: branch · behind/ahead · dirty,
// plus a Sync (git pull) button. Pull-only — converge stays a separate step. For a
// monorepo, sync operates on the whole repo and lists the sibling programs.
export function GitSyncRow({ status, program, loading, syncing, onSync }: GitSyncRowProps) {
const behind = status.behind ?? 0
const ahead = status.ahead ?? 0
return (
<span className="flex items-center gap-2 flex-wrap">
<span className="flex items-center gap-1.5">
<GitBranch size={13} className="text-[var(--muted)]" />
<span className="font-mono">{status.detached ? "detached" : status.branch ?? "—"}</span>
</span>
{status.behind === null ? (
<span className="text-xs text-[var(--muted)]">no upstream</span>
) : behind > 0 ? (
<span className="text-xs text-amber-400">
{behind} behind{ahead > 0 ? `, ${ahead} ahead` : ""}
</span>
) : (
<span className="text-xs text-[var(--muted)]">
up to date{ahead > 0 ? ` (${ahead} ahead)` : ""}
</span>
)}
<span className={`text-xs ${status.dirty ? "text-amber-400" : "text-[var(--muted)]"}`}>
· {status.dirty ? "dirty" : "clean"}
</span>
{status.error && (
<span className="text-xs text-red-400" title={status.error}>
· fetch error
</span>
)}
{status.repo?.multi && (
<span
className="text-xs text-[var(--muted)]"
title={`Shared repo — syncing pulls the whole ${status.repo.key} working copy`}
>
· shared by {status.repo.programs.filter((p) => p !== program).join(", ")}
</span>
)}
<button
onClick={onSync}
disabled={syncing || loading}
title={
status.repo?.multi
? `git pull the whole ${status.repo.key} repo (${status.repo.programs.join(", ")})`
: status.dirty
? "Working copy has local changes — a pull may be refused"
: "git pull (fast-forward)"
}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]/10 transition-colors disabled:opacity-40"
>
{syncing ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
)}
{status.repo?.multi ? "Sync repo" : "Sync"}
</button>
</span>
)
}