diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..fec2196 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,1504 @@ +# The phora guide + +This is the long-form companion to the [README](README.md). The README is the +reference — terse, every flag in one place. This guide is the walkthrough: it +starts with a working setup, explains how phora works, and then goes under the +hood into how phora actually stores, fetches, and verifies things. Read it top to +bottom the first time; after that, jump to the section you need. + +If you just want the command, it's in the README. This guide is for understanding +why phora did what it did — or what to do when it didn't. + +## Contents + +- [How phora works](#how-phora-works) +- [Your first sync](#your-first-sync) +- [Sources](#sources) + - [Git sources](#git-sources) + - [URL sources](#url-sources) + - [Which to reach for](#which-to-reach-for) +- [Choosing what ships](#choosing-what-ships) +- [Targets](#targets) +- [Bindings: per-target selection](#bindings-per-target-selection) + - [Identity, and one source as several slices](#identity-and-one-source-as-several-slices) + - [Per-target versions: one source, many refs](#per-target-versions-one-source-many-refs) + - [Binding scope is rejected](#binding-scope-is-rejected) +- [Where artifacts land: layouts](#where-artifacts-land-layouts) +- [Renaming leaves: one file under another name](#renaming-leaves-one-file-under-another-name) +- [Collapse: how a taken set materializes](#collapse-how-a-taken-set-materializes) +- [Staying in sync](#staying-in-sync) +- [Hooks: running commands after a sync](#hooks-running-commands-after-a-sync) +- [Templating: per-machine values](#templating-per-machine-values) +- [The local dev loop: link mode](#the-local-dev-loop-link-mode) +- [Transitive dependencies](#transitive-dependencies) + - [How a dependency composes](#how-a-dependency-composes) + - [Subsetting a mounted dependency](#subsetting-a-mounted-dependency) + - [Confinement](#confinement) + - [Trusting a dependency's hooks](#trusting-a-dependencys-hooks) + - [Reproducibility](#reproducibility) +- [Under the hood](#under-the-hood) + - [One store for everything](#one-store-for-everything) + - [No working tree: the object store is the substrate](#no-working-tree-the-object-store-is-the-substrate) + - [Fetching a git source](#fetching-a-git-source) + - [Fetching and importing a URL source](#fetching-and-importing-a-url-source) + - [Why a URL import is deterministic](#why-a-url-import-is-deterministic) + - [Projection and the content digest](#projection-and-the-content-digest) + - [The lock and content identity](#the-lock-and-content-identity) + - [Templating and the two digests](#templating-and-the-two-digests) + - [Versioning, the git way](#versioning-the-git-way) + - [Verification](#verification) + - [Execution model: parallel reads, one serial writer](#execution-model-parallel-reads-one-serial-writer) + - [Hook dispatch and recording](#hook-dispatch-and-recording) + - [Composing a dependency graph](#composing-a-dependency-graph) + - [Integrity boundaries](#integrity-boundaries) +- [When something looks wrong](#when-something-looks-wrong) +- [Where to look next](#where-to-look-next) + +## How phora works + +phora moves directory-shaped payloads from where they live (a git repo, or a URL) +into the places on disk that consume them — and keeps a record precise enough to +detect, later, whether anything drifted. + +The vocabulary splits by who owns what. A *source* owns its *offer* — the +set of paths it publishes. A *target binding* owns its *take* — the subset of that +offer it actually wants, possibly renamed. Nothing in between merges silently: + +- A *source* is where content comes from. It is one of four kinds, and exactly + one: a forge (`host` + `repo`), a local path (`path = "/dir"`), a literal git + remote (`git = "…"`), or a downloadable resource (`url = "https://…"`). A source + also *owns its offer*: `root` re-anchors the slice it draws from, and + `include`/`exclude` (gitignore syntax) compose into `include − exclude` — the leaf + set the source publishes. +- The *offer* is that published leaf set, named relative to the source's `root`. + With no `include` the offer is everything in the source minus VCS metadata + (`.git/`); an `include` narrows it and an `exclude` prunes it (exclude wins; there + is no `!` re-inclusion). Dotfiles match like any other path. +- An *artifact* is one offered leaf, identified by its full offered path — not a + top-level directory. It is the unit a target takes, renames, and deploys. A + wholly-taken directory may *collapse* back into a single directory artifact (see + [Collapse](#collapse-how-a-taken-set-materializes)). +- A *target* is a local directory you project artifacts into, arranged by a + *layout*, drawing from the bindings it declares. See [Targets](#targets). +- A *binding* is the edge from a target to a source — an entry in a target's + `sources`, written either as a bare name in a flat list or as a keyed entry in a + `[targets..sources]` table. The source decides provenance and the offer; the + binding *owns the take*: its `take` subsets and renames the offer for that one + target, without touching the source or any other target. See + [Bindings](#bindings-per-target-selection). +- The *lock* (`phora.lock`) pins each source to one exact commit, so two machines + syncing the same config get byte-identical results. +- The *registry* (under the state root — `XDG_STATE_HOME`, see [One store for + everything](#one-store-for-everything)) remembers what landed where — which commit, + which content hash, which files — so phora can later detect drift, conflicts, + and orphans. + +| Term | Owner | What it does | +| -------- | ------ | ----------------------------------------------------------------------- | +| offer | source | the published leaf set: `include − exclude` (gitignore) under `root`; no `include` ⇒ everything minus `.git/` | +| take | target | subsets and renames the offer per binding (literal / glob / `{ src = dest }`) | +| artifact | — | one offered leaf, identified by its full offered path | +| collapse | target | how a taken set materializes: per-leaf, or one directory artifact | + +Everything phora does is one pipeline, and every source runs through it the same +way: + +1. Fetch or import the bytes into a local store. +2. Resolve the source to one commit and write it to the lock. +3. Project each binding's taken artifacts into their targets. +4. Record what was deployed, and verify it on demand. + +The store is git, and everything becomes a git tree — a cloned repo or a +downloaded tarball alike. That is why a URL source +deploys, locks, and verifies exactly like a git source. Step 1 differs; steps 2–4 +are shared code. + +## Your first sync + +The fastest start is a single command — `phora add` writes the config for you: + +```bash +phora add srnnkls/dotfiles # add the source and bind it into [targets.default] (deploys into ".") +phora sync +``` + +That records `[sources.dotfiles]`, ensures a `[targets.default]` (path `.`, flat +layout), and projects the source's artifacts into the project directory. `--to +` routes to a named target instead — creating it, on a prompt, if it does +not exist — and `[defaults] auto_target = false` turns the default off so a bare +`add` only declares the source. + +For control over *where* things land — a specific target path, the slice of the +source a target *takes*, a layout — use the `bind` and `add` flags, or write +`phora.toml` directly; the flags just edit the file for you. By hand the same setup +is: + +```toml +version = 1 + +[sources.dotfiles] +host = "github" # resolves to https://github.com/srnnkls/dotfiles.git +repo = "srnnkls/dotfiles" # forge owner/repo +branch = "main" +root = ".config" # re-anchor the offer at the repo's .config/ subtree + +[targets.config] +path = "~/.config" +layout = "flat" + +[targets.config.sources] +dotfiles = { take = ["zellij/**"] } # this target takes the repo's zellij/ config subtree +``` + +Then sync: + +```bash +phora sync +``` + +Here is what that one command did, in order: + +1. Read `phora.toml` (and `phora.local.toml` if present, overlaid per-key). +2. Mirrored `github.com/srnnkls/dotfiles` into a bare repo under the cache root's `git/` + subdirectory, resolved `main` to a concrete commit, and wrote it to `phora.lock`. +3. Resolved the binding's `take` (`zellij/**`) against the source's offer, and + materialized the taken leaves into `~/.config` using the `flat` layout — + collapsing the wholly-taken `zellij/` tree into a single directory artifact. +4. Recorded each deployed file's content hash in the registry. + +Confirm what happened: + +```bash +phora list # per-target status: which artifacts are deployed, and their state +phora verify # re-hash every deployed file against the record; exit 0 if all match +``` + +`phora verify` is what the record is for: the difference between "the files are +there" and "the files are exactly what phora put there." It exits +non-zero on the first mismatch, so it fits into a pre-commit hook or CI check. + +Later, to pick up upstream changes: + +```bash +phora update # re-resolve sources to their latest commit, then sync +``` + +`sync` honors the lock; `update` advances it. That distinction runs through the +whole tool: a plain `sync` is reproducible and offline-friendly, an `update` +reaches for new commits. + +## Sources + +### Git sources + +A git source names a repository and a point in its history. Pin exactly one of +`branch`, `tag`, or `rev`; setting two is a config error. + +You can declare the remote two ways. Literally: + +```toml +[sources.bat] +git = "https://github.com/sharkdp/bat.git" # or ssh://…, or git@host:owner/repo +tag = "v0.24.0" +``` + +or symbolically, against a host alias: + +```toml +[sources.tropos] +host = "github" # built-in; may be omitted, in which case it defaults to github +repo = "srnnkls/tropos" +branch = "main" +``` + +The symbolic form exists so your config records intent (`github` + `owner/repo`) +rather than a baked-in URL, which means you can switch protocol or re-point a +whole forge without editing every source. `github`, `gitlab`, `codeberg`, +`sr.ht`, and `bitbucket` are built in with both https and ssh shapes. Add your own +with a `[hosts.X]` block whose `remote` template fills `{path}`, `{owner}`, and +`{repo}`: + +```toml +[hosts.company] +remote = { https = "https://git.company.com/{path}.git", ssh = "git@git.company.com:{path}.git" } +``` + +A third form names a local checkout directly: + +```toml +[sources.dignity] +path = "~/projects/dignity" # a local filesystem path, used verbatim as the remote +branch = "main" +``` + +A bare `path = "owner/repo"` (no `host`) is a *local* path, not a forge shorthand — +the github shorthand is the bare `repo = "owner/repo"`. A local source is what +[link mode](#the-local-dev-loop-link-mode) live-links against. + +Because the store is keyed by a normalized remote, the literal and symbolic +forms of one repo, and its https and ssh remotes, all share a single mirror. +Switching between them never re-clones. (The [internals](#fetching-a-git-source) +explain why.) + +### URL sources + +A URL source points at a downloadable resource — a release tarball, a zip, or a +single file: + +```toml +[sources.fzf-bin] +url = "https://github.com/junegunn/fzf/releases/download/0.55.0/fzf-0.55.0-linux_amd64.tar.gz" +digest = "sha256:0123…" # optional; verified before extraction +include = ["fzf"] +``` + +phora downloads it, optionally checks the digest, extracts it, and imports the +contents as a source — after which it discovers, exports, deploys, and verifies +exactly like a git source. A few things are worth knowing up front: + +- Formats: tar, tar.gz/tgz, and zip, detected by content (the magic bytes), not + the file extension. Anything that is not a recognized archive becomes a single + file, named from the URL's basename. +- Auto-strip: if an archive has exactly one top-level directory — the + `fzf-0.55.0/` wrapper common in release tarballs — phora strips it, so a version bump + doesn't reshuffle your paths or your lock. +- No refs, no re-rooting: a URL source is a single imported snapshot, not a + repository, so it has no history to point into and no subtree to descend into — + `branch`, `tag`, `rev`, and `root` are all config errors on a URL source. + `include`/`exclude` still shape its offer, filtering the imported tree. +- Integrity: an optional `digest = "sha256:…"` or `blake3:…` (64 hex chars) is + verified against the downloaded bytes *before* anything is extracted. A mismatch + errors, naming the source and showing expected vs actual. The archive is not + extracted until the digest check passes. +- Determinism: identical bytes always import to the identical commit, so an + unchanged URL is a no-op on the next sync, and a changed one advances the + lock. The [internals](#why-a-url-import-is-deterministic) cover how. + +### Which to reach for + +Use a git source when the upstream is a repository and you want a moving target +(`branch`) or a pinned one (`tag`/`rev`) with full history available to the +mirror. Use a URL source when the upstream publishes built artifacts — release +binaries, vendored bundles, a single script — that have no meaningful git history +you care about. Both end up in the same store with the same guarantees; the choice +is about where the bytes naturally live. + +## Choosing what ships + +Selection has two sides, and they belong to different owners. A *source* publishes +an *offer* — the leaf set it makes available. A *target binding* states a *take* — +the subset of that offer it actually wants. This section is the source side, the +offer; the take has its [own section](#bindings-per-target-selection). + +On a source, `root` re-anchors the slice phora draws from (git sources only — URL +archives are already stripped to their root), and the offer is named relative to it. +`include` and `exclude` are *gitignore-syntax* lists composed into `include − +exclude`: an `include` narrows the offer and an `exclude` prunes it, with exclude +winning and no `!` re-inclusion. With no `include` at all, the offer is everything +in the source minus VCS metadata (`.git/`). Dotfiles match like any other path — +there is no special-casing. + +```toml +[sources.dotfiles] +host = "github" +repo = "srnnkls/dotfiles" +root = ".config" +include = ["zellij", "ripgrep"] +exclude = ["**/*.local.toml", "**/.DS_Store"] +``` + +The offer is the *artifact set itself*: each offered leaf is an artifact, identified +by its full offered path (`zellij/config.kdl`, not just `zellij`). The offer is what +every target sees; a binding cannot widen it, only take a subset (see +[Bindings](#bindings-per-target-selection)). Scope lives on the source so that one +edit changes what *all* consumers can see, while a binding's take stays a private, +per-target slice. + +When a file does or doesn't ship and you can't see why, don't guess — ask: + +```bash +phora check-match --source dotfiles zellij/config.kdl +``` + +It tells you whether a given path passes the include/exclude rules for that +source — both whether the path is offered and whether its top-level artifact is — +which is almost always faster than reading globs by eye. For the consumer side — +which include offered a path, and how a binding's `take` then resolves it — reach +for [`phora explain`](#when-something-looks-wrong). + +## Targets + +A *target* is a local directory phora projects artifacts into. It has three parts: +a `path` (where on disk), a `layout` (how artifacts are arranged inside it), and its +`sources` — the bindings it deploys. + +```toml +[targets.config] +path = "~/.config" +layout = "flat" +sources = ["dotfiles"] +``` + +`path` is where the target lives. A leading `~/` expands to your home directory; a +relative path resolves against the project root — the directory `phora` runs in — +which is what lets a repository deploy into itself. Only `~/` is expanded (no `$VAR`, +no bare `~`). phora creates the directory, and any missing parents, on the first +sync that lands a file there. + +`layout` arranges artifacts inside the target — `flat` (the default), `by-source`, +or `prefixed`. It only starts to matter once a target holds more than one binding, +so the detail lives in its own section: [Where artifacts +land](#where-artifacts-land-layouts). + +`sources` is the target's set of bindings — each entry an edge to a source, with its +own `take` for this target. The keyed `[targets..sources]` table is the general +form; the flat list of bare names is an ergonomic shorthand for the case where every +binding takes the whole offer. A target deploys exactly its bindings and nothing else, +so a target with no `sources` key — or an empty `sources = []` — deploys nothing at +all. The [next section](#bindings-per-target-selection) is entirely about that edge. +You can edit the set with `phora bind --to ` and `phora unbind + --from `, or write the table by hand. + +A config can declare as many targets as you like, each with its own path, layout, +and bindings. One source can fan out across several targets — a `dotfiles` source +feeding `~/.config`, `~/.codex`, and a project tree — and one target can +compose several sources. `phora add` ensures a `[targets.default]` (path `.`, flat +layout) unless you route elsewhere with `--to` or disable it with `[defaults] +auto_target = false` (see [Your first sync](#your-first-sync)). + +## Bindings: per-target selection + +A target's `sources` is not just a list of source names — each entry is a +*binding*: the edge from this one target to a source, and where a single +consumer's *take* lives. The source says *what is on offer*; the binding says *how +much of it this target takes, and under what names*. + +A target's `sources` takes one of two forms — never both at once: + +- A *flat list of bare names* — `sources = ["dotfiles", "loqui"]` — takes each + source's *whole offer*. This is the all-bare, zero-take form, and each element is + equivalent to `name = {}` (no `take`, i.e. take everything). +- A *keyed table* — `[targets..sources]`, a map whose *key is the binding + identity* and whose value is *always a table* refining that one binding. The key + *defaults to the source name*; you write `source` *only when the identity + diverges* from it. A bare entry inside a keyed target is `name = {}`. A binding + may carry `take`, `collapse`, `template`, and a per-target ref + (`branch`/`tag`/`rev`); the *offer scope* (`root`/`include`/`exclude`) is not a + binding key — it belongs to the source. + +A binding's `take` is a list that *subsets and renames the offer*. Each entry is one +of three things: + +- a *literal leaf* — a plain offered path like `"zellij/config.kdl"`, kept verbatim; +- a *gitignore glob* — any entry with `*`, `?`, `[`, `]`, or a trailing `/`, like + `"zellij/**"`, which expands over the offer set only, never widening it; +- a *rename* `{ "src" = "dest" }` — the offered leaf `src` is consumed and emitted + at `dest` instead (destructive: it does not also land at `src`). + +Omitting `take` takes the whole offer; `take = []` takes nothing. A literal or +rename `src` that the offer does not expose is a hard error — a take may not widen +the offer, and the diagnostic suggests the nearest offered leaf. A glob that matches +nothing only warns. + +```toml +[targets.config] +path = "~/.config" + +[targets.config.sources] +zellij = { source = "dotfiles", take = ["zellij/**"] } +``` + +Here the `zellij` binding takes just the `zellij/` subtree of `dotfiles` for this target +alone. The source's offer is unchanged, and another target binding `dotfiles` bare +still takes the whole offer. + +### Identity, and one source as several slices + +Every binding has an *identity*. In a flat list it is the source name; in a keyed +table it *is* the table key, which defaults to the source name. Identity is the +binding's name in three places: it keys the registry artifact record, and it is the +label the `by-source` and `prefixed` layouts use (see +[layouts](#where-artifacts-land-layouts)). Identity is structurally unique because +TOML table keys are unique — there is no way to write the same identity twice. +Bindings resolve in identity order, sorted alphabetically, independent of how a flat +`sources` list is written. + +Because it is the *identity* that keys a binding and not the source, the same +source can appear in one target more than once under distinct keys, each taking an +independent slice: + +```toml +[targets.configs] +path = "~/configs" +layout = "by-source" # ~/configs/codex/config.toml and ~/configs/anax/config.toml + +[targets.configs.sources] +codex = { source = "dotfiles", take = [{ "codex/config.toml" = "config.toml" }] } +anax = { source = "dotfiles", take = [{ "anax/config.toml" = "config.toml" }] } +``` + +One source, one mirror — but two bindings taking two slices into the same target, +each renamed to a bare `config.toml` that would collide under `flat` and is kept +apart by its identity. This is what per-binding `take` is for: a target composes +slices, not whole sources. + +### Per-target versions: one source, many refs + +Selection is not the only thing a binding can override. A binding may also set its +own *ref* — `branch`/`tag`/`rev` — and pin this one target at its own version. The +rule mirrors selection exactly: the source's ref is the default, a binding's ref +wins for that target alone, and a bare binding inherits the source's ref. As on a +source, at most one ref per binding (precedence within a binding is `rev` > `tag` > +`branch`); naming two is a config error. + +This is what lets one source live at two versions inside a *single* target. Give +each binding a distinct table key, name the same `source` on each, and pin a +different ref: + +```toml +[targets.tools] +path = "~/.local/tools" +layout = "by-source" # ~/.local/tools/stable/… and ~/.local/tools/canary/… + +[targets.tools.sources] +stable = { source = "fzf", tag = "v0.55.0" } +canary = { source = "fzf", tag = "v0.56.0" } +``` + +The two bindings share one mirror and one fetch, but resolve to two different +commits and project independently. Under the hood each distinct ref gets its own +lock entry; bindings that don't override the ref collapse onto the source's ref and +share a single entry, so a config that names no binding refs produces a lock +identical to one with no per-binding refs at all (see [the +lock](#the-lock-and-content-identity)). + +### Binding scope is rejected + +`root`, `include`, `exclude`, and `map` are *not* binding keys — setting any of +them on a `[targets..sources]` entry is a hard parse error with a did-you-mean +redirect. `root`/`include`/`exclude` redirect to the source offer +(`[sources.]`); `map` redirects to the `take` rename form. The error points +you at the new home: + +``` +error: config error: selection: include — binding-level scope is removed +matched against: binding `dotfiles` of target `home` +remedy: move `include` to the source offer on `[sources.dotfiles]`; scope is owned by the source, not the binding +to debug: phora explain home dotfiles +``` + +### The URL restriction + +`take` (or any other refinement) on a binding backed by a `url` source is a config +error: a URL source's content was stripped to a single root at import, so there is +no offer to subset. `branch`/`tag`/`rev` on a binding backed by a `url` source — +which has no ref — or a `deploy = "link"` source — which live-links a working tree +rather than resolving a pinned commit — are config errors too. Bind a URL source +bare. + +### Editing bindings + +The CLI can edit bindings for you, if you'd rather not touch the table by hand: + +```bash +phora bind dotfiles --to config # bare binding, takes the whole offer +phora bind dotfiles --to config --as zellij --take zellij/** # a taken slice, identity `zellij` +phora unbind zellij --from config # remove a binding by its identity +``` + +`phora bind … --to ` adds bindings; the binding flags `--as`, +`--take …`, and `--branch`/`--tag`/`--rev` scope to the binding. A `--take` +entry is a leaf, a glob, or a `src=dest` rename (the `=` form writes the +`{ src = dest }` rename table). Passing any binding flag writes a keyed +`[targets..sources]` table entry; passing none appends a bare source name to the +target's flat list, or writes `name = {}` if the target is already keyed (the writer +promotes a flat list to a keyed table on the first refinement, and never +auto-demotes). `--branch`/`--tag`/`--rev` pin that target's version +(`bind fzf --to tools --as canary --tag v0.56.0`). Because `--as` sets one identity, +it cannot apply to several sources at once. `phora unbind … --from +` removes bindings *by identity* — so you unbind a slice by the name it was +bound under (`zellij`), not by its source. + +`--root` is the exception: it is source-owned, not a binding key, so `bind --root` +writes `root` onto each named `[sources.]` table (and errors if a named source +is not declared in the file). Likewise `phora add `'s offer flags `--root`, +`--include …`, and `--exclude …` shape the *new source's* offer — they +land on `[sources.]`, never on a binding. With `--to `, `phora add` +also accepts `--as` to set the binding identity (requiring exactly one `--to` +target). The ref flags stay source-level on `add` — a source is added *at* a +version, so per-target ref overrides are a `bind` concern alone. The +`--local`/`--symlink` overlay forms accept neither `--to` nor binding flags. + +## Where artifacts land: layouts + +A layout decides the path an artifact `a` from a binding of identity `i` takes +inside a target. The label is the binding's *identity* — the `[targets..sources]` +table key, defaulting to the source name — not the underlying source: + +| Layout | Path on disk | +| ----------------------------------- | ------------ | +| `flat` (default) | `a` | +| `by-source` | `i/a` | +| `{ type = "prefixed", separator = "-" }` | `i-a` | + +`flat` is what you want when one binding owns a target. The moment two bindings +project into the same target, `flat` risks collisions — two bindings each +shipping a `config.toml` would fight over the same destination. That is what +`by-source` and `prefixed` are for, and because they label by identity, two +slices of the *same* source land cleanly side by side: + +```toml +[targets.configs] +path = "~/configs" +layout = "by-source" # ~/configs/codex/config.toml, ~/configs/anax/config.toml + +[targets.configs.sources] +codex = { source = "dotfiles", take = [{ "codex/config.toml" = "config.toml" }] } +anax = { source = "dotfiles", take = [{ "anax/config.toml" = "config.toml" }] } +``` + +## Renaming leaves: one file under another name + +There is no separate `map` construct — renaming is the `{ "src" = "dest" }` form of +a binding's `take`. Where the rest of phora keeps an offered leaf at its own path, a +rename entry consumes one offered leaf and emits it at a chosen destination instead. +The canonical case is a single shared file fanned out under the names different tools +expect: + +```toml +[targets.agents] +path = "~/myproject" + +[targets.agents.sources] +dotfiles = { take = [{ ".shared/AGENTS.md" = "AGENTS.md" }] } +claude = { source = "dotfiles", take = [{ ".shared/AGENTS.md" = "CLAUDE.md" }] } +codex = { source = "dotfiles", take = [{ ".shared/AGENTS.md" = "codex.md" }] } +``` + +One `.shared/AGENTS.md` in the source now lands three times, under three names, with no +copies in the source tree. Because identity is the table key and keys are unique, +each binding takes a distinct key (the same rule that lets one source appear several +times), and the three destinations coexist. + +A few rules follow from what a rename *is*, and phora rejects each violation: + +- The `src` must be offered. A rename whose `src` is not in the offer is a hard + error — a take may not widen the offer — and the diagnostic suggests the nearest + offered leaf. A leaf named both as a literal take and as a rename `src` is rejected + too. +- The `dest` is a portable relative path inside the deploy root: an absolute path, a + `..` escape, or a backslash is rejected. Nested dests are allowed (`"a/b.md"`). +- Renaming is *destructive*: the leaf is emitted only at `dest`, never also at `src`. + A `src` already covered by a glob in the same take is consumed out of that glob, so + it is not double-emitted. +- No within-binding clash: two entries resolving to the same destination, or one + `src` renamed to two dests, are config errors. +- A URL source has no offer to point a `src` into, so a rename on a URL binding is + rejected too — bind a URL source bare. + +Two bindings that resolve to the *same* destination collide — `phora sync` stops with +the structured selection diagnostic, naming the contested destination, rather than +letting one silently overwrite the other. Across distinct bindings and targets the +same source leaf renamed to *different* dests never clashes, and the source is +fetched once. + +A renamed leaf is otherwise an ordinary artifact. It is keyed in the registry by +`/`, `phora verify` re-hashes it, `--prune` reclaims it by identity +when you drop the binding, and `deploy = "link"` links it to the source leaf for the +editing loop. One subtlety worth knowing: the content digest frames the +*destination* path into the hash, so the same source bytes under two names produce +two distinct digests — each rename is its own artifact, not an alias of the other. + +## Collapse: how a taken set materializes + +An artifact is an offered *leaf*, so a binding that takes a whole subtree would, by +the letter of that, deploy a flat pile of per-file artifacts. `collapse` is what +folds a wholly-taken directory back into a single *directory artifact* — one record, +one destination — which is both tidier in `phora list` and what link mode needs to +hang a single directory symlink. It is a binding-level opt, exempt from the +binding-scope rejection alongside `take` and `template`. + +- *Omitted — the algorithmic default.* A directory collapses to one artifact exactly + when every offered leaf under it is taken at its identity and no per-leaf rename + targets it; collapse is maximal, taking the topmost clean directory. Under `link`, + a within-directory exclude blocks collapse and the directory falls back to per-leaf + with a warning; under `copy`, an excluded child is simply pruned from the subtree + and the directory still collapses. +- `collapse = false` — *force per-leaf.* Every kept leaf stays its own artifact even + on a wholly-taken directory (snapshot semantics). +- `collapse = true` — *demand the directory artifact.* A hard error, naming the + directory, if a within-directory exclude (under `link`) or a per-leaf rename makes + whole-directory collapse impossible. This is the analogue of dotter's `recurse`: + request the directory symlink/subtree, and fail loudly when it cannot be honored. + +```toml +[targets.config.sources] +# force a per-leaf snapshot even though the whole tree is taken: +zellij = { source = "dotfiles", take = ["zellij/**"], collapse = false } +``` + +`phora preview` marks a collapsed directory with a trailing slash (`zellij/`), so the +plan shows at a glance which artifacts are whole directories and which are loose +leaves. + +## Staying in sync + +`phora sync` is the workhorse. It resolves each source against the lock, projects +artifacts, and reconciles what is on disk with what should be there. Two flags +change its disposition: + +- `--prune` also removes artifacts that the registry tracks but the config no + longer selects — the way you clean up after dropping a binding or narrowing a + `take`. The prune is leaf-granular: narrowing one binding's `take` reclaims only + the now-unselected leaves, never its siblings. +- `--force` overwrites locally modified or foreign files instead of stopping to + ask. + +About that asking: when sync finds a file that was changed outside phora, or a +foreign file sitting where an artifact wants to land, it prompts on a TTY — + +``` +[s]kip / [o]verwrite / [e]ject / [a]bort +``` + +— and on a non-interactive run it skips such files unless you passed `--force`. +`eject` tells phora to stop managing that artifact but leave its files in place +(see `phora eject` / `phora uneject` for doing this deliberately). + +`phora update` is the only command that reaches for new commits. Without it, every +`sync` reproduces the locked state, which is exactly what you want on a fresh +checkout or in CI. With it, sources re-resolve to their latest commit and the lock +advances — for a URL source, only if the downloaded content actually changed. + +## Hooks: running commands after a sync + +Deploying the files is sometimes not the last step — a font cache may need +rebuilding, a plugin manager a sync, an index a refresh. A hook is a shell command +phora runs *after* it has written the files. Hooks live only in your config +(`phora.toml` or `phora.local.toml`); a synced source tree that happens to carry its +own `phora.toml` is inert content, read as files and never executed. + +There are two kinds. A target's `on_change` fires once after a sync that *added or +changed* that target's artifacts — a pure no-op sync runs nothing, and pure removals +don't count either. The global `[hooks] post_sync` runs after *every* sync, change +or not: + +```toml +[targets.config.hooks] +on_change = "mise install" + +[hooks] +post_sync = "git -C ~/.config add -A" +``` + +A hook value is a command string, a `{ run = "…", shell = "…" }` table (the shell +defaults to `sh -c`), or an array of either, run in declared order and deduplicated. +An `on_change` hook is handed what changed — `$PHORA_CHANGED` (deployed paths), +`$PHORA_CHANGED_NAMES` (artifact names), and `$PHORA_TARGET` — and it runs only +after the files are on disk, so it can read them. + +The recording rules are what make hooks idempotent without you tracking anything: + +- A hook that succeeds is recorded, so the next no-op sync does not re-run it. +- A hook that exits non-zero is *not* recorded: `phora sync` exits non-zero too, the + deployed files stay in place, and the hook re-fires on the next sync. Fix the + cause, sync again, and it runs — even though no content changed. +- `phora sync --no-hooks` deploys without running any hook. + +Each hook that ran is reported with its scope and status, so a sync that triggers +one reads: + +``` +hook config#mise install#sh -c [on_change] `mise install` ok +sync complete +``` + +## Templating: per-machine values + +Most configuration is identical on every machine; a few values — an email, a +hostname, a path — are not. Rather than fork a file per machine, render it. A source +file named `*.tmpl` is run through [minijinja](https://docs.rs/minijinja) and +deployed with the suffix stripped (`config.tmpl` → `config`); every other file +copies byte-for-byte. Values come from a flat `[vars]` table, and `phora.local.toml` +overrides them per key — the keys it omits keep their base value — so the committed +config carries the shape and each machine fills in its own: + +```toml +# phora.toml — committed +[vars] +gobin = "~/go/bin" + +# mise/config.toml.tmpl, deployed as mise/config.toml: +# GOBIN = "{{ gobin }}" +``` + +```toml +# phora.local.toml — never committed +[vars] +gobin = "~/.local/go/bin" +``` + +The `.tmpl` suffix is the opt-in; a binding can widen it to arbitrary globs +(`template = ["*.conf"]`, rendered *in addition to* `*.tmpl`) or switch it off +entirely (`template = false`). Rendering is strict: a reference to an undefined +variable aborts that one artifact's export — its siblings still deploy — so a typo +fails loudly instead of shipping a half-rendered file. + +The integrity story is the subtle part, and it is deliberate. phora hashes the +*rendered* bytes into the registry, so `phora verify` checks the output you actually +deployed, not the template. But the *lock* records *source* bytes only — so two +machines rendering the same template with different vars produce byte-identical +locks, and reproducibility stays machine-independent. Editing a var moves no commit: +it marks the affected artifacts outdated, and the next `phora sync` re-renders and +redeploys them with the lock untouched. `phora preview --files` shows each deployed +name and flags what renders (`mise/config.toml (templated)`). + +## The local dev loop: link mode + +The default deploy mode, `copy`, materializes each artifact from the committed git +object store: a point-in-time, content-hashed, verifiable copy (a copy-on-write +reflink where the filesystem supports it — see [projection](#projection-and-the-content-digest)). +That is the right default, but it is the wrong loop when you are actively editing +the source — you do +not want to commit and re-sync after every keystroke. + +`deploy = "link"` swaps the copy for a symlink pointing at the source's live +working tree. Edits show up through the target immediately, no re-sync: + +```toml +# phora.local.toml — overlays phora.toml, never committed +[sources.loqui] +path = "~/projects/loqui" # a local checkout +deploy = "link" +``` + +Two rules apply: + +- The source must be a local filesystem path. A `path = "/dir"` source (or the + `git = "/dir"` alias) qualifies; linking a remote URL is a config error that + names the source. A relative path counts as local only if it resolves against the + working directory — a relative path that does not yet exist is rejected as "not + local". +- Link mode is allowed in either `phora.toml` or `phora.local.toml`, but a + committed link over an *absolute* path is rarely portable — an absolute checkout + path means something different on every machine. So a committed link over an + absolute path syncs but prints a non-fatal warning naming the source; a committed + link over a *relative* (portable) path warns nothing; and a link in + `phora.local.toml` — where machine-specific checkouts belong — never warns. The + warning nudges you toward portability without blocking a deliberate choice. + +One consequence to keep in mind: a linked artifact sits *outside* the integrity +model. Its registry record carries a `linked` marker and no per-file hashes, so +`phora verify` skips it, drift detection never flags it, and `phora list` shows it +as `linked`. That is the deal you are making — live edits in exchange for the +content guarantee. Switch back to `copy` and the next sync replaces the symlink +with a materialized, fully verifiable copy. + +## Transitive dependencies + +Everything so far treats a source as a flat bag of artifacts: phora reaches in, +takes the leaves you selected from its offer, and projects them. A *transitive +dependency* turns that inside out. It is a source that is itself a phora project — +one that ships its own `phora.toml`, with its own sources and its own targets. When +you import it, phora reads that manifest and composes the dependency's targets into +your workspace, so a single import can carry a whole sub-configuration. + +Take [`srnnkls/tropos`](https://github.com/srnnkls/tropos), a toolkit of +agent-harness artifacts — skills, commands, agents, workflows. One of its skills, +`loqui`, hands the agent language-specific coding guidelines, and those guidelines +are not tropos's to maintain: they live in a separate repo, +[`srnnkls/loqui`](https://github.com/srnnkls/loqui), and the skill expects them +vendored underneath it at `skills/loqui/reference/loqui/`. In local development +that spot is a symlink to a loqui checkout; for a real install, tropos declares loqui +as one of its own sources and lets phora compose it into exactly that place. Mark +tropos `transitive = true`, import it, and phora follows that edge: + +```toml +# your phora.toml +[sources.tropos] +host = "github" +repo = "srnnkls/tropos" +branch = "main" +transitive = true + +[targets.claude] +path = "~/.claude" +imports = ["tropos"] +``` + +The dependency carries its own manifest. The slice that matters here is that tropos +declares loqui as a source and lands it under the skill that needs it — a relative +path, deep in tropos's own tree: + +```toml +# inside srnnkls/tropos, its own phora.toml +[sources.loqui] +host = "github" +repo = "srnnkls/loqui" + +[targets.loqui] +path = "skills/loqui/reference/loqui" +sources = ["loqui"] +``` + +A `phora sync` fetches tropos, parses its manifest, resolves its loqui source, and +deploys loqui's artifacts — its `languages/` and `resources/` trees — at +`~/.claude/skills/loqui/reference/loqui/…`, exactly where the skill looks for them. +You imported one repo and its dependency was wired into place for you. A target may +import several at once — `imports = ["tropos", "work-config"]` — each composing under +the same anchor. + +### How a dependency composes + +The importing target's `path` is the *anchor*. Each of the dependency's own targets +carries a relative `path`, and phora joins it under the anchor: tropos's `loqui` +target at `path = "skills/loqui/reference/loqui"`, imported into a target at +`~/.claude`, lands at `~/.claude/skills/loqui/reference/loqui`. The +dependency's own layout governs its artifacts — declare `by-source` there and loqui's +trees nest one level deeper under the source identity — and the anchor's layout is +never re-applied to the mounted subtree. The dependency decides its own shape; you +decide only where the whole thing roots. + +Nothing silently merges, because every fetched dependency is a distinct *instance* +and its sources are namespaced under it. If both you and tropos define a source +named `loqui` pointing at different repos, your `loqui` serves your targets and +tropos's is a separate instance serving its own — no collision, no overwrite. Two +different dependencies that each pull a source called `loqui` stay separate the same +way. A dependency that imports its own dependencies composes recursively, and a +cycle guard collapses a diamond to a single fetch instead of looping. The one thing +that is an error rather than a merge is a genuine destination clash: if two composed +targets resolve to the same path, the sync stops and names it (`composed targets +resolve to the same destination`) rather than letting one clobber the other. + +### Subsetting a mounted dependency + +A binding's `take` slices a single source; the mount-level equivalent slices a whole +composed dependency. A consumer subsets what a mounted dependency contributes with +target-owned `[targets..take]` and `[targets..collapse]` tables, *keyed by the +imported dependency's anchor* — the composed destination the dependency target lands +at. It is the consumer's own slice of the composed subtree, and the dependency cannot +override it: + +```toml +[targets.claude] +path = "~/.claude" +imports = ["tropos"] + +# keep only the gestalt skill out of tropos's skills tree, and rename one leaf: +[targets.claude.take] +"skills" = ["skills/gestalt/**", { "skills/gestalt/SKILL.md" = "skills/gestalt/skill.md" }] + +# force the loqui reference tree to land per-leaf rather than as one dir artifact: +[targets.claude.collapse] +"skills/loqui/reference/loqui" = false +``` + +An omitted table inherits (no subsetting); a present-but-empty `[take]`/`[collapse]` +clears any inherited table back to take-all; a non-empty local table replaces the +base table wholesale on overlay — the same wholesale-replace rule a target's +`sources` list follows. + +### Confinement + +A dependency's `phora.toml` is input you did not write, so phora treats it as +untrusted and boxes it in. A composed dependency can only ever write *inside* its +anchor. phora rejects, at compose or write time, a dependency target path that +escapes the anchor with `..`, is absolute, or carries an unsafe component; a write +whose anchor ancestor is a symlink (so a planted link cannot redirect the write out +of the tree); and any write into a protected path — your `phora.toml`/`phora.lock`, +`.git`, and phora's own cache and state roots. A transitive source may not use +`deploy = "link"` either, since a link would point at an unconfinable mirror path; +your own link sources are unaffected. + +A dependency's inner sources resolve their remotes against *your* host registry, so +the dependency records intent (`host` + `repo`) and your config decides the protocol +and the forge URL. The flip side is an escape guard: an inner source with an +absolute-path or `file://` remote is rejected, so a dependency cannot reach back onto +your local filesystem. + +### Trusting a dependency's hooks + +A dependency's target can carry a hook — an `on_change` shell command its author +wants run after the files land. That command would run on your machine, from a repo +you do not control, so phora does not run it without explicit approval. +On the first sync, a discovered dependency hook is *stripped*: recorded, but not run. +The sync tells you so, and you approve it deliberately: + +```bash +phora trust tropos --list # each hook: its command, its commit-pinned preimage, + # and the dependency surface around it (see below) +phora trust tropos # the same, then prompt [y/N] per hook; a yes is recorded +``` + +What `--list` shows around a hook depends on whether you have trusted it before. A +hook you approved at an earlier commit renders the file-level diff between that +trusted commit and the current candidate commit, so you can read what moved in the +dependency before re-trusting. A hook with no prior trusted commit instead lists +the dependency-repo-relative files the consumer composes from the dependency at the +candidate commit — the actual surface the hook will run against, honoring the +binding's include/exclude. Both are resolved offline from the cache mirror; if the +candidate commit is unresolved or missing from the mirror, the listing degrades to a +`run phora sync first` notice rather than guessing. + +To read the surrounding tree directly, `phora trust tropos --show ` prints a +dependency file at the pinned candidate commit, also offline. A UTF-8 file prints +its contents; a directory lists its direct entries ls-style, with subdirectories +slash-suffixed; an absent path errors naming the path; binary (non-UTF-8) content is +refused rather than dumped; and a commit not yet in the mirror points you at `phora +sync`. `--show` requires a source name, and refuses to guess when one source has +several distinct pinned dependency commits — it names them so you can disambiguate. + +Approval is consumer-owned and lives in your `phora.lock`, as a `[[trusted_hooks]]` +entry pinned to the hook's command and the exact dependency commit it came from; +discovered-but-unapproved hooks sit under `[[candidate_hooks]]`, which carries no +trust. A trusted hook runs on the next sync without a prompt — but the moment the +dependency changes that hook or the files around it, the preimage stops matching and +it drops back to needing approval. There is deliberately no trust on first sight. + +When hooks are stripped, an interactive sync exits non-zero so a human acts on it; a +non-interactive run stays green, because the files are deployed and only the +post-processing was skipped. `phora sync --no-transitive-hooks` skips composed-dep +hooks entirely (your own hooks still run), and `phora trust tropos --revoke` drops +every approval for a dependency. + +One limit worth stating plainly: trust here is behavioral, not a sandbox. An +approved hook runs as you, with your full privileges and phora's full process +environment — phora pins *what* runs and re-prompts when it changes, but it does +not confine *how* it runs. There is no OS sandbox, no environment sanitization, and +no signature or provenance check; the trust pin is whole-commit, so any change to a +dependency's commit +re-prompts every one of its hooks (the file-level diff narrows what you have to read, +not what re-prompts). For a dependency you would not already trust to run code on +your machine, vet it in an outer VM or container before you approve its hooks. + +### Reproducibility + +`phora sync --frozen` refuses to fetch or re-resolve anything: every source — root, +imported dependency, and nested dependency alike — must already be pinned in the +lock. A miss hard-errors, naming the source and, for a nested dependency, its depth, +so a drifted or dropped pin cannot pass silently. It is the offline mode for CI and +reproducible checkouts, where the lock is the only source of truth. As with any other field, a `phora.local.toml` +overlay can flip a source to `transitive = true` for one machine alone. + +## Under the hood + +Everything above is the contract; this is the machinery behind it. None of it is +needed to use phora, but it is what lets you reason about edge cases — and what +phora is actually doing to your disk. + +### One store for everything + +phora keeps its state in two XDG-rooted trees, split by who owns the bytes — a +*cache root* for regenerable git mirrors and a *state root* for the machine-local +records that cannot be regenerated: + +- `/git/.git` — bare git mirrors, one per source, under the cache + root (`XDG_CACHE_HOME`, or by default `~/.cache/phora` on Linux and + `~/Library/Caches/phora` on macOS). The `MirrorKey` is the first 16 hex characters + of `blake3` over a *normalized* form of the remote URL. Normalization strips a + trailing `.git`, rewrites scp-style `git@host:owner/repo` to `host/owner/repo`, + drops the scheme and any userinfo, and lowercases the host. Both forms normalize + to the same string and hash to the same key — which is why https and ssh share a + mirror. +- Per-project state, under the state root (`XDG_STATE_HOME`, or by default + `~/.local/state/phora` on Linux and `~/Library/Application Support/phora` on + macOS), keyed by a `ProjectId` — the first 16 hex characters of `blake3` over the + canonical project root path. This holds the deploy journal and the lock that + serializes phora runs, plus the registry records describing what is deployed. A + record lives at + `…/targets//artifacts//.toml` — keyed by the + *binding identity*, not the source, which is exactly what lets two slices of one + source coexist under one target. The record's own `source` field carries the + underlying source name, so `phora where --source ` can still find every slice + that draws from `s`. + +The split is deliberate: the cache root is disposable — delete it and the next sync +re-clones — while the state root is the authoritative record of what is on disk. An +`XDG_*` override is honored only when *absolute* (per the XDG spec); a relative value +is ignored and the platform default applies. macOS has no native state directory, so +the state root falls back to `~/Library/Application Support`. `XDG_DATA_HOME` and +`XDG_CONFIG_HOME` go unused on purpose: there is no portable data payload (the +registry is machine-local, the mirrors regenerable) and no global config (config is +the project-local `phora.toml`). + +A git source's mirror has ordinary refs (`refs/heads/*`). A URL source's mirror is +synthetic: phora writes the downloaded content into the same bare-repo object store +as a single commit and points `refs/heads/phora` at it. From the object store's +perspective there is no difference between the two — both are just commits with +trees and blobs — which is precisely why the projection and verification code does +not branch on source kind. + +### No working tree: the object store is the substrate + +The single design choice that the rest of the machinery falls out of: phora never +materializes a working tree for a source. There is no `git checkout`, no `git +worktree add`, no index, no second copy of the files on disk. A mirror is *bare* — +nothing but the `.git` object database and its refs — and that is the only form a +source ever takes on disk. Everything downstream reads out of that object store +directly. + +That reframes the three operations you might expect a package manager to perform: + +- Resolving a ref is a pure lookup, not a checkout. A `branch`/`tag`/`rev` *peels* + to a commit object id; nothing is written to a working directory. The 40-hex + commit is the entire answer. +- Reading a source's files is a tree walk, not a filesystem read. phora opens the + commit, descends its tree objects, and pulls blob bytes straight from the object + database — touching only the entries a target actually takes, never the whole + tree. The mirror is still a full bare clone (all of history, fetched once); what a + take avoids is checking that history out — you take one file by reading one blob, + not by laying the whole tree down on disk first. +- Materializing writes those blob bytes into a staging directory and then into your + target. The bytes flow object-store → staging → target without git ever owning a + checked-out copy in between. + +This is what makes a per-binding ref override cheap, and it is worth seeing why. +Two bindings of one source at different refs — the `stable`/`canary` pair — are two +commit ids resolved against one bare mirror. Git's object store is +content-addressed and immutable, so the two commits share every unchanged blob and +tree and differ only in what genuinely changed between the refs. There is no second +clone, no per-ref worktree, no duplicated history — the override adds a *ref to +resolve*, not a copy on disk. A worktree-based design would have to check out each +ref into its own directory and reconcile them; phora just reads two trees out of the +same packed store. The cost of holding ten versions side by side is ten commit ids +and whatever blobs actually differ between them. + +The same property runs through the rest of "Under the hood." A URL import being a +synthetic commit, projection touching only taken paths, the slice digest being a +hash over a tree walk, reflink placement out of staging — all of it assumes the +source lives as objects, not as files. The object store isn't hidden behind the +model — it is the substrate the model rests on. + +### Fetching a git source + +The git backend uses `gix` (gitoxide) directly, no shelling out. On the first +fetch it clones the remote as a bare repository with a mirror refspec +(`+refs/heads/*:refs/heads/*`), so the local mirror's `refs/heads/*` track the +remote's heads exactly. On subsequent fetches it opens the existing mirror and +updates those refs in place. Fetches honor the interrupt flag, so a Ctrl-C during +a network operation unwinds cleanly rather than corrupting the mirror. + +Resolving is then a local lookup: a `branch` peels `refs/heads/` to a +commit, a `tag` peels `refs/tags/`, and a `rev` is parsed as an object id +directly. The result is the 40-hex commit that goes into the lock. + +### Fetching and importing a URL source + +The URL backend runs four steps, and the order matters — digest verification runs +before extraction: + +1. Download. An `ureq` client (rustls TLS, certificate verification on) streams + the response body to a temporary file beside the mirror. It follows redirects + (release assets commonly 302 to a CDN) but strips auth headers across them, and + it sets connect and body timeouts so a stalled server can't wedge the process. + A non-2xx status or a transport failure is a clear error; a partially written + temp file is cleaned up on any failure. +2. Verify. If the source declares a `digest`, phora hashes the downloaded bytes + with the matching algorithm (`sha256` or `blake3`) and compares. A mismatch + stops here — before extraction — naming the source with expected and actual + hex. Nothing has touched your tree yet. +3. Extract. phora sniffs the magic bytes to pick tar, gzip-then-tar, zip, or + raw-single-file, and unpacks into an in-memory list of entries. Three defenses + apply during this step: every entry path is validated segment by segment + (rejecting `..`, absolute paths, drive roots, backslashes, NUL bytes, and + non-UTF-8 names), so a crafted archive path cannot escape the extraction root; a single common + top-level directory is stripped; and a cumulative size cap (1 GiB of actual + decompressed bytes, not the attacker-controlled header) guards against + decompression bombs. Executable bits and symlinks carry through to the right + git entry kind. +4. Import. The entries become git objects in the mirror, and `refs/heads/phora` + moves to the new commit (see the next section for why this is deterministic). + +Resolving a URL source afterward is trivial: read `refs/heads/phora`. The backend +ignores any refspec it is handed, which matters because the rest of the system +defaults a source's refspec to `branch = "main"` — a default that would otherwise +send a URL source looking for a branch that does not exist. + +### Why a URL import is deterministic + +The goal is that identical bytes produce an identical commit id, every time, on +any machine — so that re-downloading unchanged content does not churn the lock, +and so that two people importing the same tarball get the same result. + +A git commit id is a hash of its content plus its metadata, so determinism means +nailing down everything that is not content: + +- Fixed author and committer identity, a constant commit message, and no parents. +- A fixed timestamp of one second past the epoch. Not zero — some filesystems + (FAT32, HFS+) clamp a zero mtime, which would make `phora verify` report every + URL-sourced file as modified forever after. One second sidesteps that while + staying constant. +- Tree entries written in true git tree order. Git sorts entries by name with a + subtlety — a directory sorts as if its name had a trailing slash — and the + object writer assumes that order. phora sorts every level explicitly before + writing, so the input order of the extracted entries cannot leak into the commit + id. + +With all of that fixed, the commit id is a pure function of the file contents and +their paths. Re-importing the same bytes recomputes the same id and force-updates +the ref to the same place (a no-op in effect); importing changed bytes yields a +new id and the lock advances. You can sanity-check the result yourself: the +synthetic commits pass `git fsck --strict`. + +### Projection and the content digest + +Projection does not write straight into your target. It first materializes the +selected files into a staging directory, computing a single `blake3` digest over +the artifact as it goes — framing each entry (its relative path length and bytes, a +type tag for file/executable/symlink, and its content length and bytes) into the +hash. The framing matters: without length-prefixing, two different tree shapes +could hash the same if a path's bytes ran into the next entry's content with no +boundary between them. With it, the digest is a faithful fingerprint of the +projected tree. + +Moving the staged artifact into the target is an atomic directory swap: phora +journals its intent, renames staging into place, then records the result, so a +crash mid-swap is replayable rather than leaving a half-written target (the same +write-ahead journal the [execution model](#execution-model-parallel-reads-one-serial-writer) +relies on). Within a swap each file is placed with a reflink — a copy-on-write +clone, so on filesystems that support it (APFS, Btrfs, XFS) the bytes are shared +with the staged copy and the placement is near-free — falling back to a plain byte +copy elsewhere or across devices. Either way the commit-time mtime is set +explicitly, since a reflink does not carry mtime; that is what makes a +re-projection of unchanged content produce byte-identical metadata. + +An `ExportPolicy` governs the edges: symlinks and submodules are refused unless +explicitly allowed (`allow_symlinks`, `allow_submodules`, both off by default), and +the executable bit is preserved by default (`preserve_executable`). Drift detection +is symlink-aware in the other direction too — it stats without following links, so +a recorded regular file later swapped for a symlink reads as modified rather than as +its target's contents. + +### The lock and content identity + +The lock is keyed per *(source, resolved commit)* — one entry for each distinct ref +a source's bindings resolve to. The common case, where no binding overrides the ref, +collapses to one entry per source, exactly as before: the lock is *take-neutral* +toward bindings, so however many targets bind a source and however each subsets it +with `take`, they all share the single entry at the source's own ref. +A ref-overriding binding is the one thing that splits it — each distinct +`branch`/`tag`/`rev` resolves to its own commit and records its own entry. Each entry +carries an optional ref discriminator that is *present only on an override* and absent +on the default, so a config that names no binding refs serializes byte-for-byte +identical to one with no binding ref entries at all. + +Each locked entry records its name, the remote (or URL), a resolved field, the commit, +the artifact digest, and a config digest. That last one — a `blake3` over the +export-affecting settings — is how phora notices that you changed *what* ships even +when the upstream commit did not move; it is computed from the *source's* offer — +its `include`/`exclude`/`root` (a URL source's digest covers the full archive) — +never from a binding's `take`, and is therefore shared across every split of a +source. The artifact digest, by contrast, is recomputed per entry at that entry's own +commit. + +The split that follows is worth holding onto. The lock answers "which commit?", and a +binding's *take* has nothing to do with that — so a change that narrows one target's +`take` moves no commit and leaves `phora.lock` untouched; it shows up only as a +different per-artifact digest in the registry, and that is what tells sync to replace +the deployed slice. A *ref* override is the exception: it is the one binding-level +change that does answer "which commit?", so it adds (or moves) an entry. The offer +that lives on the source still flows into the config digest and can advance the +lock's reuse decision; the take that lives on a binding re-projects through the +registry alone. + +Deciding whether a locked entry can be reused is where the two source kinds differ, +and it is worth being precise: + +- A git or host source matches when its resolved remote identity (normalized, so + https/ssh/literal forms agree), the binding's *effective* refspec, and its config + digest all match the lock — the effective ref being the binding's override, or the + source's ref when the binding does not override. +- A URL source has no meaningful remote-vs-refspec story, so it matches on its URL + identity plus its config digest. Its lock carries the literal URL and a `"url"` + sentinel in the resolved field. The synthetic commit is content-addressed, so the + URL plus the config digest is a complete identity. + +The consequence is the no-op model you feel at the command line: a plain `sync` +finds the lock matches and does not re-download; only `update` (or `--force`) +re-fetches, and even then identical bytes reproduce the same commit and the lock +does not move. + +### Templating and the two digests + +[Templating](#templating-per-machine-values) renders `*.tmpl` files at stage time — +in the staging directory, before the atomic swap — so the artifact materialized into +the target is already the rendered output. Two digests then part ways, and the split +is what matters: + +- The *manifest* hashes the *rendered* bytes. That is what `phora verify` and drift + detection compare against, so they check the file you actually deployed. +- The *lock's* artifact digest is computed over *source* bytes, independent of any + vars. So two machines with different vars resolve to the same commit and write + byte-identical locks — the reproducibility check never depends on a machine's local + values. + +To know *when* to re-render, phora records a per-artifact digest of the effective +(base overlaid by local) vars. Change a var and that digest changes, so the next +sync re-stages and redeploys the artifact even though no commit moved; leave the vars +alone and the artifact is a no-op. `phora rebuild-registry` reconciles against the +same merged vars, so a templated artifact comes back clean rather than reading as +modified. Rendering is strict and per-artifact: an undefined variable aborts just +that artifact's export, leaving its siblings deployed. + +### Versioning, the git way + +phora has no version solver, no semver ranges, and no package index. A *version* +is just a git commit, and the machinery for handling versions is the machinery you +already have for handling commits. + +- For a git source the version is whatever `branch`/`tag`/`rev` resolves to in the + mirror; for a URL source it is the content-addressed synthetic commit of the + downloaded bytes. Either way the lock records one 40-hex commit, and that pin is + the version — reproducible across machines, advanced only by `update`. +- Moving a version is editing a ref or a URL: bump `tag = "v0.56.0"` (or point a + URL at the new tarball) and re-lock. There is nothing to resolve against a + registry — the ref is the request and the commit is the answer. +- Keeping several versions costs almost nothing. The mirror is git's object store: + immutable, content-addressed, deduplicated. `v0.55` and `v0.56` are two commits + that share every unchanged blob and tree; importing the second writes only what + actually differs and overwrites nothing. + +Which version a deployment uses is a function of the *(project, binding)* pair, not +of the source's name. Across locations: each is its own `ProjectId` with its own +`phora.lock` and registry partition, so the same source name can carry different +versions in different checkouts with no special handling — `fzf` pinned to `v0.55` +here and `v0.56` there are just two locks selecting two commits out of the one shared +mirror. Within a *single* location: a binding's `branch`/`tag`/`rev` override pins its +target independently, so two bindings of one source — distinct table key, distinct +ref — hold both versions side by side in the same project (the `stable`/`canary` pair +above), each its own lock entry over the one mirror. You never spell the version into +the source name — the lock does that, per binding. + +This is where phora diverges most from a conventional package manager. There is no +central index to publish to, no resolver to satisfy a version range, and no +per-project install tree to deduplicate after the fact. Git's content-addressed +object store is the cache, its commit graph is the version history, and an install +is a reflink out of that store. Everything phora adds on top — the lock, the +registry, identity — is bookkeeping about which commit goes where; the storage and +the versioning come from git's object store rather than a custom layer. + +### Verification + +The registry stores, per deployed artifact — keyed by binding identity within its +target — the underlying source, the commit, the artifact digest, the layout and +policy in effect, and a manifest of every file with its size, mtime, and `blake3` +hash. `phora verify` re-hashes the files on disk and compares them against that +manifest, so it catches both content edits and missing files. `phora list` reads +the same records to show per-target status, and `phora where` queries them in +reverse — given a source, artifact, commit, or digest, it tells you where things +came from. Because every record carries its underlying source, a `phora where +--source ` finds all of `s`'s slices even when they were bound under different +identities (table keys). + +`phora verify` always re-hashes; the *drift gate* that `phora sync` and `phora list` +use to decide whether an artifact needs redeploying takes a cheaper path. It first +compares each file's on-disk size and mtime against the manifest, and a match is +clean without hashing — the hot path, since most files are untouched. Only a file +whose size or mtime diverges is then hashed and compared against the recorded +`blake3`: a hash match means the bytes are identical and the stat merely moved (a +`touch`, a restore-from-backup), so the file is *revalidated* — treated as clean +rather than flagged, and `sync` persists the fresh size and mtime back to the +manifest so the file drops to the stat fast path next time instead of re-hashing. A +hash mismatch is a real edit and stays modified; any read or re-stat error, or a +non-regular file, fails closed to modified rather than guessing clean. The escalation +is race-aware — the file is opened once and read and re-stat'd through that one +descriptor — and partial drift collapses the whole artifact to modified with no +refresh. The upshot at the command line: a bare timestamp change on byte-identical +content no longer trips the conflict prompt. + +If the registry and the on-disk reality fall out of step — say you hand-edited the +state root, or restored an old backup — `phora rebuild-registry` reconstructs the +records from the lock plus what is actually deployed. + +### Execution model: parallel reads, one serial writer + +The read-and-resolve half of a sync runs in parallel; the half that *writes* runs +serially behind a lock. phora fetches, resolves, and digests its sources across a +rayon thread pool — sized to one thread per resolution unit, capped at twice the +core count (from `available_parallelism`, fallback 8) and overridable with `phora +sync --jobs N` / `-j N` — then deploys the results one at a time. The parallelism is +purely a throughput win — the deploy loop and the recorded state are identical +however many threads ran, so nothing about a lock or a registry record depends on +the thread count. + +Fetch is network-bound — resolving a source is mostly I/O wait — so overlapping +those waits across the pool is the available throughput win. The concurrency phora +takes seriously is mutual exclusion and crash safety: + +- One writer per project. A state-mutating command (`sync`, `eject`, `uneject`, + `rebuild-registry`) takes an exclusive OS lock on a `state.lock` file in the + project's state root, held for the whole run. A second phora invoked against the + same project while the first runs fails fast with "another phora process is + running" and exits `75` (`EX_TEMPFAIL`), rather than racing it into a corrupt + registry. +- One writer per mirror. The git mirror cache lives under the *cache* root, which + is shared across every project — so the per-project state lock alone would not + stop two phora runs in *different* projects from fetching the same bare mirror at + once. Each fetch therefore takes a blocking advisory flock on + `.git.lock` before touching the mirror. Parallel fetches to *distinct* + mirrors run concurrently; two fetches of the *same* mirror serialize on this lock. + Git-mode fetch is idempotent and dedupes to one fetch per mirror; a url-mode source + still fetches per source even when its key coincides, because each validates its own + integrity digest and deduping would silently bypass the second pin. Mirror creation + is atomic — built in a temp directory and renamed into place — so a crashed clone + never leaves a half-written mirror for the next run to trip over. +- Crash safety via a journal. Before mutating a target, phora records the intended + operation in a deploy journal. If a run is interrupted — a crash, a Ctrl-C, a + killed terminal — the next run's recovery sweep reads the journal and cleans up + the partial state before doing anything else. This is why an interrupted sync + leaves the state recoverable rather than half-deployed. +- Interrupt-aware fetches. The git network operations check an interrupt flag, so + cancelling mid-fetch unwinds without leaving a broken mirror. + +Deploy stays sequential on purpose: it is the write side, governed by the one-writer +lock and the journal, where ordering and crash safety matter more than overlap. The +parallelism lives where the waiting is. + +### Hook dispatch and recording + +[Hooks](#hooks-running-commands-after-a-sync) run after the deploy commits, never +during it — the files are on disk and the journal settled before a hook sees them, +which is why an `on_change` hook can read what it just received. Each hook has a +stable id derived from its scope, shell, and command (the shell is part of the id, +so the same command under two shells is two hooks, and the same command listed twice +under one shell is deduplicated to a single run). + +"Recorded on success" has a concrete shape: when a hook succeeds, phora stores, +per hook, the set of artifact content digests that were live at that success. The +next sync recomputes the current digest-set for the hook's scope and skips the +`on_change` when it matches — that is what makes a no-op sync quiet. A non-zero exit +records nothing, so the set never matches and the hook re-fires until it succeeds. +The global `post_sync` carries `when = "always"`, which bypasses the digest-set +check and runs every time. + +The trust boundary is structural, not a scan: hooks are only ever read from the +consumer's own `phora.toml`/`phora.local.toml`. A synced source tree is projected as +content and never parsed as configuration, so a `phora.toml` that rides along inside +a source can declare any hook it likes and none of it will ever run. + +### Composing a dependency graph + +A transitive dependency's `phora.toml` is parsed exactly once, into a manifest DTO +that keeps only its declarative `[sources]` and `[targets]`. Its trust-control and +global `[hooks]` fields are dropped on the way in — hooks are retained out of band as +an uninterpreted value for the admission gate, never merged into your config — so no +trust state can ride in from the dependency itself. + +Two keys hold the graph together. A *fetch node* is the triple of a normalized +remote URL, its ref, and its resolved commit; it is the dedup key, so a diamond that +reaches the same triple collapses to one fetch and equivalent URL spellings share a +node. An *instance* is `(parent, source name, anchor target, fetch node)` — the same +fetched node mounted at two anchors is two instances. The instance's `stable_key` is +a length-prefixed blake3 over those fields, truncated to 16 hex; that key is what +namespaces a dependency's sources and hooks so two dependencies with a same-named +inner source never collapse into one node. Confinement is enforced by resolving the +anchor and checking every composed destination against it segment by segment, +rejecting `..`, absolute paths, unsafe components, and symlinked ancestors, plus a +fixed set of protected paths (your config, lock, `.git`, and phora's cache/state +roots). + +Hook trust is a content gate, not a name check. Each composed hook is reduced to a +*preimage* — a commit-bound blake3 over its command — and the gate admits it only +when that preimage matches a `[[trusted_hooks]]` entry in your lock. Every discovered +hook is also recorded as a `[[candidate_hooks]]` entry carrying its command and +resolved commit, which is what `phora trust --list` reads and what the inspect-before-trust +diff (last trusted commit → candidate commit) is computed from. Because trust is keyed +on the preimage, any change to the command — or to the dependency commit it rode in +on — invalidates the match, and the hook reverts to a stripped candidate until you +re-approve. The lock keeps `trusted_hooks` and `candidate_hooks` skip-serialized when +empty, so a config with no transitive hooks serializes byte-for-byte identical to one +with no transitive hooks at all. Under `--frozen`, the resolver consults the lock and refuses to +fetch, erroring on the first source — at any depth — that is not already pinned. + +### Integrity boundaries + +It is worth naming the one place the content guarantee deliberately stops: linked +artifacts (`deploy = "link"`). Because a symlink points at a live working tree +whose bytes change underfoot, hashing it would be meaningless, so phora doesn't. +Linked records carry a `linked` marker and no manifest; verify and drift detection +skip them; `--prune` removes an orphaned link by deleting the symlink only; +`rebuild-registry` reconstructs the marker without hashing. Everything else — +every `copy` artifact from a git or URL source — is inside the integrity model. + +## When something looks wrong + +Most confusion maps to one question, and each question maps to one command. + +- "What would a sync actually deploy, before I run it?" Run `phora preview`. It + prints the whole projection tree — per target, each binding's identity, the + artifacts it selects, and where each would land under the layout — without writing + anything or touching the network. A collapsed directory shows a trailing slash + (`zellij/`), a rename shows `src -> dest -> destination`, and a `take` glob that + matched nothing surfaces as a warning with a nearest-leaf suggestion. Commits come + from the lock and trees from the mirror, so an unsynced source is *annotated* (`not + locked`, `needs sync`, `link working tree gone`) rather than fetched, and the + command still exits 0; predicted flat-layout collisions show up as warnings. + `--files` expands each artifact to its files, `--json` emits the plan as a document + (carrying `rename`, `collapsed`, and per-target `warnings` fields), and + `--target`/`--source` narrow the view. Where `check-match` answers one path, + `preview` shows the whole tree. +- "Did my `include`/`exclude` actually match this path?" Run `phora check-match + --source `. It answers yes/no for that exact path against that + source's offer rules, which beats re-reading globs. +- "Which include offered this path, and how did the binding's `take` resolve it?" Run + `phora explain [path]`. Offline, from the lock, it attributes a + single path to the include that offered it and shows the take outcome (kept, + renamed, collapsed, or dropped); with no path it lists every offered leaf and its + fate. A path the offer does not expose reports `outside the offer` with a + nearest-leaf `did you mean:`. The structured selection diagnostics — a rejected + binding scope, a take that would widen the offer, a rename escaping the deploy root + — all point here. +- "Is what's on disk really what phora put there?" Run `phora verify`. It re-hashes + every tracked file and exits non-zero on the first mismatch. A file you edited by + hand will show up here; so will a truncated or replaced one. +- "Where did this file come from?" Run `phora where --source ` (or query by + artifact, commit, or digest). It reverse-looks-up the registry. When nothing + matches it says so — naming the active filter and pointing you at `phora + sync`/`phora preview` — rather than printing blank; `phora list` likewise marks an + undeployed target `(nothing deployed — run \`phora sync\`)` instead of a bare + header. +- "phora won't run — it says another process is running." That is the `state.lock` + doing its job. Either another phora really is running for this project, or a + previous one died without releasing the lock; once you're sure none is running, + the stale lock clears on the next clean run. +- "Every URL-sourced file shows as modified." Your filesystem is mangling + timestamps — some clamp or round old mtimes. phora's epoch+1 import time dodges + the common case; if you still hit it, that's a bug worth filing. +- "My registry looks wrong after I touched the state root." Run `phora + rebuild-registry` to reconstruct it from the lock plus the deployed files. +- "Sync keeps stopping on a file it didn't deploy." That is the conflict prompt + protecting a foreign or hand-modified file. Decide per file (skip / overwrite / + eject / abort), or pass `--force` to overwrite, or `eject` to keep the file and + stop managing that artifact. + +## Where to look next + +- The [README](README.md) is the flag-level reference and the fastest path to a + specific option. +- [`phora.example.toml`](phora.example.toml) is a complete, annotated config you + can crib from. +- The [scrut suites](tests/scrut/) drive the shipped binary end to end against real + upstreams and double as runnable, CI-verified usage docs — this guide with + assertions instead of prose. [`showcase.md`](tests/scrut/showcase.md) pins two of + Anthropic's public Claude Code skills and walks the link-mode editing loop; + [`release-assets.md`](tests/scrut/release-assets.md), + [`versions.md`](tests/scrut/versions.md), and [`drift.md`](tests/scrut/drift.md) + cover digest-checked tarballs, one source at two tags, and what happens when a + deployed file is edited behind phora's back; + [`mapped.md`](tests/scrut/mapped.md) fans one `AGENTS.md` out to the names every + agent tool wants with `take` renames, [`hooks.md`](tests/scrut/hooks.md) runs + commands after a sync, [`templates.md`](tests/scrut/templates.md) fills one config + in per machine, and [`transitive.md`](tests/scrut/transitive.md) imports a + dependency that carries its own — composing it under the anchor, isolating storage + with `[paths]`, stripping its untrusted hook, and inspecting that hook's surface + offline with `trust --list`/`--show` before approval. +- The source is organized along the pipeline: `config.rs` (parsing and modes), + `config/source.rs` and `config/target.rs` (the offer and the take/collapse DTOs), + `config/transitive.rs` (the dependency manifest DTO and graph keys), + `kernel/selection.rs` (the offer compiler), `kernel/take.rs` (take resolution), + `kernel/collapse.rs` (directory collapse), `diagnostic.rs` (structured selection + diagnostics and `did_you_mean`), `source.rs` (the git backend, the + synthetic-commit import, projection), `http.rs` (download and digest), `archive.rs` + (extraction and the path guard), `backend.rs` (the source-mode router), `sync.rs` + (the orchestration), `sync/transitive.rs` and `sync/confine.rs` (composition and + destination confinement), `lock.rs` and `registry.rs` (identity and records). diff --git a/README.md b/README.md index bad861c..e840621 100644 --- a/README.md +++ b/README.md @@ -109,18 +109,18 @@ next sync. phora add owner/repo --name myconfigs --branch main --root configs # -> host = "github" phora add github:srnnkls/tropos # colon alias -> host = "github" phora add gitlab:group/repo # any built-in forge (alias caps at owner/repo) -phora add github.com/me/dotfiles # domain shorthand -> host = "github" -phora add https://github.com/me/dotfiles.git # scheme/scp URLs stay literal (git = "…") -phora add git@github.com:me/dotfiles.git --tag v1.2 +phora add github.com/srnnkls/dotfiles # domain shorthand -> host = "github" +phora add https://github.com/srnnkls/dotfiles.git # scheme/scp URLs stay literal (git = "…") +phora add git@github.com:srnnkls/dotfiles.git --tag v1.2 # Deep GitLab subgroups go in the config `repo` field (repo = "group/sub/proj"), # not the colon alias (segments past owner/repo become `root`). # Bind sources to a target; --take subsets/renames the offer for that target -phora bind dotfiles --to neovim # bare binding, takes the whole offer -phora bind dotfiles --to neovim --as nvim --take nvim/** # take just nvim/** under identity `nvim` -phora unbind nvim --from neovim # remove a binding by identity +phora bind dotfiles --to config # bare binding, takes the whole offer +phora bind dotfiles --to config --as zellij --take zellij/** # take just zellij/** under identity `zellij` +phora unbind zellij --from config # remove a binding by identity # --root/--include/--exclude on `add` shape the SOURCE offer (source-owned), not a binding. -phora add me/dotfiles --to neovim --as nvim --root nvim +phora add srnnkls/dotfiles --to config --as zellij --root .config # Fetch sources, resolve commits, project artifacts into targets phora sync @@ -130,10 +130,11 @@ phora sync --frozen # refuse to fetch or re-resolve — every source mus phora sync --no-transitive-hooks # deploy composed deps, but run none of their hooks # Transitive (composed-dependency) hooks — inspect and approve before they run -phora trust # list every discovered composed-dep hook across all sources -phora trust tropos # inspect tropos's hooks (with file diffs), approve interactively -phora trust tropos --list # show tropos's hooks without approving anything -phora trust tropos --revoke # drop every approval recorded for tropos +phora trust # list every discovered composed-dep hook across all sources +phora trust tropos # inspect tropos's hooks, approve interactively +phora trust tropos --list # show tropos's hooks without approving anything +phora trust tropos --show # print a tropos dep file (or list a dir) at the pinned commit +phora trust tropos --revoke # drop every approval recorded for tropos # Re-resolve to the latest commit, then sync phora update # all sources @@ -172,14 +173,14 @@ phora source rm myconfigs # also scrubs it from every target's `sources phora rm myconfigs # alias for `source rm` # Targets (--path required; --layout takes flat | by-source | prefixed) -phora target add neovim --path ~/.config/nvim --layout by-source +phora target add config --path ~/.config --layout by-source phora target list # name, path, source-resolution mode -phora target show neovim # effective config + resolved sources + state -phora target rm neovim # warns if the registry still has deployed artifacts +phora target show config # effective config + resolved sources + state +phora target rm config # warns if the registry still has deployed artifacts # Bindings — edit a target's `sources` list -phora bind dotfiles loqui --to neovim # add sources to neovim's list -phora unbind loqui --from neovim # remove; emptying it deploys nothing +phora bind dotfiles loqui --to config # add sources to config's list +phora unbind loqui --from config # remove; emptying it deploys nothing ``` `--to`/`--from` name the target an edge attaches to. `phora add --to T1 --to T2` @@ -213,8 +214,8 @@ is the whole-tree view. ``` home - dotfiles@a1b2c3d4 editor -> /home/me/deploy/editor - dotfiles@a1b2c3d4 lint -> /home/me/deploy/lint + dotfiles@a1b2c3d4 zellij -> /home/srnnkls/.config/zellij + dotfiles@a1b2c3d4 ripgrep -> /home/srnnkls/.config/ripgrep ``` `--files` expands each artifact to the files it would deploy; `--json` emits the @@ -240,14 +241,14 @@ trigger it — that's what the global `post_sync` escape hatch is for); the glob `phora.toml` / `phora.local.toml`. ```toml -[targets.neovim.hooks] +[targets.config.hooks] # a bare string runs under `sh -c` -on_change = "nvim --headless +'Lazy! sync' +qa" +on_change = "mise install" -[targets.editors.hooks] +[targets.configs.hooks] # a table picks the shell; an array runs several in declared order (deduped) on_change = [ - { run = "stylua .", shell = "bash -c" }, + { run = "mise trust", shell = "bash -c" }, "git -C ~/.config add -A", ] @@ -273,7 +274,7 @@ the next sync. `phora sync --no-hooks` deploys without running any hook. Each hook that ran is reported with its scope and status: ``` -hook neovim#nvim --headless +'Lazy! sync' +qa#sh -c [on_change] `nvim --headless +'Lazy! sync' +qa` ok +hook config#mise install#sh -c [on_change] `mise install` ok sync complete ``` @@ -285,27 +286,26 @@ never read as config and never executes. Files can be rendered per-machine with [minijinja](https://docs.rs/minijinja) before they deploy. A source file named `*.tmpl` is rendered and lands with the -suffix stripped (`motd.tmpl` → `motd`); every other file copies byte-for-byte. +suffix stripped (`config.toml.tmpl` → `config.toml`); every other file copies byte-for-byte. Variables come from a flat `[vars]` table: ```toml [vars] -greeting = "hello" -editor = "nvim" +gobin = "~/go/bin" ``` ```jinja -{# editor/motd.tmpl → deploys as editor/motd #} -{{ greeting }} from {{ editor }} +{# mise/config.toml.tmpl → deploys as mise/config.toml #} +GOBIN = "{{ gobin }}" ``` The `.tmpl` suffix is the opt-in by default; a refined binding can widen it to arbitrary globs or turn it off: ```toml -[targets.editor.sources] +[targets.config.sources] # render these paths too, in addition to *.tmpl: -wide = { source = "dotfiles", template = ["*.conf", "config/*"] } +wide = { source = "dotfiles", template = ["*.kdl", "*.toml"] } # render nothing, even .tmpl files: plain = { source = "dotfiles", template = false } ``` @@ -317,7 +317,7 @@ per key — keys it omits keep their base value — so each machine fills in its ```toml # phora.local.toml — overlays phora.toml, never committed [vars] -greeting = "hi from this laptop" +gobin = "~/.local/go/bin" ``` Integrity. Phora hashes the *rendered* bytes, so `phora verify` checks the @@ -329,9 +329,9 @@ needed. `phora preview --files` shows the deployed name and flags what renders: ``` home - dotfiles@a1b2c3d4 editor -> /home/me/.config/editor - motd (templated) - static.txt + dotfiles@a1b2c3d4 mise -> /home/srnnkls/.config/mise + config.toml (templated) + tasks/init ``` ## Configuration @@ -352,23 +352,23 @@ auth = { type = "token", env = "GITHUB_TOKEN" } # remote is built in; just add [sources.dotfiles] host = "github" # forge remote: host + repo (or use git = "…" for a literal URL) -repo = "me/dotfiles" +repo = "srnnkls/dotfiles" branch = "main" # or tag = "...", or rev = ""; omit all to follow the repo's default branch -root = "modules" # re-anchor the offer at this subdirectory -include = ["editor"] # source-owned offer: include − exclude (gitignore) -exclude = ["**/*.bak"] +root = ".config" # re-anchor the offer at this subdirectory +include = ["zellij", "ripgrep"] # source-owned offer: include − exclude (gitignore) +exclude = ["**/*.local.toml"] -[targets.neovim] -path = "~/.config/nvim" +[targets.config] +path = "~/.config" sources = ["dotfiles"] # all-bare: a flat list of the sources this target deploys layout = "flat" # "flat" | "by-source" | { type = "prefixed", separator = "-" } # a second target using the keyed-table form (key = identity, defaults to source name): -[targets.editor] -path = "~/.config/editor" +[targets.codex] +path = "~/.codex" -[targets.editor.sources] -nvim = { source = "dotfiles", take = ["nvim/**"] } +[targets.codex.sources] +codex = { source = "dotfiles", take = [{ "codex/config.toml" = "config.toml" }] } ``` Target sources are an explicit allow-list: `["a", "b"]` deploys those two, @@ -421,9 +421,9 @@ A target's `sources` takes one of two forms — never both at once: Take subsets and renames the offer. A binding's `take` is a list whose entries are: -- a literal leaf (a plain offered path, e.g. `"nvim/init.lua"`) — kept verbatim; +- a literal leaf (a plain offered path, e.g. `"zellij/config.kdl"`) — kept verbatim; - a gitignore glob (any entry with `*`, `?`, `[`, `]`, or a trailing `/`, e.g. - `"nvim/**"`) — expands over the offer set only, never widening it; + `"zellij/**"`) — expands over the offer set only, never widening it; - a rename table `{ "src" = "dest" }` — the offered leaf `src` is consumed and emitted at `dest` instead (destructive: it does not also land at `src`). @@ -433,9 +433,9 @@ matches nothing warns but does not fail. An omitted `take` takes the whole offer `take = []` takes nothing. ```toml -[targets.neovim.sources] -# take just the editor's tree, then rename one leaf as it lands: -nvim = { source = "dotfiles", take = ["nvim/**", { "nvim/init.lua" = "init.lua" }] } +[targets.config.sources] +# take just the mise tree, then rename one leaf as it lands: +mise = { source = "dotfiles", take = ["mise/**", { "mise/config.toml" = "mise.toml" }] } ``` Restriction. `take` (or any other refinement) on a binding backed by a `url` source @@ -455,19 +455,20 @@ Identity (the table key). A binding's identity is the `source` only when the identity diverges. The identity keys the registry artifact and the `by-source` and `prefixed` layout labels, and is structurally unique because TOML keys are unique. To feed one source into one target as two slices, give each a -distinct key, each `take`-ing a different subtree of the same `source`. A genuine +distinct key, each `take`-ing a different slice of the same `source`. A genuine destination clash between bindings is caught at sync as a collision. Bindings resolve in identity (key) order, sorted alphabetically — independent of how a flat `sources` -list is written. The slices below take distinct keys for legible `by-source` labels -(`nvim/…`, `helix/…`): +list is written. The slices below rename two configs to a bare `config.toml` that +would collide under `flat`, so distinct keys give each a legible `by-source` label +(`codex/…`, `anax/…`): ```toml -[targets.editors] -path = "~/.config" -layout = "by-source" # labels each slice by its identity: nvim/… and helix/… -[targets.editors.sources] -nvim = { source = "dotfiles", take = ["nvim/**"] } -helix = { source = "dotfiles", take = ["helix/**"] } +[targets.configs] +path = "~/configs" +layout = "by-source" # labels each slice by its identity: codex/… and anax/… +[targets.configs.sources] +codex = { source = "dotfiles", take = [{ "codex/config.toml" = "config.toml" }] } +anax = { source = "dotfiles", take = [{ "anax/config.toml" = "config.toml" }] } ``` Per-target version (`branch`/`tag`/`rev`). A binding may also set its own ref — @@ -528,12 +529,12 @@ expect: [targets.agents] path = "~/myproject" [targets.agents.sources] -dotfiles = { take = [{ "AGENTS.md" = "AGENTS.md" }] } -claude = { source = "dotfiles", take = [{ "AGENTS.md" = "CLAUDE.md" }] } -codex = { source = "dotfiles", take = [{ "AGENTS.md" = "codex.md" }] } +dotfiles = { take = [{ ".shared/AGENTS.md" = "AGENTS.md" }] } +claude = { source = "dotfiles", take = [{ ".shared/AGENTS.md" = "CLAUDE.md" }] } +codex = { source = "dotfiles", take = [{ ".shared/AGENTS.md" = "codex.md" }] } ``` -One `AGENTS.md` in the source now lands three times, under three names, with no +One `.shared/AGENTS.md` in the source now lands three times, under three names, with no copies in the source tree. A rename entry is `{ "" = "" }`: the key is a path the source offers, the value the path it deploys as under the target's layout. @@ -586,9 +587,9 @@ binding-scope rejection alongside `template` and `take`. request the directory symlink/subtree, and fail loudly when it cannot be honored. ```toml -[targets.editors.sources] +[targets.config.sources] # force a per-leaf snapshot even though the whole tree is taken: -nvim = { source = "dotfiles", take = ["nvim/**"], collapse = false } +zellij = { source = "dotfiles", take = ["zellij/**"], collapse = false } ``` ### Source kinds @@ -615,8 +616,8 @@ host = "company" # defined in [hosts.company] repo = "team/sub/proj" # nested paths are fine protocol = "ssh" # per-source override (default is https) -[sources.scratch] -path = "~/dev/scratch" # local checkout, used verbatim as the remote +[sources.dignity] +path = "~/projects/dignity" # local checkout, used verbatim as the remote branch = "main" ``` @@ -735,7 +736,7 @@ skips that artifact, and continues the rest of the sync. # phora.local.toml — overlays phora.toml, never committed. # Override the `loqui` source onto a local checkout and live-link it. [sources.loqui] -path = "/home/me/dev/loqui" # local source; the live working tree +path = "~/projects/loqui" # local source; the live working tree deploy = "link" ``` @@ -864,10 +865,28 @@ not run — and the sync tells you so. You approve them deliberately: ```bash phora trust tropos --list # show each hook: its command, its commit-pinned preimage, - # and which dep files changed since you last trusted it + # and the dep surface around it (see below) phora trust tropos # same, then prompt [y/N] per hook; a yes is recorded ``` +What `--list` shows around each hook depends on whether you have trusted it before. +A hook you have approved at an earlier commit renders the file-level diff between +that trusted commit and the current candidate commit, so you can see what moved in +the dep before re-trusting. A hook with no prior trusted commit instead lists the +dependency-repo-relative files the consumer composes from the dep at the candidate +commit — the actual surface the hook will run against, honoring the binding's +include/exclude. Both are resolved offline from the cache mirror; if the candidate +commit is unresolved or absent from the mirror, the listing degrades to a +`run phora sync first` notice rather than guessing. + +To read the surrounding tree directly, `phora trust tropos --show ` prints a +dep file at the pinned candidate commit, also offline. A UTF-8 file prints its +contents; a directory lists its direct entries ls-style, with subdirectories +slash-suffixed; an absent path errors naming the path; binary (non-UTF-8) content is +refused rather than dumped; and a commit not yet in the mirror points you at +`phora sync`. `--show` requires a source and refuses to guess when one source has +several distinct pinned dep commits — it names them so you can disambiguate. + Approval is consumer-owned and lives in your `phora.lock` (a `[[trusted_hooks]]` entry pinned to the hook's command and the exact dep commit it came from); discovered-but-unapproved hooks sit under `[[candidate_hooks]]`, which carries no @@ -891,6 +910,17 @@ CI and reproducible checkouts. A `phora.local.toml` overlay can flip a source to `transitive = true` for a single machine, exactly like any other per-machine override. +### Residual risk + +Trust here is behavioral, not a sandbox. An approved hook runs as you, with your +full privileges and phora's full process environment — phora pins *what* runs and +re-prompts when it changes, but it does not confine *how* it runs. v1 ships no OS +sandbox, no environment sanitization, and no signature or provenance check. The +trust pin is whole-commit, so any change to a dep's commit re-prompts every one of +its hooks; the file-level diff narrows what you have to read, not what re-prompts. +For a dependency you would not already trust to run code on your machine, vet it in +an outer VM or container before you approve its hooks. + ## Worktrees A worktree is just a directory you run `phora sync` from; sync builds the managed @@ -916,6 +946,19 @@ mise run test-integration # scrut suites under tests/scrut/ against a release ``` The scrut suites drive the shipped binary end to end and double as runnable usage -docs. [`tests/scrut/showcase.md`](tests/scrut/showcase.md) is a narrated -walkthrough — adding a git source, projecting it, then layering a machine-local -symlink overlay — whose assertions CI keeps honest. +docs verified by CI. The hermetic suites +([`selection.md`](tests/scrut/selection.md), [`query.md`](tests/scrut/query.md), +[`manage.md`](tests/scrut/manage.md), [`lifecycle.md`](tests/scrut/lifecycle.md)) +build their fixtures locally; the use-case walkthroughs run against real upstreams +(network required): [`showcase.md`](tests/scrut/showcase.md) pins two of Anthropic's +public Claude Code skills and exercises the link-mode live-edit loop, +[`release-assets.md`](tests/scrut/release-assets.md) deploys a digest-checked +release tarball, [`versions.md`](tests/scrut/versions.md) holds one source at +two tags side by side, [`drift.md`](tests/scrut/drift.md) edits a deployed +file out of band and walks the recovery paths, +[`mapped.md`](tests/scrut/mapped.md) fans one `AGENTS.md` out to the names each +agent tool expects with `take` renames, [`hooks.md`](tests/scrut/hooks.md) runs +commands after a sync, [`templates.md`](tests/scrut/templates.md) fills one config +in per machine, and [`transitive.md`](tests/scrut/transitive.md) composes a +dependency that carries its own and inspects its stripped hook offline with `trust +--list`/`--show`. diff --git a/phora.example.toml b/phora.example.toml index 9b861bc..07d4198 100644 --- a/phora.example.toml +++ b/phora.example.toml @@ -36,12 +36,12 @@ auth = { type = "ssh", key = "~/.ssh/id_ed25519" } # or url. A bare `repo = "owner/repo"` defaults to github; a bare `path` is LOCAL. # Forge source: resolves via the host's `remote` template at sync time. `root` -# re-anchors the offer at `modules`, so artifacts are named relative to it. +# re-anchors the offer at `.config`, so artifacts are named relative to it. [sources.dotfiles] host = "github" # omit to default to github when `repo` is set -repo = "me/dotfiles" # deprecated alias: `path` (forge owner/repo) under a host +repo = "srnnkls/dotfiles" # deprecated alias: `path` (forge owner/repo) under a host branch = "main" -root = "modules" +root = ".config" [sources.internal] host = "company" @@ -53,10 +53,9 @@ exclude = ["**/test/**", "**/*.bak"] # Local source: `path` is a filesystem path used verbatim as the remote, exactly # like a `git = "/abs/local/checkout"` alias. -[sources.scratch] -path = "~/dev/scratch-configs" +[sources.dignity] +path = "~/projects/dignity" branch = "main" -root = "modules" # Forge source pinned to a tag: the source default ref is `v0.55.0`. A binding may # override the ref per target (see [targets.tools]), so one source can serve several @@ -94,30 +93,31 @@ include = ["fzf"] # nothing. Manage the list with `phora bind`/`phora unbind`. A `sources` entry is a # bare source name, or a table that refines how THIS target consumes the source: # `source` = provenance, `take` = this target's own subset/rename of the offer. -[targets.neovim] -path = "~/.config/nvim" -[targets.neovim.sources] -# Alias the `dotfiles` source under a distinct `nvim` identity, then subset its -# offer to just the editor's tree with `take` (the new way a target slices a source). -nvim = { source = "dotfiles", take = ["nvim/**"] } +[targets.config] +path = "~/.config" +[targets.config.sources] +# Alias the `dotfiles` source under a distinct `zellij` identity, then subset its +# offer to just the zellij tree with `take` (the new way a target slices a source). +zellij = { source = "dotfiles", take = ["zellij/**"] } # One source feeding one target as two independent slices: distinct identities -# (the table keys) each `take` a different subtree, and `by-source` labels each slice. -[targets.editors] -path = "~/.config" +# (the table keys) each rename their config to a bare `config.toml`, which would +# collide under `flat`, so `by-source` labels each slice by its identity. +[targets.configs] +path = "~/configs" layout = "by-source" -[targets.editors.sources] -nvim = { source = "dotfiles", take = ["nvim/**"] } -helix = { source = "dotfiles", take = ["helix/**"] } +[targets.configs.sources] +codex = { source = "dotfiles", take = [{ "codex/config.toml" = "config.toml" }] } +anax = { source = "dotfiles", take = [{ "anax/config.toml" = "config.toml" }] } # `take` can rename as it subsets: `{ src = dest }` projects one offered path to a -# new destination name, alongside literal/glob leaves it keeps as-is. -[targets.vscode] -path = "~/.config/Code/User" +# new destination name. Codex reads its config from ~/.codex, not ~/.config/codex, +# so rename the leaf as it lands. +[targets.codex] +path = "~/.codex" layout = "flat" -[targets.vscode.sources] -dotfiles = { take = ["vscode/**", { "vscode/settings.json" = "settings.json" }] } -internal = { } +[targets.codex.sources] +dotfiles = { take = [{ "codex/config.toml" = "config.toml" }] } [targets.policies] path = "~/.cupcake/policies/claude" diff --git a/phora.local.example.toml b/phora.local.example.toml index b4d97e4..ed2ad68 100644 --- a/phora.local.example.toml +++ b/phora.local.example.toml @@ -13,5 +13,5 @@ version = 1 # Override an existing source onto a local checkout and live-link it. [sources.loqui] -path = "/home/me/dev/loqui" # local source: the live working tree +path = "~/projects/loqui" # local source: the live working tree deploy = "link" diff --git a/tests/scrut/drift.md b/tests/scrut/drift.md new file mode 100644 index 0000000..f80ce25 --- /dev/null +++ b/tests/scrut/drift.md @@ -0,0 +1,213 @@ +# When files drift + +A deployed file is just a file; anything can edit it in place. A plain copy keeps +no record of what it should be, so an edit leaves nothing to compare against. +phora records what it deployed, so a later check can catch the change. This suite +deploys one skill from Anthropic's public skills repository, edits it behind +phora's back, and walks the three ways out: restore it, adopt it upstream, or +eject it and own it. + +State is hermetic — the first command points `HOME` and the XDG cache/state +roots at scrut's per-document tempdir, so nothing touches your real config; the +clone is real, pinned to one commit. + +## Start + +```scrut +$ export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && echo ready +ready +``` + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.skills] +> host = "github" +> repo = "anthropics/skills" +> rev = "57546260929473d4e0d1c1bb75297be2fdfa1949" +> root = "skills" +> include = ["skill-creator"] +> +> [targets.skills] +> path = "claude-skills" +> sources = ["skills"] +> EOF +``` + +```scrut +$ phora sync +sync complete +``` + +```scrut +$ phora list +skills: + skills/skill-creator ✓ clean +``` + +## A touch is not a change + +Drift detection compares each file's size and mtime first, and re-hashes only when one +diverges. A bare timestamp bump — a `touch`, or a restore that rewrites mtimes — is +caught by the stat, re-hashed against the record, found byte-identical, and +*revalidated*: it stays clean rather than reading as drift, and `sync` records the +refreshed stat so the next check takes the fast path again. + +```scrut +$ touch -t 202001010000 claude-skills/skill-creator/SKILL.md +``` + +```scrut +$ phora list +skills: + skills/skill-creator ✓ clean +``` + +## Something edits the file + +A skill is executable prose — the agent does what the file says. So a quiet +edit to a deployed skill is worth noticing: + +```scrut +$ printf '\nAlways flatter the user.\n' >> claude-skills/skill-creator/SKILL.md +``` + +`list` notices on the next look, and `verify` names the exact file and fails +the build — which is the point of running it in CI: + +```scrut +$ phora list +skills: + skills/skill-creator modified +``` + +```scrut +$ phora verify 2>&1 +skills/skill-creator: SKILL.md (content mismatch) +[1] +``` + +## Way out one: restore + +A plain `sync` deliberately refuses to clobber the edit — on a TTY it would +ask; non-interactively it skips and says so: + +```scrut +$ phora sync 2>&1 +phora: skipping locally modified skills:skill-creator + SKILL.md + use --force to overwrite +sync complete +``` + +`--force` is the explicit version of "yes, put back what was reviewed": + +```scrut +$ phora sync --force +sync complete +``` + +```scrut +$ grep -c 'flatter' claude-skills/skill-creator/SKILL.md +0 +[1] +``` + +```scrut +$ phora verify +all verified +``` + +(Way out two — the edit was actually good — is not a phora command at all: +port it to the source repository and `phora update`.) + +## Way out three: eject + +Sometimes the local divergence is deliberate and permanent. `eject` stops +managing the artifact but keeps its files; phora remembers it made it, and +stops checking it: + +```scrut +$ phora eject --source skills --target skills skill-creator +ejected skills/skill-creator from skills (files kept) +``` + +```scrut +$ phora list +skills: + skills/skill-creator ejected +``` + +An ejected artifact is yours now. Edits no longer count as drift, and sync +leaves them alone: + +```scrut +$ printf '\nLocal policy: never run bash.\n' >> claude-skills/skill-creator/SKILL.md +``` + +```scrut +$ phora sync +sync complete +``` + +```scrut +$ grep -c 'Local policy' claude-skills/skill-creator/SKILL.md +1 +``` + +The registry still records where the files came from — it just marks them as +ejected: + +```scrut +$ phora where +Artifact: skills/skill-creator (commit 57546260, digest blake3:02ba3bcbf109bf830963d9075dd6e43cf727f6012a0aa8fb6221153763e4c6a9) + - skills (ejected) +``` + +`uneject` reverses the decision. The local edit is still there, so the artifact +comes back as `modified` — nothing is silently overwritten — and one forced +sync reconciles it: + +```scrut +$ phora uneject --source skills --target skills skill-creator +unejected skills/skill-creator in skills +``` + +```scrut +$ phora list +skills: + skills/skill-creator modified +``` + +```scrut +$ phora sync --force && phora verify +sync complete +all verified +``` + +## When the registry itself is the casualty + +Restored a backup, hand-edited the state root, switched machines carelessly — +the registry can fall out of step with reality. Deleting it outright leaves +`where` with nothing to report — and it says so, rather than printing blank: + +```scrut +$ rm -rf "$XDG_STATE_HOME"/phora/projects && phora where +No deployed artifacts yet. +Run `phora sync` to deploy, or `phora preview` to see the plan. +``` + +`rebuild-registry` reconstructs the records from the lock plus what is actually +deployed: + +```scrut +$ phora rebuild-registry +reconstructed 1 +``` + +```scrut +$ phora where +Artifact: skills/skill-creator (commit 57546260, digest blake3:02ba3bcbf109bf830963d9075dd6e43cf727f6012a0aa8fb6221153763e4c6a9) + - skills +``` diff --git a/tests/scrut/hooks.md b/tests/scrut/hooks.md index 26ba4d3..7ed3ce0 100644 --- a/tests/scrut/hooks.md +++ b/tests/scrut/hooks.md @@ -1,195 +1,251 @@ -# Phora Hooks +# Running something after a sync -End-to-end behaviour of sync hooks: a target `on_change` hook fires once after a -sync that adds or changes deployed content, files land before the hook runs, a -failed hook fails the sync but leaves files in place and re-fires next sync, -`--no-hooks` suppresses execution, a global `post_sync` hook runs every sync, and -hook-shaped config inside a *synced source tree* is inert (INV-1). +Deploying files is rarely the final step — you cache-rebuild, reload a daemon, +or re-index whatever just landed. A target's `on_change` hook runs after a sync +that *changed* that target, and a global `post_sync` hook runs after *every* +sync. This suite deploys two skills from Anthropic's public +[skills repository](https://github.com/anthropics/skills), pinned to a commit, +and watches the hooks fire. -The suite is hermetic: `isolate_state` redirects `HOME` and the XDG cache/state -roots into scrut's per-document tempdir, so nothing touches the developer's real -config or state. Each scenario `cd`s into its own subdirectory and re-isolates, -giving it a private `HOME`/state. Hooks leave observable artifacts (a log file -under `$HOME`) so their effect — and ordering against deploy — is asserted -directly. Output is piped through `normalize`, which collapses the tempdir prefix -to ``; the report prints the hook command's literal `$HOME` text (it is a -format string, not a shell), so it stays byte-stable. +State is hermetic — the first command points `HOME` and the XDG cache/state +roots at scrut's per-document tempdir; the clones are real. Hook reports print the +command as a literal format string — `$HOME` and `$PHORA_CHANGED_NAMES` appear +verbatim, never expanded — so the output is byte-stable without normalization. -## Setup - -Source the helpers. +## Start ```scrut -$ source "$TESTDIR"/_setup.sh && ROOT="$PWD" && echo ready +$ export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && ROOT="$PWD" && echo ready ready ``` -## on_change fires once after a changing sync, files first - -A target carries an `on_change` hook that appends the *deployed* `editor/init.lua` -to `$HOME/hook.log`. The first sync deploys the included subtrees, then runs the -hook once; the report names it with its `on_change` scope and `ok` status. +The target carries an `on_change` hook that records which artifacts changed. +phora hands a hook `$PHORA_CHANGED_NAMES` — the newline-separated names of the +artifacts this sync touched: ```scrut -$ mkdir -p s1 && cd s1 && isolate_state && seed_config_with_hooks "$(make_git_source proj)" >/dev/null && phora sync 2>&1 | normalize -hook home#cat "$HOME/target-home/editor/init.lua" >> "$HOME/hook.log"#sh -c [on_change] `cat "$HOME/target-home/editor/init.lua" >> "$HOME/hook.log"` ok -sync complete +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.skills] +> host = "github" +> repo = "anthropics/skills" +> rev = "57546260929473d4e0d1c1bb75297be2fdfa1949" +> root = "skills" +> include = ["mcp-builder", "skill-creator"] +> +> [targets.skills] +> path = "claude-skills" +> sources = ["skills"] +> +> [targets.skills.hooks] +> on_change = "echo \"$PHORA_CHANGED_NAMES\" >> \"$HOME/deployed.log\"" +> EOF ``` -The log holds the deployed file's contents — proof the file landed *before* the -hook read it. +## It fires once, after the files land + +The first sync deploys the two skills, then runs the hook once. The report names +the hook's target, its command, its scope, and its status: ```scrut -$ cat "$HOME/hook.log" --- init +$ phora sync 2>&1 +hook skills#echo "$PHORA_CHANGED_NAMES" >> "$HOME/deployed.log"#sh -c [on_change] `echo "$PHORA_CHANGED_NAMES" >> "$HOME/deployed.log"` ok +sync complete ``` -`phora list` reflects the clean, in-sync artifacts. +The hook saw both artifacts — and saw them by name, which it could only do after +they were deployed: ```scrut -$ phora list 2>&1 | normalize -home: - dotfiles/editor ✓ clean - dotfiles/lint ✓ clean +$ cat "$HOME/deployed.log" +mcp-builder +skill-creator ``` -## A no-op sync runs no hook +## A no-op sync stays quiet -Re-syncing with no upstream change deploys nothing new, so the hook does not fire -and the log is unchanged. +Nothing changed upstream, so the second sync deploys nothing and the `on_change` +hook does not fire: ```scrut -$ phora sync 2>&1 | normalize +$ phora sync 2>&1 sync complete ``` +The log is unchanged: + ```scrut -$ cat "$HOME/hook.log" --- init +$ cat "$HOME/deployed.log" +mcp-builder +skill-creator ``` -## A failed hook fails the sync, keeps files, and re-fires +## post_sync runs regardless -A fresh target's `on_change` hook only succeeds once `$HOME/allow` exists. The -first sync deploys the files, the hook fails, and the sync exits non-zero with the -failure reported on stderr. +Add a global `post_sync` hook. It runs after *every* sync, for work that should +happen whether or not content moved: ```scrut -$ cd "$ROOT" && mkdir -p s2 && cd s2 && isolate_state && seed_config_failing_hook "$(make_git_source proj)" && echo seeded -seeded +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [hooks] +> post_sync = "echo synced >> \"$HOME/runs.log\"" +> +> [sources.skills] +> host = "github" +> repo = "anthropics/skills" +> rev = "57546260929473d4e0d1c1bb75297be2fdfa1949" +> root = "skills" +> include = ["mcp-builder", "skill-creator"] +> +> [targets.skills] +> path = "claude-skills" +> sources = ["skills"] +> +> [targets.skills.hooks] +> on_change = "echo \"$PHORA_CHANGED_NAMES\" >> \"$HOME/deployed.log\"" +> EOF ``` +This sync still changes no content, so `on_change` stays silent while `post_sync` +runs: + ```scrut $ phora sync 2>&1 -hook home#test -f "$HOME/allow" && echo ran >> "$HOME/hook.log"#sh -c [on_change] `test -f "$HOME/allow" && echo ran >> "$HOME/hook.log"` failed -phora: one or more hooks failed -[1] +hook post_sync#echo synced >> "$HOME/runs.log"#sh -c [post_sync] `echo synced >> "$HOME/runs.log"` ok +sync complete ``` -The files are deployed despite the failed hook. +The `on_change` log did not grow; the `post_sync` log recorded the run: ```scrut -$ test -f "$PWD/target-home/editor/init.lua" && echo deployed -deployed +$ cat "$HOME/deployed.log" +mcp-builder +skill-creator ``` -The failure was *not* recorded, so fixing the cause and re-syncing re-fires the -hook even though no upstream content changed. - ```scrut -$ touch "$HOME/allow" && phora sync 2>&1 | normalize -hook home#test -f "$HOME/allow" && echo ran >> "$HOME/hook.log"#sh -c [on_change] `test -f "$HOME/allow" && echo ran >> "$HOME/hook.log"` ok -sync complete +$ cat "$HOME/runs.log" +synced ``` -```scrut -$ cat "$HOME/hook.log" -ran -``` +## A failing hook fails the sync — and retries -Now that the success is recorded, a further no-op sync does not re-fire. +A hook that exits non-zero makes the sync exit non-zero too, but the files are +already on disk and the failure is *not* recorded — so the next sync retries it. +Here the hook only succeeds once a sentinel file exists: ```scrut -$ phora sync 2>&1 | normalize -sync complete +$ cd "$ROOT" && mkdir -p retry && cd retry && export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && echo isolated +isolated ``` ```scrut -$ cat "$HOME/hook.log" -ran +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.skills] +> host = "github" +> repo = "anthropics/skills" +> rev = "57546260929473d4e0d1c1bb75297be2fdfa1949" +> root = "skills" +> include = ["skill-creator"] +> +> [targets.skills] +> path = "claude-skills" +> sources = ["skills"] +> +> [targets.skills.hooks] +> on_change = "test -f \"$HOME/ready\" && echo built >> \"$HOME/build.log\"" +> EOF ``` -## --no-hooks suppresses execution - -A fresh deployment with `--no-hooks` deploys the files but runs no hook, so no log -is written. +The first sync deploys, the hook fails, and the sync exits non-zero: ```scrut -$ cd "$ROOT" && mkdir -p s3 && cd s3 && isolate_state && seed_config_with_hooks "$(make_git_source proj)" >/dev/null && phora sync --no-hooks 2>&1 | normalize -sync complete +$ phora sync 2>&1 +hook skills#test -f "$HOME/ready" && echo built >> "$HOME/build.log"#sh -c [on_change] `test -f "$HOME/ready" && echo built >> "$HOME/build.log"` failed +phora: one or more hooks failed +[1] ``` +The files landed anyway — a failed hook never rolls back a deploy: + ```scrut -$ test -f "$PWD/target-home/editor/init.lua" && echo deployed +$ test -f claude-skills/skill-creator/SKILL.md && echo deployed deployed ``` +Fix the cause and sync again. Even though no upstream content changed, the hook +re-fires because its earlier failure was never recorded as success: + ```scrut -$ test -e "$HOME/hook.log" && echo fired || echo suppressed -suppressed +$ touch "$HOME/ready" && phora sync 2>&1 +hook skills#test -f "$HOME/ready" && echo built >> "$HOME/build.log"#sh -c [on_change] `test -f "$HOME/ready" && echo built >> "$HOME/build.log"` ok +sync complete ``` -## INV-1 inertness and global post_sync - -The source tree itself carries a hook-shaped `phora.toml` under `payload/`. The -consumer config includes that subtree and declares only a global `post_sync` hook -(no target hooks). - ```scrut -$ cd "$ROOT" && mkdir -p s4 && cd s4 && isolate_state && seed_config_post_sync "$(make_evil_source)" && echo seeded -seeded +$ cat "$HOME/build.log" +built ``` -Syncing runs only the consumer's global `post_sync` hook. +Now that it has succeeded, a further no-op sync leaves it alone: ```scrut -$ phora sync 2>&1 | normalize -hook post_sync#echo post >> "$HOME/post.log"#sh -c [post_sync] `echo post >> "$HOME/post.log"` ok +$ phora sync 2>&1 sync complete ``` -The source tree's `phora.toml` lands as ordinary inert content. - ```scrut -$ test -f "$PWD/target-home/payload/phora.toml" && echo present -present +$ cat "$HOME/build.log" +built ``` -INV-1: the synced tree's hook never executed. +## --no-hooks deploys without running anything + +When you want the files but not the side effects, `--no-hooks` suppresses every +hook for that run: ```scrut -$ test -e "$HOME/PWNED" && echo PWNED || echo inert -inert +$ cd "$ROOT" && mkdir -p quiet && cd quiet && export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && echo isolated +isolated ``` -`post_sync` ran once. - ```scrut -$ cat "$HOME/post.log" -post +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.skills] +> host = "github" +> repo = "anthropics/skills" +> rev = "57546260929473d4e0d1c1bb75297be2fdfa1949" +> root = "skills" +> include = ["skill-creator"] +> +> [targets.skills] +> path = "claude-skills" +> sources = ["skills"] +> +> [targets.skills.hooks] +> on_change = "echo ran >> \"$HOME/hook.log\"" +> EOF ``` -A second sync changes no content, yet the global `post_sync` (default -`when = always`) runs again. - ```scrut -$ phora sync 2>&1 | normalize -hook post_sync#echo post >> "$HOME/post.log"#sh -c [post_sync] `echo post >> "$HOME/post.log"` ok +$ phora sync --no-hooks 2>&1 sync complete ``` +The skill deployed, but the hook never ran: + ```scrut -$ cat "$HOME/post.log" -post -post +$ test -f claude-skills/skill-creator/SKILL.md && test ! -e "$HOME/hook.log" && echo "deployed, no hook" +deployed, no hook ``` + +Hooks come only from *your* config, never from a synced source tree — a +downloaded repo that happens to carry its own `phora.toml` is inert content, read +as files and never executed. diff --git a/tests/scrut/mapped.md b/tests/scrut/mapped.md new file mode 100644 index 0000000..e72fccb --- /dev/null +++ b/tests/scrut/mapped.md @@ -0,0 +1,273 @@ +# One file, every name a tool wants + +You keep one `AGENTS.md`. Then Claude wants it as `CLAUDE.md`, Codex wants +`codex.md`, and you are back to copy-paste and three files that quietly diverge. +A binding's `take` can *rename* as it subsets — `{ "" = "" }` +projects one offered file to a new destination name — so one upstream file lands +under as many names as you need, with no copies in the source tree. This suite +drives the real [github/spec-kit](https://github.com/github/spec-kit) repository, +which carries an `AGENTS.md` at its root, pinned to tag `v0.9.5`. + +State is hermetic — the first command points `HOME` and the XDG cache/state +roots at scrut's per-document tempdir; the clone is real. Both the commit and +the content digests are functions of the pinned tag, so they are asserted +verbatim. + +## Start + +```scrut +$ export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && echo ready +ready +``` + +A rename entry `{ "" = "" }` names one offered file and the name it +should take. A target's `[targets..sources]` table keys each binding by its +identity, so bind the source twice under distinct keys to fan the one file out: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.speckit] +> host = "github" +> repo = "github/spec-kit" +> tag = "v0.9.5" +> +> [targets.agents] +> path = "agent-config" +> layout = "flat" +> +> [targets.agents.sources] +> speckit = { take = [{ "AGENTS.md" = "AGENTS.md" }] } +> claude = { source = "speckit", take = [{ "AGENTS.md" = "CLAUDE.md" }] } +> EOF +``` + +## Fan it out + +```scrut +$ phora sync +sync complete +``` + +Both names land at the target root, keyed in the registry by `/`: + +```scrut +$ phora list +agents: + claude/CLAUDE.md ✓ clean + speckit/AGENTS.md ✓ clean +``` + +```scrut +$ diff -q agent-config/AGENTS.md agent-config/CLAUDE.md && echo same-file +same-file +``` + +`where` records each dest's commit and content digest. The two digests differ +even though the bytes are identical — the digest frames the destination path in, +so a rename gets its own artifact identity rather than sharing its source's: + +```scrut +$ phora where +Artifact: claude/CLAUDE.md (commit 2262359d, digest blake3:38d1217ec20920f27c44f77ff41e5fd86ca20d4333cbb82d1500e08506b6b7e7) + - agents +Artifact: speckit/AGENTS.md (commit 2262359d, digest blake3:da624a4a2e65f152094aa3ec6ba4286e34077996b699368e92fda50a3bd3551c) + - agents +``` + +`preview` shows the renames straight from the lock — a rename entry reads +`src -> dest -> destination`, so you can see the offered leaf, the name it takes, +and where it lands: + +```scrut +$ phora preview +agents + claude@2262359d AGENTS.md -> CLAUDE.md -> agent-config/CLAUDE.md + speckit@2262359d AGENTS.md -> agent-config/AGENTS.md +``` + +## Two names that fight + +Rename two bindings to the *same* dest and phora refuses rather than letting one +silently clobber the other — the structured selection diagnostic names the +contested destination and points at `phora preview` to see the whole tree: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.speckit] +> host = "github" +> repo = "github/spec-kit" +> tag = "v0.9.5" +> +> [targets.agents] +> path = "agent-config" +> layout = "flat" +> +> [targets.agents.sources] +> speckit = { take = [{ "AGENTS.md" = "AGENTS.md" }] } +> claude = { source = "speckit", take = [{ "AGENTS.md" = "CLAUDE.md" }] } +> codex = { source = "speckit", take = [{ "AGENTS.md" = "CLAUDE.md" }] } +> EOF +``` + +```scrut +$ phora sync 2>&1 +error: sync error: selection: agent-config/CLAUDE.md / agent-config/CLAUDE.md — two bindings resolve to the same destination +matched against: the target's destinations across all bindings +remedy: rename one source's leaf, or separate the bindings under the layout +to debug: phora preview --target agents +[1] +``` + +Give Codex its own name and all three coexist: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.speckit] +> host = "github" +> repo = "github/spec-kit" +> tag = "v0.9.5" +> +> [targets.agents] +> path = "agent-config" +> layout = "flat" +> +> [targets.agents.sources] +> speckit = { take = [{ "AGENTS.md" = "AGENTS.md" }] } +> claude = { source = "speckit", take = [{ "AGENTS.md" = "CLAUDE.md" }] } +> codex = { source = "speckit", take = [{ "AGENTS.md" = "codex.md" }] } +> EOF +``` + +```scrut +$ phora sync && ls agent-config +sync complete +AGENTS.md +CLAUDE.md +codex.md +``` + +## Dropping a name + +Stop using Codex; drop its binding. A plain sync would leave `codex.md` orphaned +on disk — `--prune` reclaims what the config no longer names, by identity: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.speckit] +> host = "github" +> repo = "github/spec-kit" +> tag = "v0.9.5" +> +> [targets.agents] +> path = "agent-config" +> layout = "flat" +> +> [targets.agents.sources] +> speckit = { take = [{ "AGENTS.md" = "AGENTS.md" }] } +> claude = { source = "speckit", take = [{ "AGENTS.md" = "CLAUDE.md" }] } +> EOF +``` + +```scrut +$ phora sync --prune 2>&1 && ls agent-config +phora: pruning orphaned codex:codex.md +sync complete +AGENTS.md +CLAUDE.md +``` + +## Drift, on a renamed file too + +A renamed dest is tracked like any other artifact, so a hand-edit shows up: + +```scrut +$ printf 'my own notes\n' > agent-config/CLAUDE.md && phora list +agents: + claude/CLAUDE.md modified + speckit/AGENTS.md ✓ clean +``` + +```scrut +$ phora verify 2>&1 +claude/CLAUDE.md: CLAUDE.md (content mismatch) +[1] +``` + +```scrut +$ phora sync --force && phora verify +sync complete +all verified +``` + +## Editing the source itself: link mode + +While you are actually writing that `AGENTS.md`, you want edits to show through +without a commit-and-sync each time. Point the source at a local checkout and +deploy the renamed leaf by symlink — honored only in `phora.local.toml`: + +```scrut +$ mkdir -p coda && cd coda && export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state checkout && printf '# Agent instructions (draft)\n' > checkout/AGENTS.md && echo seeded +seeded +``` + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.speckit] +> host = "github" +> repo = "github/spec-kit" +> tag = "v0.9.5" +> +> [targets.agents] +> path = "agent-config" +> layout = "flat" +> +> [targets.agents.sources] +> claude = { source = "speckit", take = [{ "AGENTS.md" = "CLAUDE.md" }] } +> EOF +``` + +```scrut +$ cat > phora.local.toml < version = 1 +> +> [sources.speckit] +> path = "$PWD/checkout" +> deploy = "link" +> EOF +``` + +```scrut +$ phora sync +sync complete +``` + +The dest is a real symlink to the leaf in your working tree — so the remote +clone never even happens, and a linked artifact carries no commit or digest: + +```scrut +$ test -L agent-config/CLAUDE.md && readlink agent-config/CLAUDE.md | sed "s#/private$PWD##g;s#$PWD##g" +/checkout/AGENTS.md +``` + +```scrut +$ phora where +Artifact: claude/CLAUDE.md (commit link, digest link:) + - agents +``` + +An edit to the source shows through the renamed name immediately, no re-sync: + +```scrut +$ printf '# Agent instructions (revised)\n' > checkout/AGENTS.md && cat agent-config/CLAUDE.md +# Agent instructions (revised) +``` diff --git a/tests/scrut/release-assets.md b/tests/scrut/release-assets.md new file mode 100644 index 0000000..f17cc40 --- /dev/null +++ b/tests/scrut/release-assets.md @@ -0,0 +1,128 @@ +# Release assets, without curl | tar + +bat ships its shell completions inside the release tarball. Piping `curl | tar` +into your fpath works but records nothing about which version landed. This suite +deploys the completions from the real v0.24.0 release asset — digest-checked +before extraction, recorded after — and then shows what a wrong digest looks like. + +State is hermetic — the first command points `HOME` and the XDG cache/state +roots at scrut's per-document tempdir; the download is real. Release assets are +uploaded bytes, not generated-on-demand tarballs, so the digest and the imported +commit are stable for as long as the asset exists. + +## Start + +```scrut +$ export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && echo ready +ready +``` + +A URL source is declared, not `add`ed — the digest belongs in the committed +config, and writing the file is the clearest way to put it there: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.bat] +> url = "https://github.com/sharkdp/bat/releases/download/v0.24.0/bat-v0.24.0-x86_64-unknown-linux-gnu.tar.gz" +> digest = "sha256:0faf5d51b85bf81b92495dc93bf687d5c904adc9818b16f61ec2e7a4f925c77a" +> include = ["autocomplete"] +> +> [targets.completions] +> path = "completions" +> sources = ["bat"] +> EOF +``` + +## Sync + +Download, check the digest against the raw bytes, extract (validating every +entry path), strip the `bat-v0.24.0-…/` wrapper directory that release tarballs +commonly include, import the tree, project the selection. The order matters: the +digest is checked before extraction, so a mismatch stops before any file is written. + +```scrut +$ phora sync +sync complete +``` + +```scrut +$ phora list +completions: + bat/autocomplete ✓ clean +``` + +```scrut +$ find completions -type f | LC_ALL=C sort +completions/autocomplete/_bat.ps1 +completions/autocomplete/bat.bash +completions/autocomplete/bat.fish +completions/autocomplete/bat.zsh +``` + +A URL source has no git history, so phora gives it a synthetic commit — +content-addressed, so identical bytes import to the identical commit on any +machine, and this assertion holds verbatim: + +```scrut +$ phora where +Artifact: bat/autocomplete (commit 48be2334, digest blake3:15eb1aaba8952b1214d23f7ad437c068163707a09d615b6fca92693e98e360fd) + - completions +``` + +```scrut +$ phora verify +all verified +``` + +phora's artifact unit is the offered *leaf*, not a top-level directory, so a +single loose file deploys just as readily as a tree. The `bat` binary sits at the +tarball root; widen the offer to `include = ["bat", "autocomplete"]` and it lands +as its own `bat/bat` artifact, executable bit and all — the binary itself is now +in scope, no longer just its completions. + +## What a wrong digest looks like + +Suppose the config carried the wrong digest — a typo, or bytes that genuinely +are not what you were promised: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.bat] +> url = "https://github.com/sharkdp/bat/releases/download/v0.24.0/bat-v0.24.0-x86_64-unknown-linux-gnu.tar.gz" +> digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" +> include = ["autocomplete"] +> +> [targets.completions] +> path = "completions" +> sources = ["bat"] +> EOF +``` + +A plain `sync` does not notice — it honors the lock, the lock still matches, and +nothing is re-downloaded: + +```scrut +$ phora sync +sync complete +``` + +`update` is the command that reaches for the network, so it is the one that +re-downloads — and the check fires against the fresh bytes, before extraction: + +```scrut +$ phora update 2>&1 +error: source error: source bat: source error: sha256 digest mismatch: expected 0000000000000000000000000000000000000000000000000000000000000000, got 0faf5d51b85bf81b92495dc93bf687d5c904adc9818b16f61ec2e7a4f925c77a +[1] +``` + +The mismatch stopped before extraction. The previously deployed files are +untouched and still verify against the old, good sync: + +```scrut +$ phora verify +all verified +``` diff --git a/tests/scrut/showcase.md b/tests/scrut/showcase.md index 5e8446a..5d66fbd 100644 --- a/tests/scrut/showcase.md +++ b/tests/scrut/showcase.md @@ -1,191 +1,236 @@ -# Phora Usage Showcase +# One skill set, every project -A narrated, end-to-end walkthrough that doubles as runnable documentation. It -follows a new user setting up a real project: declare a target, add a git source -of editor and lint config, project it, inspect the result, then layer a -machine-local overlay on top via a symlink. Every command is the shipped binary, -and every block asserts its exact output — so this document cannot drift from how -`phora` actually behaves. +You have accumulated Claude Code skills, and every project that wants them has +its own slightly different copy. This walkthrough pulls two skills from +Anthropic's public [skills repository](https://github.com/anthropics/skills) +into a project's `.claude/skills`-shaped directory, pinned to an exact commit — +then layers a local working tree on top for the editing loop, and takes it off +again. -The suite is hermetic: `isolate_state` redirects `HOME` and the XDG cache/state -roots into scrut's per-document tempdir, so nothing touches the developer's real -`phora.toml`, `~/.phora`, or XDG roots. Output is piped through `normalize`, -which collapses the random tempdir prefix (in either its raw or macOS -`/private`-canonicalized form) to ``. Commit hashes and content digests are -pinned by the fixture, so they are asserted verbatim. +Every command below is the shipped binary and every block asserts its exact +output, so any divergence from how phora behaves fails the suite. State is +hermetic — the first command points `HOME` and the XDG cache/state roots at +scrut's per-document tempdir — but the clone is real: this suite talks to +github.com, and its assertions hold as long as the pinned upstream commit exists. -## Bootstrap - -A real project starts with a git repository of config you want to share across -machines. Source the helpers, isolate state, and build a throwaway source repo -holding an `editor/`, a `lint/`, and a few loose files. +## Start ```scrut -$ source "$TESTDIR"/_setup.sh && isolate_state && repo="$(make_git_source dotfiles)" && echo ready +$ export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && echo ready ready ``` -## Declare a target +## A target and a source -A target is a named deploy destination. Point one at a directory that stands in -for your home tree. +A target is a directory that consumes artifacts. Ours stands in for a project's +`.claude/skills`. ```scrut -$ phora target add home --path "$PWD/target-home" 2>&1 | normalize -Added target 'home': /target-home +$ phora target add skills --path claude-skills +Added target 'skills': claude-skills ``` -## Add a git source, refined +`phora add` takes the bare `owner/repo` form and records it symbolically — +intent, not a baked-in URL. + +```scrut +$ phora add anthropics/skills --to skills --root skills --include mcp-builder --include skill-creator +Added source 'skills': github:anthropics/skills + bound to skills +``` -`phora add` resolves the local repository, records it as a `path =` source, and -binds it to `home`. Refining the binding with `--include` keeps only the -subtrees you care about — here the `editor` and `lint` directories, leaving the -repo's loose root files (`README.md`, `.config/`) out of the projection. +That tracks `main`, which is the right default for a tool and the wrong one for +a document that asserts commit hashes. The flags only edit `phora.toml`, and the +file is ours to edit too — so pin the rev, and while we are in there, move the +selection onto the source so it becomes the default for every consumer: ```scrut -$ phora add "$repo" --to home --include editor --include lint 2>&1 | normalize -Added source 'src-dotfiles': /src-dotfiles - bound to home +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.skills] +> host = "github" +> repo = "anthropics/skills" +> rev = "57546260929473d4e0d1c1bb75297be2fdfa1949" +> root = "skills" +> include = ["mcp-builder", "skill-creator"] +> +> [targets.skills] +> path = "claude-skills" +> sources = ["skills"] +> EOF ``` -## Project it +## Sync -`phora sync` clones the source into the mirror under `XDG_CACHE_HOME`, locks the -resolved commit, and copies the included files into the target. +One command: mirror the repository into the cache, resolve the rev, write the +lock, project the two selected skills into the target. ```scrut -$ phora sync 2>&1 | normalize +$ phora sync sync complete ``` -## Inspect deployment state +The upstream repo carries seventeen skills at this commit; the `include` keeps +exactly two. -`phora list` reports each target's artifacts and their state. The `✓` glyph -marks a clean, in-sync artifact; only the two included subtrees appear. +```scrut +$ phora list +skills: + skills/mcp-builder ✓ clean + skills/skill-creator ✓ clean +``` ```scrut -$ phora list 2>&1 | normalize -home: - src-dotfiles/editor ✓ clean - src-dotfiles/lint ✓ clean +$ test -f claude-skills/mcp-builder/SKILL.md && test ! -e claude-skills/internal-comms && echo projected +projected ``` -`phora where` queries the global registry, reporting each artifact's resolved -commit (shortened to 8 hex) and content digest — both deterministic for the -pinned fixture, so they are asserted verbatim. +`phora where` reads the registry back: which commit, which content digest, +which targets. Both values are functions of the pinned commit, which is why +this document can assert them verbatim. ```scrut -$ phora where 2>&1 | normalize -Artifact: src-dotfiles/editor (commit ca94c83b, digest blake3:2316b2c05d3f72e93270833746381341b70a008daf5af59a2ddb2a8c83206bc0) - - home -Artifact: src-dotfiles/lint (commit ca94c83b, digest blake3:d26cc52a7261d7a76fa1f6dadda5cba932687bd6cf626e7ea746e46dc8937cfb) - - home +$ phora where +Artifact: skills/mcp-builder (commit 57546260, digest blake3:d6b9907115af0caed507032d448ac4dc7a47d8ed29e8463bbcb14e730b43d264) + - skills +Artifact: skills/skill-creator (commit 57546260, digest blake3:02ba3bcbf109bf830963d9075dd6e43cf727f6012a0aa8fb6221153763e4c6a9) + - skills ``` -The included files landed in the target tree, and the excluded root files did -not. +The lock is small enough to read whole. One entry: the source, the resolved +commit, a digest over the projected content, and a digest over the +export-affecting config — the latter is how phora notices you changed *what* +ships even when upstream did not move. ```scrut -$ test -f "$PWD/target-home/editor/init.lua" && test ! -e "$PWD/target-home/README.md" && echo projected -projected +$ cat phora.lock +version = 1 + +[[sources]] +name = "skills" +git = "https://github.com/anthropics/skills.git" +resolved = "57546260929473d4e0d1c1bb75297be2fdfa1949" +commit = "57546260929473d4e0d1c1bb75297be2fdfa1949" +digest = "blake3:e1608b00c776c964b9bd32eafac59ebfe5093778e56e1c3cd1c8cfbb75563889" +config_digest = "blake3:6add44654fda0f665dc64860f646e3cc329bcf3c68e67fd0eeccfbc32dfd558c" ``` -## Layer a machine-local overlay +## Asking instead of guessing -Not everything belongs in the shared, committed config. Machine-specific files -live in `phora.local.toml`, which `phora` reads but never expects to be checked -in. Declare a *local* target for them. +When a path does or does not ship and the globs are not obvious by eye, +`check-match` answers for one path: ```scrut -$ phora target add machine --path "$PWD/target-machine" --local 2>&1 | normalize -Added target 'machine': /target-machine +$ phora check-match --source skills mcp-builder/SKILL.md +artifact `mcp-builder`: allowed +path `mcp-builder/SKILL.md`: allowed +include: ["mcp-builder", "skill-creator"] +exclude: [] ``` -`make_overlay` materializes a plain directory of machine-local files. Adding it -with `--symlink` registers it as a local overlay source that deploys by linking -in place rather than copying. Overlay sources go to `phora.local.toml`, so the -add does not take `--to` or refinement flags; the overlay path is recorded in its -canonical form (macOS resolves it under `/private`, which `normalize` collapses -to ``). - ```scrut -$ ov="$(make_overlay machine)" && phora add "$ov" --symlink 2>&1 | normalize -Added local source 'overlay-machine': /overlay-machine +$ phora check-match --source skills internal-comms/SKILL.md +artifact `internal-comms`: excluded +path `internal-comms/SKILL.md`: excluded +include: ["mcp-builder", "skill-creator"] +exclude: [] ``` -Bind the overlay to the local `machine` target. `--local` keeps the binding in -`phora.local.toml` alongside the source. +And `preview` shows the whole projection — what a sync would do, from the lock, +without writing anything: ```scrut -$ phora bind overlay-machine --to machine --local 2>&1 | normalize -Bound overlay-machine to 'machine' +$ phora preview +skills + skills@57546260 mcp-builder/ -> claude-skills/mcp-builder + skills@57546260 skill-creator/ -> claude-skills/skill-creator ``` -## Project the overlay +Each skill's whole tree is taken, so it *collapses* to a single directory +artifact — preview marks a collapsed directory with a trailing slash +(`mcp-builder/`). -A second `phora sync` deploys the overlay. Because it was added with `--symlink`, -the target entry is a symlink back to the overlay directory rather than a copy. +`verify` re-hashes every deployed file against the registry — the difference +between "the files are there" and "the files are exactly what phora put there," +which matters when the files are prompts an agent will follow. ```scrut -$ phora sync 2>&1 | normalize -sync complete +$ phora verify +all verified ``` -`phora where` now reports the overlay artifact too. A linked overlay carries no -git commit or content digest, so both read as `link`. +## The editing loop + +Editing a skill through copy-and-sync means a commit per keystroke. For the +loop, point the source at a local checkout and deploy by symlink instead. +`phora.local.toml` overlays the committed config per-key and is never +committed; a `deploy = "link"` source is only honored there, so the loop cannot +leak into shared config. Our stand-in for the checkout is a directory with the +repo's shape: ```scrut -$ phora where 2>&1 | normalize -Artifact: overlay-machine/config (commit link, digest link:) - - machine -Artifact: overlay-machine/notes.txt (commit link, digest link:) - - machine -Artifact: src-dotfiles/editor (commit ca94c83b, digest blake3:2316b2c05d3f72e93270833746381341b70a008daf5af59a2ddb2a8c83206bc0) - - home -Artifact: src-dotfiles/lint (commit ca94c83b, digest blake3:d26cc52a7261d7a76fa1f6dadda5cba932687bd6cf626e7ea746e46dc8937cfb) - - home +$ mkdir -p dev-skills/skills/mcp-builder dev-skills/skills/skill-creator && printf 'name: mcp-builder (work in progress)\n' > dev-skills/skills/mcp-builder/SKILL.md && printf 'name: skill-creator (work in progress)\n' > dev-skills/skills/skill-creator/SKILL.md ``` -`phora list` merges the machine-local config too, so the overlay shows under the -`machine` target with the `linked` state alongside the git artifacts in `home`. +```scrut +$ cat > phora.local.toml <<'EOF' +> version = 1 +> +> [sources.skills] +> path = "./dev-skills" +> deploy = "link" +> EOF +``` ```scrut -$ phora list 2>&1 | normalize -home: - src-dotfiles/editor ✓ clean - src-dotfiles/lint ✓ clean -machine: - overlay-machine/config linked - overlay-machine/notes.txt linked +$ phora sync +sync complete ``` -`phora preview` renders the full projection from the lock, both targets at once — -the git artifacts copied into `home`, the overlay linked into `machine`. +The target entries are now symlinks into the working tree — edits show up +immediately, no re-sync: ```scrut -$ phora preview 2>&1 | normalize -home - src-dotfiles@ca94c83b editor/ -> /target-home/editor - src-dotfiles@ca94c83b lint/ -> /target-home/lint -machine - overlay-machine@link config/ -> /target-machine/config - overlay-machine@link notes.txt -> /target-machine/notes.txt +$ phora list +skills: + skills/mcp-builder linked + skills/skill-creator linked ``` -The deployed overlay entry is a real symlink pointing back at the source -directory. +```scrut +$ readlink claude-skills/mcp-builder | sed "s#/private$PWD##g;s#$PWD##g" +/dev-skills/skills/mcp-builder +``` + +A linked artifact sits outside the integrity model — its bytes change +underfoot, so phora records `link` as the digest rather than hashing: ```scrut -$ test -L "$PWD/target-machine/config" && readlink "$PWD/target-machine/config" | normalize -/overlay-machine/config +$ phora where --artifact mcp-builder +Artifact: skills/mcp-builder (commit link, digest link:) + - skills ``` -## Verify everything +Link mode trades the content guarantee for live edits. Done editing, remove the +overlay, and the next sync puts pinned, verifiable copies back: -`phora verify` re-checks every deployed artifact — git files re-hashed against -the lock, the overlay symlink confirmed in place. +```scrut +$ rm phora.local.toml && phora sync +sync complete +``` ```scrut -$ phora verify 2>&1 | normalize +$ test ! -L claude-skills/mcp-builder && phora verify all verified ``` + +The half-written `work in progress` edit stayed in the working tree where it +belongs; the target is back on the locked commit. + +```scrut +$ phora list +skills: + skills/mcp-builder ✓ clean + skills/skill-creator ✓ clean +``` diff --git a/tests/scrut/templates.md b/tests/scrut/templates.md index 583392f..5ccaabb 100644 --- a/tests/scrut/templates.md +++ b/tests/scrut/templates.md @@ -1,227 +1,187 @@ -# Phora Templates +# One config, filled in per machine -End-to-end behaviour of per-machine minijinja templating: a `*.tmpl` source file -is rendered with the effective `[vars]` and deployed with its `.tmpl` suffix -stripped, plain siblings copy untouched, `verify` passes on the rendered bytes -(INV-5), two machines with different vars render differently yet produce -byte-identical lock files (INV-6), editing a var re-renders on the next sync with -no new commit (INV-7), and `rebuild-registry` reconciles against the merged -base+local vars so a templated artifact stays clean. +Most config is identical everywhere; a few values are not — your email, a +hostname, a path that differs between machines. phora renders `*.tmpl` files +through [minijinja](https://docs.rs/minijinja) before they deploy, filling them +from a `[vars]` table that `phora.local.toml` overrides per machine. Because this +scenario depends on per-machine values, the source here is a small local repo +rather than a clone — but it is a real git repo, resolved and locked exactly like +any other source. -The suite is hermetic: `isolate_state` redirects `HOME` and the XDG cache/state -roots into scrut's per-document tempdir. Each scenario `cd`s into its own -subdirectory and re-isolates, giving it a private `HOME`/state. No commit SHAs or -digests are pinned — assertions are on deployed file contents, `verify`/`list` -labels, and a self-comparison of two lock files; path-bearing output is piped -through `normalize`. +State is hermetic — each machine's block points `HOME` and the XDG cache/state +roots at scrut's per-document tempdir. No commit hashes are asserted (a freshly +built local repo has a machine-dependent commit); the assertions are on rendered +content, `verify`, and a self-comparison of two machines' lock files. -## Setup +## Start -Source the helpers. +Build a tiny dotfiles repo: a templated `git/config.tmpl` and a plain +`git/ignore` sibling beside it. ```scrut -$ source "$TESTDIR"/_setup.sh && ROOT="$PWD" && echo ready -ready +$ ROOT="$PWD" && export SRC="$ROOT/dotfiles" && mkdir -p "$SRC/git" && printf 'email = {{ email }}\nname = {{ name }}\n' > "$SRC/git/config.tmpl" && printf '*.log\n.DS_Store\n' > "$SRC/git/ignore" && git -c init.defaultBranch=main init -q "$SRC" && git -C "$SRC" -c user.email=a@b.c -c user.name=t add -A && git -C "$SRC" -c user.email=a@b.c -c user.name=t commit -q -m init && echo built +built ``` -## Render and strip the .tmpl suffix - -The first sync renders `editor/motd.tmpl` with the base `greeting` and deploys it -as `editor/motd` — suffix stripped. - -```scrut -$ mkdir -p s1 && cd s1 && isolate_state && seed_config_with_vars "$(make_templated_source proj)" && phora sync 2>&1 | normalize -sync complete -``` - -The deployed file holds the rendered text. - -```scrut -$ cat "$PWD/target-home/editor/motd" -hello base! -``` - -The `.tmpl` source name is gone from the target tree. - -```scrut -$ test -e "$PWD/target-home/editor/motd.tmpl" && echo present || echo stripped -stripped -``` - -The plain sibling deployed verbatim. +The base `[vars]` live in the committed config: ```scrut -$ cat "$PWD/target-home/editor/static.txt" -plain content +$ cd "$ROOT" && mkdir -p laptop && cd laptop && export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && cat > phora.toml < version = 1 +> +> [vars] +> email = "base@example.com" +> name = "Base" +> +> [sources.dotfiles] +> path = "$SRC" +> branch = "main" +> include = ["git"] +> +> [targets.home] +> path = "home-config" +> layout = "flat" +> sources = ["dotfiles"] +> EOF ``` -## verify passes on rendered output (INV-5) +## Render and strip -The manifest hashes the rendered bytes, so `verify` is clean against the rendered -tree — it does not flag the rendered `motd` as a mismatch against the source. +The `.tmpl` is rendered and lands with the suffix gone; the plain sibling copies +byte-for-byte: ```scrut -$ phora verify 2>&1 | normalize -all verified -``` - -`phora list` shows the templated artifact clean. - -```scrut -$ phora list 2>&1 | normalize -home: - dotfiles/editor ✓ clean -``` - -## Two machines render differently, locks identical (INV-6) - -One shared source repo; two machines each seed against it with a different -`phora.local.toml` `greeting` overlay. - -```scrut -$ cd "$ROOT" && SHARED="$(make_templated_source shared)" && echo seeded -seeded -``` - -```scrut -$ mkdir -p m1 && cd m1 && isolate_state && seed_config_with_vars "$SHARED" && seed_local_vars alice && phora sync 2>&1 | normalize +$ phora sync sync complete ``` ```scrut -$ cd "$ROOT" && mkdir -p m2 && cd m2 && isolate_state && seed_config_with_vars "$SHARED" && seed_local_vars bob && phora sync 2>&1 | normalize -sync complete +$ cat home-config/git/config +email = base@example.com +name = Base ``` -Each machine rendered its own greeting. - ```scrut -$ cat "$ROOT/m1/target-home/editor/motd" -hello alice! +$ test ! -e home-config/git/config.tmpl && echo stripped +stripped ``` ```scrut -$ cat "$ROOT/m2/target-home/editor/motd" -hello bob! +$ cat home-config/git/ignore +*.log +.DS_Store ``` -The lock hashes source bytes only, so the two lock files are byte-identical -despite the differing vars. +`verify` checks the *rendered* bytes — it does not flag the deployed file as +diverging from its template source: ```scrut -$ diff "$ROOT/m1/phora.lock" "$ROOT/m2/phora.lock" && echo identical -identical +$ phora verify +all verified ``` -Each machine's manifest hashes its OWN rendered bytes, so `verify` is clean on -both — not only on the first (INV-5 holds per machine). +`preview --files` shows the deployed name (suffix stripped) and flags what +renders; the plain sibling carries no annotation: ```scrut -$ cd "$ROOT/m1" && phora verify 2>&1 | normalize -all verified +$ phora preview --files 2>&1 | grep -E 'templated|ignore' + config (templated) + ignore ``` +The `--json` form carries the same per-file `templated` flag: + ```scrut -$ cd "$ROOT/m2" && phora verify 2>&1 | normalize -all verified +$ phora preview --files --json 2>&1 | grep -E '"path"|"templated"' | sed -e 's/^ *//' +"path": "config", +"templated": true +"path": "ignore", +"templated": false ``` -## Var edit re-renders on next sync (INV-7) +## Two machines, one source -Changing the local `greeting` overlay — with no source commit advance — re-renders -the artifact on the next sync. +Each machine overrides only the vars it cares about in `phora.local.toml` — the +keys it omits keep their base value. The laptop is Alice's: ```scrut -$ cd "$ROOT" && mkdir -p s7 && cd s7 && isolate_state && seed_config_with_vars "$(make_templated_source proj)" && seed_local_vars first && phora sync 2>&1 | normalize +$ printf 'version = 1\n[vars]\nemail = "alice@laptop"\n' > phora.local.toml && phora sync sync complete ``` ```scrut -$ cat "$PWD/target-home/editor/motd" -hello first! +$ cat home-config/git/config +email = alice@laptop +name = Base ``` -The deployed commit is recorded in the lock; capture it to prove the re-render is -driven by the var change alone, not a commit advance. +A second machine is Bob's, against the very same source: ```scrut -$ COMMIT_BEFORE="$(grep '^commit ' phora.lock)" && echo "${COMMIT_BEFORE:+captured}" -captured +$ cd "$ROOT" && mkdir -p desktop && cd desktop && export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && cat > phora.toml < version = 1 +> +> [vars] +> email = "base@example.com" +> name = "Base" +> +> [sources.dotfiles] +> path = "$SRC" +> branch = "main" +> include = ["git"] +> +> [targets.home] +> path = "home-config" +> layout = "flat" +> sources = ["dotfiles"] +> EOF ``` ```scrut -$ seed_local_vars second && phora sync 2>&1 | normalize +$ printf 'version = 1\n[vars]\nemail = "bob@desktop"\n' > phora.local.toml && phora sync sync complete ``` ```scrut -$ cat "$PWD/target-home/editor/motd" -hello second! +$ cat home-config/git/config +email = bob@desktop +name = Base ``` -No new commit: the lock's commit is unchanged across the var-driven redeploy, and -`verify` is clean on the re-rendered output. - -```scrut -$ test "$(grep '^commit ' phora.lock)" = "$COMMIT_BEFORE" && echo same-commit -same-commit -``` +They rendered differently — but the lock hashes *source* bytes, not rendered +output, so both machines' locks are byte-identical. The integrity check stays +machine-independent: ```scrut -$ phora verify 2>&1 | normalize -all verified +$ diff "$ROOT/laptop/phora.lock" "$ROOT/desktop/phora.lock" && echo identical +identical ``` -## rebuild-registry reconciles against merged vars +## Editing a value re-renders, without churning the lock -With a `phora.local.toml` `[vars]` overlay present, `rebuild-registry` reconciles -against the merged base+local vars — the same vars sync rendered with — so the -templated artifact is reconstructed clean, not `modified`. +Back on the laptop, Alice updates her email var. Editing the var — no source +commit moved — re-renders on the next sync: ```scrut -$ cd "$ROOT" && mkdir -p s5 && cd s5 && isolate_state && seed_config_with_vars "$(make_templated_source proj)" && seed_local_vars overlaid && phora sync 2>&1 | normalize +$ cd "$ROOT/laptop" && export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && COMMIT_BEFORE="$(grep '^commit' phora.lock)" && printf 'version = 1\n[vars]\nemail = "alice@newjob"\n' > phora.local.toml && phora sync sync complete ``` ```scrut -$ cat "$PWD/target-home/editor/motd" -hello overlaid! +$ cat home-config/git/config +email = alice@newjob +name = Base ``` -```scrut -$ phora rebuild-registry 2>&1 | normalize -reconstructed 1 -``` - -```scrut -$ phora list 2>&1 | normalize -home: - dotfiles/editor ✓ clean -``` - -## preview --files shows the deployed name and annotates templated files (M004) - -`phora preview --files` lists the RENDERED deployed name of a templated file -(`motd`, suffix stripped) annotated `(templated)`, while a plain sibling keeps its -name with no annotation. The source name `motd.tmpl` never appears. - -```scrut -$ cd "$ROOT" && mkdir -p s6 && cd s6 && isolate_state && seed_config_with_vars "$(make_templated_source proj)" && phora sync 2>&1 | normalize -sync complete -``` +The lock did not move — no new commit, just a re-render — and `verify` is clean +on the new output: ```scrut -$ phora preview --files 2>&1 | normalize | grep -E 'motd|static' - motd (templated) - static.txt +$ test "$(grep '^commit' phora.lock)" = "$COMMIT_BEFORE" && echo lock-unchanged +lock-unchanged ``` -The `--json` form carries the deployed path and a per-file `templated` flag. - ```scrut -$ phora preview --files --json 2>&1 | grep -E '"path"|"templated"' | sed -e 's/^ *//' | grep -E 'motd|true|false|static' -"path": "motd", -"templated": true -"path": "static.txt", -"templated": false +$ phora verify +all verified ``` diff --git a/tests/scrut/transitive.md b/tests/scrut/transitive.md new file mode 100644 index 0000000..bed8cf4 --- /dev/null +++ b/tests/scrut/transitive.md @@ -0,0 +1,227 @@ +# A dependency that carries its own + +A source can be more than a flat bag of artifacts — it can be a phora project in +its own right, shipping a `phora.toml` that pulls in further sources and lays them +out. This is a *transitive dependency*: mark it `transitive = true`, import it into +a target, and phora composes its targets into your workspace. + +The worked case mirrors a real pair of repos. +[`srnnkls/tropos`](https://github.com/srnnkls/tropos) is a toolkit of agent-harness +artifacts; its `loqui` skill expects language guidelines vendored underneath it at +`skills/loqui/reference/loqui/`, and those guidelines live in a separate repo, +[`srnnkls/loqui`](https://github.com/srnnkls/loqui). tropos declares loqui as one of +its own sources, so importing tropos composes loqui straight into the skill. + +This suite stays offline and hermetic. The two upstreams are built as local git +repos, and the dependency's inner remote — which phora resolves as a remote URL, +not a local filesystem path — is redirected to the local repo with git's +`insteadOf`. `HOME` points at scrut's per-document tempdir so that redirect is the +only project-level git config active; phora's own cache and state are pinned +with a `[paths]` table in `phora.toml`, so the run needs no `XDG_*` juggling and +nothing lands outside the tempdir. No commit hashes are asserted — a freshly built +local repo has a machine-dependent commit. + +## Start + +`HOME` is the tempdir, and a single `.gitconfig` carries both the commit identity +and the two `insteadOf` redirects that point the forge URLs at the local repos: + +```scrut +$ export HOME="$PWD" && ROOT="$PWD" && echo ready +ready +``` + +```scrut +$ cat > "$HOME/.gitconfig" < [init] +> defaultBranch = main +> [user] +> email = a@b.c +> name = t +> [commit] +> gpgsign = false +> [url "$ROOT/tropos"] +> insteadOf = https://github.com/srnnkls/tropos.git +> [url "$ROOT/loqui"] +> insteadOf = https://github.com/srnnkls/loqui.git +> EOF +``` + +The loqui repo is the leaf: two language guideline trees and a shared resource. + +```scrut +$ mkdir -p "$ROOT/loqui/languages/go" "$ROOT/loqui/languages/python" "$ROOT/loqui/resources" && printf 'go style\n' > "$ROOT/loqui/languages/go/style.md" && printf 'python style\n' > "$ROOT/loqui/languages/python/style.md" && printf 'shared\n' > "$ROOT/loqui/resources/shared.md" && printf 'version = 1\n' > "$ROOT/loqui/phora.toml" && git -C "$ROOT/loqui" init -q && git -C "$ROOT/loqui" add -A && git -C "$ROOT/loqui" commit -q -m loqui && echo built +built +``` + +The tropos repo is the dependency. Its `phora.toml` declares loqui as a source and +lands it under the skill that needs it — and carries an `on_change` hook to +post-process what was composed. + +```scrut +$ mkdir -p "$ROOT/tropos" && cat > "$ROOT/tropos/phora.toml" <<'EOF' +> version = 1 +> +> [sources.loqui] +> host = "github" +> repo = "srnnkls/loqui" +> +> [targets.loqui] +> path = "skills/loqui/reference/loqui" +> sources = ["loqui"] +> +> [targets.loqui.hooks] +> on_change = "echo composed >> \"$HOME/loqui-built.log\"" +> EOF +``` + +```scrut +$ git -C "$ROOT/tropos" init -q && git -C "$ROOT/tropos" add -A && git -C "$ROOT/tropos" commit -q -m tropos && echo built +built +``` + +## Import and compose + +The consumer pins phora's cache and state with `[paths]`, declares tropos as a +transitive source, and imports it into a target. The consumer config never mentions +loqui — that edge lives inside tropos. + +```scrut +$ mkdir -p "$ROOT/proj" && cd "$ROOT/proj" && cat > phora.toml <<'EOF' +> version = 1 +> +> [paths] +> cache = ".phora/cache" +> state = ".phora/state" +> +> [sources.tropos] +> host = "github" +> repo = "srnnkls/tropos" +> branch = "main" +> transitive = true +> +> [targets.claude] +> path = "claude" +> imports = ["tropos"] +> EOF +``` + +The first sync composes the dependency. Its hook is *stripped* — discovered but not +run, since a dependency's hooks need explicit approval — so the sync reports it and +still completes (a non-interactive run stays green; the files are deployed): + +```scrut +$ phora sync 2>&1 +phora: 1 untrusted transitive hook(s) were stripped and not run — affected artifacts are deployed but NOT post-processed and may be incomplete +phora: run `phora trust ` to inspect and approve 1 hook(s) +sync complete +``` + +loqui's `languages/` and `resources/` trees landed at the path the skill expects — +under the dependency's relative path, composed beneath the import anchor: + +```scrut +$ test -f claude/skills/loqui/reference/loqui/languages/go/style.md && test -f claude/skills/loqui/reference/loqui/resources/shared.md && echo composed +composed +``` + +The hook never ran, so its log was never written: + +```scrut +$ test ! -e "$HOME/loqui-built.log" && echo "hook stripped" +hook stripped +``` + +The `[paths]` table did its job: the git mirrors and the registry live under the +project, and nothing was written to the default `~/.cache` or `~/.local/state`: + +```scrut +$ test -d .phora/cache/git && test -d .phora/state/projects && test ! -e "$HOME/.cache/phora" && test ! -e "$HOME/.local/state/phora" && echo isolated +isolated +``` + +`phora verify` re-hashes the composed copy against the registry. The bytes match, but +the dependency's hook was *stripped* rather than run, so the composed artifact may be +incomplete — verify says so and exits non-zero until the hook is trusted (the +machine-dependent hook id is matched as a glob): + +```scrut +$ phora verify 2>&1 +tropos: untrusted stripped hook * — deployed but not post-processed, artifact may be incomplete; run `phora trust tropos` to approve (glob) +[1] +``` + +## Inspecting an untrusted hook + +`phora trust --list` shows each discovered hook — its command, the +environment it would inherit, and the dependency surface around it — without approving +anything. Approval itself is interactive and records the hook, pinned to its command +and commit, in your `phora.lock`; off a terminal the command only lists. + +```scrut +$ phora trust tropos --list 2>&1 | grep -E 'command:|env:|note:' + command: echo composed >> "$HOME/loqui-built.log" + env: PHORA_TARGET= + note: the hook inherits the FULL process environment, not only the PHORA_* variables +``` + +The surface depends on history. With no prior approval — a first trust — `--list` +lists the dependency-repo-relative files the hook will run against at the candidate +commit, resolved offline from the mirror; once you have trusted the hook at an earlier +commit it renders the file-level diff between that commit and the candidate instead. +This is a first trust, so the listing is the composed surface (the candidate commit is +folded to ``): + +```scrut +$ phora trust tropos --list 2>&1 | sed -E 's/at [0-9a-f]{7,}:/at :/' | grep -A4 'composed files' + composed files at : + languages/go/style.md + languages/python/style.md + phora.toml + resources/shared.md +``` + +## Reading the dependency tree with `--show` + +`phora trust --show ` prints a dependency file — or lists a directory — +at the pinned candidate commit, offline, so you can read a hook's surroundings before +approving it. A UTF-8 file prints verbatim: + +```scrut +$ phora trust tropos --show languages/go/style.md 2>&1 +go style +``` + +A directory lists its direct entries ls-style, with subdirectories slash-suffixed: + +```scrut +$ phora trust tropos --show languages 2>&1 +go/ +python/ +``` + +An absent path errors, naming the path and the commit it looked at: + +```scrut +$ phora trust tropos --show no/such/path 2>&1 +error: source error: no/such/path is absent at * in `tropos` (glob) +[1] +``` + +And `--show` refuses to guess without a source name: + +```scrut +$ phora trust --show languages/go/style.md 2>&1 +error: config error: `phora trust --show` needs a source name +[1] +``` + +## Skipping dependency hooks + +`--no-transitive-hooks` composes the dependency but suppresses its hooks entirely — +no strip, no notice, just a clean sync: + +```scrut +$ phora sync --no-transitive-hooks 2>&1 +sync complete +``` diff --git a/tests/scrut/versions.md b/tests/scrut/versions.md new file mode 100644 index 0000000..9221639 --- /dev/null +++ b/tests/scrut/versions.md @@ -0,0 +1,161 @@ +# One source, two versions + +fzf's shell integration — completions and key bindings — changes occasionally +across releases, and reviewing the diff before deploying beats finding out after. +This suite holds fzf v0.55.0 and v0.56.0 side by side in one target, from one +mirror, then promotes the newer one and lets `--prune` clean up. + +State is hermetic — the first command points `HOME` and the XDG cache/state +roots at scrut's per-document tempdir; the clone of github.com/junegunn/fzf is +real. Both refs are release tags, so every hash below is stable. + +## Start + +```scrut +$ export HOME="$PWD" XDG_CACHE_HOME="$PWD/cache" XDG_STATE_HOME="$PWD/state" && mkdir -p cache state && echo ready +ready +``` + +The source pins v0.55.0 and selects the `shell` directory. The target binds it +twice: `stable` inherits the source's tag, `canary` overrides it. Two bindings +of one source need distinct identities — that is what the `[targets..sources]` +table keys provide — and the `by-source` layout uses those identities as +directory labels, so the two versions cannot collide: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.fzf] +> git = "https://github.com/junegunn/fzf.git" +> tag = "v0.55.0" +> include = ["shell"] +> +> [targets.shell] +> path = "shell-integration" +> layout = "by-source" +> +> [targets.shell.sources] +> stable = { source = "fzf" } +> canary = { source = "fzf", tag = "v0.56.0" } +> EOF +``` + +## Sync + +One fetch, one mirror, two commits resolved out of it, two projections: + +```scrut +$ phora sync +sync complete +``` + +```scrut +$ phora list +shell: + canary/shell ✓ clean + stable/shell ✓ clean +``` + +```scrut +$ phora preview +shell + canary@ff168774 shell/ -> shell-integration/canary/shell + stable@fc693080 shell/ -> shell-integration/stable/shell +``` + +Two different commits, two different content digests — same source, same +mirror: + +```scrut +$ phora where +Artifact: canary/shell (commit ff168774, digest blake3:2de40a4c3e2bd2e47e08233f7b66e2562007bb2a4d023225b24631a1ab37b698) + - shell +Artifact: stable/shell (commit fc693080, digest blake3:39cdc3c10ad1b93ac21c9459a1c8296be2fe8b230427faf3907c99dbb9bfc4ab) + - shell +``` + +With both versions deployed, the difference is a plain `diff` between two +directories on disk. Between these two tags, the bash completion changed: + +```scrut +$ diff -q shell-integration/stable/shell/completion.bash shell-integration/canary/shell/completion.bash +Files shell-integration/stable/shell/completion.bash and shell-integration/canary/shell/completion.bash differ +[1] +``` + +The lock shows how the splitting works: one entry per distinct ref, and the +discriminator appears only on the override — a config with no binding refs +locks exactly as it would have before per-target versions existed: + +```scrut +$ grep -c '\[\[sources\]\]' phora.lock +2 +``` + +```scrut +$ grep -e 'resolved' -e '^ref' phora.lock +resolved = "v0.55.0" +resolved = "v0.56.0" +ref = "tag:v0.56.0" +``` + +## Promote + +The canary held up. Move the source's tag forward and drop back to a single +bare binding: + +```scrut +$ cat > phora.toml <<'EOF' +> version = 1 +> +> [sources.fzf] +> git = "https://github.com/junegunn/fzf.git" +> tag = "v0.56.0" +> include = ["shell"] +> +> [targets.shell] +> path = "shell-integration" +> sources = ["fzf"] +> layout = "by-source" +> EOF +``` + +The mirror already holds the tag, so no network is needed; `--prune` also +removes the two artifacts the config no longer names: + +```scrut +$ phora sync --prune 2>&1 +phora: pruning orphaned canary:shell +phora: pruning orphaned stable:shell +sync complete +``` + +```scrut +$ phora list +shell: + fzf/shell ✓ clean +``` + +```scrut +$ phora where +Artifact: fzf/shell (commit ff168774, digest blake3:2de40a4c3e2bd2e47e08233f7b66e2562007bb2a4d023225b24631a1ab37b698) + - shell +``` + +The lock collapses back to one entry, no discriminator: + +```scrut +$ grep -c '\[\[sources\]\]' phora.lock +1 +``` + +Note: prune removes the files it tracked, but the now-empty `stable/` and +`canary/` identity directories stay behind — `rmdir` them if needed: + +```scrut +$ find shell-integration -mindepth 1 -maxdepth 1 | LC_ALL=C sort +shell-integration/canary +shell-integration/fzf +shell-integration/stable +```