From 2d010fc65e38fe4d0b6b50fea4540e0da331bd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Wed, 24 Jun 2026 16:15:57 +0200 Subject: [PATCH 1/6] docs: GUIDE.md + use-case scrut walkthroughs on the offer/take model Stacked on the selection-model-redesign branch (PR #44). Adds GUIDE.md, the long-form companion to the README, written to the source-offer / target-take model: source owns the offer (`root` + gitignore `include`/`exclude`), binding owns the `take` (literal / glob / `{ src = dest }` rename), artifacts are path-identified leaves, and a `collapse` opt folds a wholly-taken directory into one artifact. Adds use-case scrut walkthroughs that drive the shipped binary against real upstreams and double as runnable, CI-verified usage docs: showcase, drift, versions, release-assets, mapped (take renames), hooks, templates, and transitive. These sit alongside the branch's hermetic suites (selection, query, manage, lifecycle); the README testing section indexes both. --- GUIDE.md | 1413 +++++++++++++++++++++++++++++++++ README.md | 18 +- tests/scrut/drift.md | 195 +++++ tests/scrut/hooks.md | 270 ++++--- tests/scrut/mapped.md | 274 +++++++ tests/scrut/release-assets.md | 130 +++ tests/scrut/showcase.md | 280 ++++--- tests/scrut/templates.md | 260 +++--- tests/scrut/transitive.md | 173 ++++ tests/scrut/versions.md | 164 ++++ 10 files changed, 2801 insertions(+), 376 deletions(-) create mode 100644 GUIDE.md create mode 100644 tests/scrut/drift.md create mode 100644 tests/scrut/mapped.md create mode 100644 tests/scrut/release-assets.md create mode 100644 tests/scrut/transitive.md create mode 100644 tests/scrut/versions.md diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..f31b649 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,1413 @@ +# The phora guide + +This is the long-form companion to the [README](README.md). The README is the +map — 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. If you want to understand why +phora did what it did — or what to do when it didn't — you're in the right place. + +## 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) + - [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 +prove, later, that nothing drifted. + +The vocabulary splits cleanly 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 one idea worth holding onto: 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 me/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/me/dotfiles.git +repo = "me/dotfiles" # forge owner/repo +branch = "main" + +[targets.nvim] +path = "~/.config/nvim" +layout = "flat" + +[targets.nvim.sources] +dotfiles = { take = ["nvim/**"] } # this target takes the repo's nvim/ 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/me/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` (`nvim/**`) against the source's offer, and + materialized the taken leaves into `~/.config/nvim` using the `flat` layout — + collapsing the wholly-taken `nvim/` 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 the payoff for all the bookkeeping: it is 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 drops cleanly into a pre-commit hook or CI. + +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, and it is worth internalizing early: 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.tool] +git = "https://github.com/me/tool.git" # or ssh://…, or git@host:owner/repo +tag = "v1.4.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.scratch] +path = "~/dev/scratch" # 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. + +A useful property falls out of how the store is keyed: 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/` that release tarballs love — 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. This is a stronger + story than `curl | tar`: nothing touches your disk tree until the bytes check + out. +- Determinism: identical bytes always import to the identical commit, so an + unchanged URL is a true 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 = "me/dotfiles" +root = "modules" +include = ["editor", "shell/**"] +exclude = ["**/*.bak", "**/.DS_Store"] +``` + +The offer is the *artifact set itself*: each offered leaf is an artifact, identified +by its full offered path (`editor/init.lua`, not just `editor`). 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 path/in/the/source +``` + +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.nvim] +path = "~/.config/nvim" +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/nvim`, `~/.config/git`, 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 `"nvim/init.lua"`, kept verbatim; +- a *gitignore glob* — any entry with `*`, `?`, `[`, `]`, or a trailing `/`, like + `"nvim/**"`, 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.neovim] +path = "~/.config/nvim" + +[targets.neovim.sources] +nvim = { source = "dotfiles", take = ["nvim/**"] } +``` + +Here the `nvim` binding takes just the `nvim/` 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.editors] +path = "~/.config" +layout = "by-source" # ~/.config/nvim/… and ~/.config/helix/… + +[targets.editors.sources] +nvim = { source = "dotfiles", take = ["nvim/**"] } +helix = { source = "dotfiles", take = ["helix/**"] } +``` + +One source, one mirror — but two bindings taking two subtrees into the same target, +each labelled by its identity. This is the headline thing per-binding `take` buys +you: 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 locks byte-for-byte as +it did before this existed (see [the lock](#the-lock-and-content-identity)). + +### Binding scope is rejected + +`root`, `include`, `exclude`, and `map` are *not* binding keys — they were, before +the offer/take split, and setting any of them on a `[targets..sources]` entry is +now 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. This is pre-alpha — there is no migration shim, the error just 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 neovim # bare binding, takes the whole offer +phora bind dotfiles --to neovim --as nvim --take nvim/** # a taken slice, identity `nvim` +phora unbind nvim --from neovim # 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 (`nvim`), 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 an `editor` artifact 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.editors] +path = "~/.config" +sources = ["work-dotfiles", "personal-dotfiles"] +layout = "by-source" # ~/.config/work-dotfiles/editor, ~/.config/personal-dotfiles/editor +``` + +## 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 = [{ "AGENTS.md" = "AGENTS.md" }] } +claude = { source = "dotfiles", take = [{ "AGENTS.md" = "CLAUDE.md" }] } +codex = { source = "dotfiles", take = [{ "AGENTS.md" = "codex.md" }] } +``` + +One `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.editors.sources] +# force a per-leaf snapshot even though the whole tree is taken: +nvim = { source = "dotfiles", take = ["nvim/**"], collapse = false } +``` + +`phora preview` marks a collapsed directory with a trailing slash (`nvim/`), 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` is the interesting choice: it 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 + +Sometimes deploying the files is only half of it — a font cache wants rebuilding, a +plugin manager wants a sync, an index wants refreshing. 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.neovim.hooks] +on_change = "nvim --headless +'Lazy! sync' +qa" + +[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 neovim#nvim --headless +'Lazy! sync' +qa#sh -c [on_change] `nvim --headless +'Lazy! sync' +qa` 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] +email = "me@example.com" + +# git/config.tmpl, deployed as git/config: +# email = {{ email }} +``` + +```toml +# phora.local.toml — never committed +[vars] +email = "me@this-laptop.example" +``` + +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 (`config (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] +git = "/home/me/dev/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 + earlier hard rejection of committed link mode is gone: 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 + +This is the sharp edge. 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 never trusts it implicitly. +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 which dependency files changed since you last trusted it +phora trust tropos # the same, then prompt [y/N] per hook; a yes is recorded +``` + +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. + +### 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, the-lock-is-the-law +mode for CI and reproducible checkouts. 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 +required reading to use phora, but it is what lets you reason about edge cases — and +it is the honest answer to "what is this thing actually doing to my 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. That is the trick + behind "https and ssh share a mirror": both normalize to the same string, so both + hash to the same key. +- 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. + +### 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 is the security property: + +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 love to 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 malicious archive cannot escape; 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 collide by smearing a path into the next file's content. With it, the digest +is an honest 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 as it +did before per-target versions existed. + +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 the whole trick: + +- 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 fall out of git for free. + +### 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). + +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 byte-identical +to the old serial path, so nothing about a lock or a registry record depends on how +many threads ran. + +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 you 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 as it did before +any of this existed. 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 + (`nvim/`), 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]`, and stripping its untrusted hook. +- 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..ff0fdd1 100644 --- a/README.md +++ b/README.md @@ -916,6 +916,18 @@ 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 whose assertions CI keeps honest. 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 walks the link-mode editing 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 behind phora's back and walks the ways out, +[`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) composes a +dependency that carries its own. diff --git a/tests/scrut/drift.md b/tests/scrut/drift.md new file mode 100644 index 0000000..f33b21f --- /dev/null +++ b/tests/scrut/drift.md @@ -0,0 +1,195 @@ +# When files drift + +A deployed file is just a file. Anyone — you at midnight, a colleague, a +well-meaning script — can edit it in place, and most tools will never tell you. +phora records what it deployed precisely so that this is detectable later. This +suite deploys one skill from Anthropic's public skills repository, edits it +behind phora's back, and walks the three honest 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 +``` + +## 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 knows where the files came from — it just annotates the +divorce: + +```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..9ab89aa 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 often only half the job — 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 — the escape hatch 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..c71050f --- /dev/null +++ b/tests/scrut/mapped.md @@ -0,0 +1,274 @@ +# 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 is its own artifact rather than an alias that could be mistaken for +the original: + +```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..f58b12a --- /dev/null +++ b/tests/scrut/release-assets.md @@ -0,0 +1,130 @@ +# Release assets, without curl | tar + +bat ships its shell completions inside the release tarball. The usual move is +`curl | tar` into somewhere on your fpath plus a mental note about which version +that was; the mental note does not survive the week. 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 +love, import the tree, project the selection. The order is the security +property: nothing touches the target until the bytes check out. + +```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 even notice — it honors the lock, the lock still +matches, and nothing is re-downloaded. That is the no-op model doing its job, +not a gap: + +```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..2441259 100644 --- a/tests/scrut/showcase.md +++ b/tests/scrut/showcase.md @@ -1,191 +1,239 @@ -# 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 this document cannot drift from how phora actually behaves. 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. This is the +difference between "the files are there" and "the files are exactly what phora +put there" — a sentence that matters more than usual 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 hashing them would be meaningless, and the registry says so +instead of pretending: ```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 +That is the deal: live edits in exchange for the content guarantee. 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..3bab796 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 the laptop and the box under the desk. +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. This is the one inherently *local* story in the suite, so +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 changes jobs. 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..2985698 --- /dev/null +++ b/tests/scrut/transitive.md @@ -0,0 +1,173 @@ +# 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 against your host +registry, and which must be a real remote, never a local 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 git config in play; 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, the kind a +dependency author might use to post-process what was just 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, because phora never trusts a dependency's hooks implicitly — so the sync says so +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 exactly where the skill looks for +them — 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: + +```scrut +$ phora verify +all verified +``` + +## Inspecting an untrusted hook + +`phora trust --list` shows each discovered hook — its command, the +environment it would inherit, and (once a prior approval exists) the dependency files +that changed since you last trusted 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 +``` + +## 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..f5ec265 --- /dev/null +++ b/tests/scrut/versions.md @@ -0,0 +1,164 @@ +# One source, two versions + +fzf's shell integration — completions and key bindings — is the kind of thing +you deploy once and stop thinking about. Until a new release changes it, and +you would rather read the difference than discover it. 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 +``` + +And the point of the exercise — the difference is now an ordinary `diff` +between two directories on disk. Between these two tags it is the bash +completion that moved: + +```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 +``` + +One small honesty: prune removes the files it tracked, but the now-empty +`stable/` and `canary/` identity directories stay behind — `rmdir` them if they +bother you: + +```scrut +$ find shell-integration -mindepth 1 -maxdepth 1 | LC_ALL=C sort +shell-integration/canary +shell-integration/fzf +shell-integration/stable +``` From d9371807adbd0e5b491d3901e75fad8d73362dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Wed, 24 Jun 2026 16:33:58 +0200 Subject: [PATCH 2/6] docs(guide): add object-store model subsection (no working tree) --- GUIDE.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/GUIDE.md b/GUIDE.md index f31b649..44410af 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -38,6 +38,7 @@ phora did what it did — or what to do when it didn't — you're in the right p - [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) @@ -997,6 +998,47 @@ perspective there is no difference between the two — both are just commits wit 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 underwrites 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 is not an implementation +detail behind the model; it is the model. + ### Fetching a git source The git backend uses `gix` (gitoxide) directly, no shelling out. On the first From 07aef21e8266a57c159ba71d03f135dbec10c664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Wed, 24 Jun 2026 23:34:47 +0200 Subject: [PATCH 3/6] docs: tone down flourishes and unsupported claims across guide + scrut prose Sweep flagged by a review pass. Replaces editorializing and overclaims with plain, mechanism-grounded statements, e.g.: - "precise enough to prove nothing drifted" -> "detect whether anything drifted" - "stronger story than curl|tar: nothing touches your disk" -> "not extracted until the digest check passes" - "the order is the security property" -> "the order matters: digest before extraction" - "a malicious archive cannot escape" -> "a crafted archive path cannot escape the extraction root" - drops scene-setting ("you at midnight", "the box under the desk", "Alice changes jobs"), strained metaphors ("annotates the divorce"), and anthropomorphisms ("release tarballs love to 302", "the registry says so instead of pretending"). Prose-only; all 12 scrut suites still pass against the new-model binary. --- GUIDE.md | 51 +++++++++++++++++------------------ README.md | 10 +++---- tests/scrut/drift.md | 16 +++++------ tests/scrut/hooks.md | 6 ++--- tests/scrut/mapped.md | 3 +-- tests/scrut/release-assets.md | 18 ++++++------- tests/scrut/showcase.md | 17 +++++------- tests/scrut/templates.md | 16 +++++------ tests/scrut/transitive.md | 22 +++++++-------- tests/scrut/versions.md | 19 ++++++------- 10 files changed, 84 insertions(+), 94 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 44410af..76a3acb 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -58,7 +58,7 @@ phora did what it did — or what to do when it didn't — you're in the right p 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 -prove, later, that nothing drifted. +detect, later, whether anything drifted. The vocabulary splits cleanly 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 @@ -108,8 +108,8 @@ way: 3. Project each binding's taken artifacts into their targets. 4. Record what was deployed, and verify it on demand. -The one idea worth holding onto: the store is git, and *everything becomes a git -tree* — a cloned repo or a downloaded tarball alike. That is why a URL source +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. @@ -172,8 +172,8 @@ phora list # per-target status: which artifacts are deployed, and their s phora verify # re-hash every deployed file against the record; exit 0 if all match ``` -`phora verify` is the payoff for all the bookkeeping: it is the difference between -"the files are there" and "the files are exactly what phora put there." It exits +`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 drops cleanly into a pre-commit hook or CI. Later, to pick up upstream changes: @@ -267,9 +267,8 @@ exactly like a git source. A few things are worth knowing up front: `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. This is a stronger - story than `curl | tar`: nothing touches your disk tree until the bytes check - out. + 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 true no-op on the next sync, and a changed one advances the lock. The [internals](#why-a-url-import-is-deterministic) cover how. @@ -658,9 +657,8 @@ foreign file sitting where an artifact wants to land, it prompts on a TTY — ``` — and on a non-interactive run it skips such files unless you passed `--force`. -`eject` is the interesting choice: it tells phora to stop managing that artifact -but leave its files in place (see `phora eject` / `phora uneject` for doing this -deliberately). +`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 @@ -669,8 +667,8 @@ advances — for a URL source, only if the downloaded content actually changed. ## Hooks: running commands after a sync -Sometimes deploying the files is only half of it — a font cache wants rebuilding, a -plugin manager wants a sync, an index wants refreshing. A hook is a shell command +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. @@ -916,9 +914,9 @@ your local filesystem. ### Trusting a dependency's hooks -This is the sharp edge. 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 never trusts it implicitly. +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: @@ -946,15 +944,15 @@ every approval for a dependency. `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, the-lock-is-the-law -mode for CI and reproducible checkouts. As with any other field, a `phora.local.toml` +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 -required reading to use phora, but it is what lets you reason about edge cases — and -it is the honest answer to "what is this thing actually doing to my disk?" +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 @@ -1054,11 +1052,12 @@ 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 is the security property: +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 love to 302 to a CDN) but strips auth headers across them, and + (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. @@ -1070,7 +1069,7 @@ The URL backend runs four steps, and the order is the security property: 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 malicious archive cannot escape; a single common + 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 @@ -1117,7 +1116,7 @@ the artifact as it goes — framing each entry (its relative path length and byt 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 collide by smearing a path into the next file's content. With it, the digest -is an honest fingerprint of the projected tree. +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 @@ -1243,7 +1242,7 @@ 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 fall out of git for free. +the versioning come from git's object store rather than a custom layer. ### Verification diff --git a/README.md b/README.md index ff0fdd1..a0a1670 100644 --- a/README.md +++ b/README.md @@ -916,18 +916,18 @@ 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 whose assertions CI keeps honest. The hermetic suites +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 walks the link-mode editing loop, +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 behind phora's back and walks the ways out, -[`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 +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. diff --git a/tests/scrut/drift.md b/tests/scrut/drift.md index f33b21f..f849f46 100644 --- a/tests/scrut/drift.md +++ b/tests/scrut/drift.md @@ -1,11 +1,11 @@ # When files drift -A deployed file is just a file. Anyone — you at midnight, a colleague, a -well-meaning script — can edit it in place, and most tools will never tell you. -phora records what it deployed precisely so that this is detectable later. This -suite deploys one skill from Anthropic's public skills repository, edits it -behind phora's back, and walks the three honest ways out: restore it, adopt it -upstream, or eject it and own it. +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 @@ -138,8 +138,8 @@ $ grep -c 'Local policy' claude-skills/skill-creator/SKILL.md 1 ``` -The registry still knows where the files came from — it just annotates the -divorce: +The registry still records where the files came from — it just marks them as +ejected: ```scrut $ phora where diff --git a/tests/scrut/hooks.md b/tests/scrut/hooks.md index 9ab89aa..7ed3ce0 100644 --- a/tests/scrut/hooks.md +++ b/tests/scrut/hooks.md @@ -1,6 +1,6 @@ # Running something after a sync -Deploying files is often only half the job — you cache-rebuild, reload a daemon, +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 @@ -83,8 +83,8 @@ skill-creator ## post_sync runs regardless -Add a global `post_sync` hook. It runs after *every* sync — the escape hatch for -work that should happen whether or not content moved: +Add a global `post_sync` hook. It runs after *every* sync, for work that should +happen whether or not content moved: ```scrut $ cat > phora.toml <<'EOF' diff --git a/tests/scrut/mapped.md b/tests/scrut/mapped.md index c71050f..e72fccb 100644 --- a/tests/scrut/mapped.md +++ b/tests/scrut/mapped.md @@ -66,8 +66,7 @@ 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 is its own artifact rather than an alias that could be mistaken for -the original: +so a rename gets its own artifact identity rather than sharing its source's: ```scrut $ phora where diff --git a/tests/scrut/release-assets.md b/tests/scrut/release-assets.md index f58b12a..f17cc40 100644 --- a/tests/scrut/release-assets.md +++ b/tests/scrut/release-assets.md @@ -1,10 +1,9 @@ # Release assets, without curl | tar -bat ships its shell completions inside the release tarball. The usual move is -`curl | tar` into somewhere on your fpath plus a mental note about which version -that was; the mental note does not survive the week. 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. +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 @@ -40,8 +39,8 @@ $ cat > phora.toml <<'EOF' Download, check the digest against the raw bytes, extract (validating every entry path), strip the `bat-v0.24.0-…/` wrapper directory that release tarballs -love, import the tree, project the selection. The order is the security -property: nothing touches the target until the bytes check out. +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 @@ -103,9 +102,8 @@ $ cat > phora.toml <<'EOF' > EOF ``` -A plain `sync` does not even notice — it honors the lock, the lock still -matches, and nothing is re-downloaded. That is the no-op model doing its job, -not a gap: +A plain `sync` does not notice — it honors the lock, the lock still matches, and +nothing is re-downloaded: ```scrut $ phora sync diff --git a/tests/scrut/showcase.md b/tests/scrut/showcase.md index 2441259..5d66fbd 100644 --- a/tests/scrut/showcase.md +++ b/tests/scrut/showcase.md @@ -8,7 +8,7 @@ then layers a local working tree on top for the editing loop, and takes it off again. Every command below is the shipped binary and every block asserts its exact -output, so this document cannot drift from how phora actually behaves. State is +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. @@ -151,10 +151,9 @@ 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/`). -`verify` re-hashes every deployed file against the registry. This is the -difference between "the files are there" and "the files are exactly what phora -put there" — a sentence that matters more than usual when the files are prompts -an agent will follow. +`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 verify @@ -205,8 +204,7 @@ $ readlink claude-skills/mcp-builder | sed "s#/private$PWD##g;s#$PWD# phora.local.toml && phora sync diff --git a/tests/scrut/transitive.md b/tests/scrut/transitive.md index 2985698..982dc51 100644 --- a/tests/scrut/transitive.md +++ b/tests/scrut/transitive.md @@ -13,10 +13,10 @@ artifacts; its `loqui` skill expects language guidelines vendored underneath it 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 against your host -registry, and which must be a real remote, never a local 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 git config in play; phora's own cache and state are pinned +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. @@ -55,8 +55,8 @@ 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, the kind a -dependency author might use to post-process what was just composed. +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' @@ -106,9 +106,9 @@ $ mkdir -p "$ROOT/proj" && cd "$ROOT/proj" && cat > phora.toml <<'EOF' > EOF ``` -The first sync composes the dependency. Its hook is *stripped* — discovered, but not -run, because phora never trusts a dependency's hooks implicitly — so the sync says so -and still completes (a non-interactive run stays green; the files are deployed): +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 @@ -117,8 +117,8 @@ phora: run `phora trust ` to inspect and approve 1 hook(s) sync complete ``` -loqui's `languages/` and `resources/` trees landed exactly where the skill looks for -them — under the dependency's relative path, composed beneath the import anchor: +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 diff --git a/tests/scrut/versions.md b/tests/scrut/versions.md index f5ec265..9221639 100644 --- a/tests/scrut/versions.md +++ b/tests/scrut/versions.md @@ -1,10 +1,9 @@ # One source, two versions -fzf's shell integration — completions and key bindings — is the kind of thing -you deploy once and stop thinking about. Until a new release changes it, and -you would rather read the difference than discover it. 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. +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 @@ -76,9 +75,8 @@ Artifact: stable/shell (commit fc693080, digest blake3:39cdc3c10ad1b93ac21c9459a - shell ``` -And the point of the exercise — the difference is now an ordinary `diff` -between two directories on disk. Between these two tags it is the bash -completion that moved: +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 @@ -152,9 +150,8 @@ $ grep -c '\[\[sources\]\]' phora.lock 1 ``` -One small honesty: prune removes the files it tracked, but the now-empty -`stable/` and `canary/` identity directories stay behind — `rmdir` them if they -bother you: +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 From e9d15ec7704ccbaeabb2c6ba3b73a879f908631e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Thu, 25 Jun 2026 14:11:14 +0200 Subject: [PATCH 4/6] docs: ground examples in the real srnnkls/dotfiles layout Replace invented nvim/helix/vscode/editor/modules examples with the actual ~/dotfiles tree: srnnkls/dotfiles rooted at .config (zellij, ripgrep, codex, anax, mise), .shared/AGENTS.md fanned out to CLAUDE.md/codex.md, ~/projects/loqui for link mode, mise GOBIN for templating, mise hooks. Paths verified against the layout kernel and scrut tests. --- GUIDE.md | 137 ++++++++++++++++++++------------------- README.md | 126 +++++++++++++++++------------------ phora.example.toml | 48 +++++++------- phora.local.example.toml | 2 +- 4 files changed, 159 insertions(+), 154 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 76a3acb..795c701 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -118,7 +118,7 @@ are shared code. The fastest start is a single command — `phora add` writes the config for you: ```bash -phora add me/dotfiles # add the source and bind it into [targets.default] (deploys into ".") +phora add srnnkls/dotfiles # add the source and bind it into [targets.default] (deploys into ".") phora sync ``` @@ -137,16 +137,17 @@ is: version = 1 [sources.dotfiles] -host = "github" # resolves to https://github.com/me/dotfiles.git -repo = "me/dotfiles" # forge owner/repo +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.nvim] -path = "~/.config/nvim" +[targets.config] +path = "~/.config" layout = "flat" -[targets.nvim.sources] -dotfiles = { take = ["nvim/**"] } # this target takes the repo's nvim/ subtree +[targets.config.sources] +dotfiles = { take = ["zellij/**"] } # this target takes the repo's zellij/ config subtree ``` Then sync: @@ -158,11 +159,11 @@ 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/me/dotfiles` into a bare repo under the cache root's `git/` +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` (`nvim/**`) against the source's offer, and - materialized the taken leaves into `~/.config/nvim` using the `flat` layout — - collapsing the wholly-taken `nvim/` tree into a single directory artifact. +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: @@ -196,9 +197,9 @@ A git source names a repository and a point in its history. Pin exactly one of You can declare the remote two ways. Literally: ```toml -[sources.tool] -git = "https://github.com/me/tool.git" # or ssh://…, or git@host:owner/repo -tag = "v1.4.0" +[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: @@ -225,8 +226,8 @@ remote = { https = "https://git.company.com/{path}.git", ssh = "git@git.company. A third form names a local checkout directly: ```toml -[sources.scratch] -path = "~/dev/scratch" # a local filesystem path, used verbatim as the remote +[sources.dignity] +path = "~/projects/dignity" # a local filesystem path, used verbatim as the remote branch = "main" ``` @@ -300,14 +301,14 @@ there is no special-casing. ```toml [sources.dotfiles] host = "github" -repo = "me/dotfiles" -root = "modules" -include = ["editor", "shell/**"] -exclude = ["**/*.bak", "**/.DS_Store"] +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 (`editor/init.lua`, not just `editor`). The offer is what +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, @@ -316,7 +317,7 @@ 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 path/in/the/source +phora check-match --source dotfiles zellij/config.kdl ``` It tells you whether a given path passes the include/exclude rules for that @@ -332,8 +333,8 @@ a `path` (where on disk), a `layout` (how artifacts are arranged inside it), and `sources` — the bindings it deploys. ```toml -[targets.nvim] -path = "~/.config/nvim" +[targets.config] +path = "~/.config" layout = "flat" sources = ["dotfiles"] ``` @@ -360,7 +361,7 @@ You can edit the set with `phora bind --to ` and `phora unbind 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/nvim`, `~/.config/git`, and a project tree — and one target can +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)). @@ -388,9 +389,9 @@ A target's `sources` takes one of two forms — never both at once: 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 `"nvim/init.lua"`, kept verbatim; +- a *literal leaf* — a plain offered path like `"zellij/config.kdl"`, kept verbatim; - a *gitignore glob* — any entry with `*`, `?`, `[`, `]`, or a trailing `/`, like - `"nvim/**"`, which expands over the offer set only, never widening it; + `"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`). @@ -400,14 +401,14 @@ the offer, and the diagnostic suggests the nearest offered leaf. A glob that mat nothing only warns. ```toml -[targets.neovim] -path = "~/.config/nvim" +[targets.config] +path = "~/.config" -[targets.neovim.sources] -nvim = { source = "dotfiles", take = ["nvim/**"] } +[targets.config.sources] +zellij = { source = "dotfiles", take = ["zellij/**"] } ``` -Here the `nvim` binding takes just the `nvim/` subtree of `dotfiles` for this target +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. @@ -427,17 +428,18 @@ source can appear in one target more than once under distinct keys, each taking independent slice: ```toml -[targets.editors] -path = "~/.config" -layout = "by-source" # ~/.config/nvim/… and ~/.config/helix/… +[targets.configs] +path = "~/configs" +layout = "by-source" # ~/configs/codex/config.toml and ~/configs/anax/config.toml -[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" }] } ``` -One source, one mirror — but two bindings taking two subtrees into the same target, -each labelled by its identity. This is the headline thing per-binding `take` buys +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 the headline thing per-binding `take` buys you: a target composes slices, not whole sources. ### Per-target versions: one source, many refs @@ -499,9 +501,9 @@ bare. The CLI can edit bindings for you, if you'd rather not touch the table by hand: ```bash -phora bind dotfiles --to neovim # bare binding, takes the whole offer -phora bind dotfiles --to neovim --as nvim --take nvim/** # a taken slice, identity `nvim` -phora unbind nvim --from neovim # remove a binding by its identity +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`, @@ -515,7 +517,7 @@ 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 (`nvim`), not by its source. +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 @@ -541,15 +543,18 @@ table key, defaulting to the source name — not the underlying source: `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 an `editor` artifact would fight over the same destination. That is what +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.editors] -path = "~/.config" -sources = ["work-dotfiles", "personal-dotfiles"] -layout = "by-source" # ~/.config/work-dotfiles/editor, ~/.config/personal-dotfiles/editor +[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 @@ -565,12 +570,12 @@ expect: 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. 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. @@ -627,12 +632,12 @@ binding-scope rejection alongside `take` and `template`. 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 } ``` -`phora preview` marks a collapsed directory with a trailing slash (`nvim/`), so the +`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. @@ -679,8 +684,8 @@ don't count either. The global `[hooks] post_sync` runs after *every* sync, chan or not: ```toml -[targets.neovim.hooks] -on_change = "nvim --headless +'Lazy! sync' +qa" +[targets.config.hooks] +on_change = "mise install" [hooks] post_sync = "git -C ~/.config add -A" @@ -704,7 +709,7 @@ Each hook that ran is reported with its scope and status, so a sync that trigger one reads: ``` -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 ``` @@ -721,16 +726,16 @@ config carries the shape and each machine fills in its own: ```toml # phora.toml — committed [vars] -email = "me@example.com" +gobin = "~/go/bin" -# git/config.tmpl, deployed as git/config: -# email = {{ email }} +# mise/config.toml.tmpl, deployed as mise/config.toml: +# GOBIN = "{{ gobin }}" ``` ```toml # phora.local.toml — never committed [vars] -email = "me@this-laptop.example" +gobin = "~/.local/go/bin" ``` The `.tmpl` suffix is the opt-in; a binding can widen it to arbitrary globs @@ -746,7 +751,7 @@ 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 (`config (templated)`). +name and flags what renders (`mise/config.toml (templated)`). ## The local dev loop: link mode @@ -763,7 +768,7 @@ working tree. Edits show up through the target immediately, no re-sync: ```toml # phora.local.toml — overlays phora.toml, never committed [sources.loqui] -git = "/home/me/dev/loqui" # a local checkout +path = "~/projects/loqui" # a local checkout deploy = "link" ``` @@ -1379,7 +1384,7 @@ Most confusion maps to one question, and each question maps to one command. 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 - (`nvim/`), a rename shows `src -> dest -> destination`, and a `take` glob that + (`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 diff --git a/README.md b/README.md index a0a1670..610e7fd 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 @@ -172,14 +172,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 +213,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 +240,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 +273,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 +285,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 +316,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 +328,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 +351,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 +420,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 +432,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 +454,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 +528,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 +586,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 +615,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 +735,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" ``` 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" From 3f9fe6f0af25fbcb1a743442634af2f30c228fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Sun, 28 Jun 2026 00:30:45 +0200 Subject: [PATCH 5/6] docs: trust file inspection, drift-gate revalidation, hook-trust residual risk Track the 16ad1a4..6281e9d range across guide + scrut: - trust inspection (THI): document `trust --show ` and the first-trust composed-file listing in README + GUIDE; extend transitive.md with --show (file/dir/absent/no-source) and the composed-surface assertion. - drift gate (DGI): GUIDE Verification now describes the size+mtime fast path escalating to recorded blake3, the Revalidated reclassification, and the persisted stat refresh; drift.md gains a 'touch is not a change' case. - residual risk (TDEP-DOCS-001): state the honest v1 hook-trust limits (no sandbox, full env+privileges, whole-commit re-prompt) in README + GUIDE. transitive.md's `phora verify` assertion is reconciled with the post-16ad1a4 verify hook-gate so the suite passes against the feature-complete binary. --- GUIDE.md | 48 +++++++++++++++++++++++++-- README.md | 43 +++++++++++++++++++++---- tests/scrut/drift.md | 18 +++++++++++ tests/scrut/transitive.md | 68 +++++++++++++++++++++++++++++++++++---- 4 files changed, 162 insertions(+), 15 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 795c701..88d310e 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -927,10 +927,28 @@ The sync tells you so, and you approve it deliberately: ```bash phora trust tropos --list # each hook: its command, its commit-pinned preimage, - # and which dependency files changed since you last trusted it + # 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 @@ -944,6 +962,15 @@ post-processing was skipped. `phora sync --no-transitive-hooks` skips composed-d hooks entirely (your own hooks still run), and `phora trust tropos --revoke` drops every approval for a dependency. +One honest limit: 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 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, @@ -1262,6 +1289,22 @@ 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. @@ -1446,7 +1489,8 @@ Most confusion maps to one question, and each question maps to one command. 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]`, and stripping its untrusted hook. + 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), diff --git a/README.md b/README.md index 610e7fd..e840621 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -930,4 +960,5 @@ file out of band and walks the recovery paths, 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. +dependency that carries its own and inspects its stripped hook offline with `trust +--list`/`--show`. diff --git a/tests/scrut/drift.md b/tests/scrut/drift.md index f849f46..f80ce25 100644 --- a/tests/scrut/drift.md +++ b/tests/scrut/drift.md @@ -46,6 +46,24 @@ 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 diff --git a/tests/scrut/transitive.md b/tests/scrut/transitive.md index 982dc51..bed8cf4 100644 --- a/tests/scrut/transitive.md +++ b/tests/scrut/transitive.md @@ -140,20 +140,23 @@ $ test -d .phora/cache/git && test -d .phora/state/projects && test ! -e "$HOME/ isolated ``` -`phora verify` re-hashes the composed copy against the registry: +`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 -all verified +$ 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 (once a prior approval exists) the dependency files -that changed since you last trusted 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. +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:' @@ -162,6 +165,57 @@ $ phora trust tropos --list 2>&1 | grep -E 'command:|env:|note:' 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 — From b0f0885fadc18bcaaacc1e3c54ab1cbdf00c5be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Mon, 29 Jun 2026 17:25:42 +0200 Subject: [PATCH 6/6] docs: soften overclaims, fix awkward phrasing, drop dev-context leakage in GUIDE --- GUIDE.md | 91 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 88d310e..fec2196 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,13 +1,13 @@ # The phora guide This is the long-form companion to the [README](README.md). The README is the -map — 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 +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. If you want to understand why -phora did what it did — or what to do when it didn't — you're in the right place. +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 @@ -60,7 +60,7 @@ 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 cleanly by who owns what. A *source* owns its *offer* — the +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: @@ -175,7 +175,7 @@ phora verify # re-hash every deployed file against the record; exit 0 if al `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 drops cleanly into a pre-commit hook or CI. +non-zero on the first mismatch, so it fits into a pre-commit hook or CI check. Later, to pick up upstream changes: @@ -184,8 +184,8 @@ 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, and it is worth internalizing early: a plain `sync` is reproducible -and offline-friendly, an `update` reaches for new commits. +whole tool: a plain `sync` is reproducible and offline-friendly, an `update` +reaches for new commits. ## Sources @@ -235,7 +235,7 @@ A bare `path = "owner/repo"` (no `host`) is a *local* path, not a forge shorthan 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. -A useful property falls out of how the store is keyed: the literal and symbolic +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.) @@ -260,7 +260,7 @@ exactly like a git source. A few things are worth knowing up front: 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/` that release tarballs love — phora strips it, so a version bump + `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 — @@ -271,7 +271,7 @@ exactly like a git source. A few things are worth knowing up front: 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 true no-op on the next sync, and a changed one advances the + 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 @@ -439,8 +439,8 @@ 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 the headline thing per-binding `take` buys -you: a target composes slices, not whole sources. +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 @@ -468,16 +468,16 @@ 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 locks byte-for-byte as -it did before this existed (see [the lock](#the-lock-and-content-identity)). +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 — they were, before -the offer/take split, and setting any of them on a `[targets..sources]` entry is -now 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. This is pre-alpha — there is no migration shim, the error just points +`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: ``` @@ -785,8 +785,7 @@ Two rules apply: 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 - earlier hard rejection of committed link mode is gone: the warning nudges you - toward portability without blocking a deliberate choice. + 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 @@ -962,11 +961,12 @@ post-processing was skipped. `phora sync --no-transitive-hooks` skips composed-d hooks entirely (your own hooks still run), and `phora trust tropos --revoke` drops every approval for a dependency. -One honest limit: 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 dependency's commit +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. @@ -997,9 +997,9 @@ records that cannot be regenerated: `~/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. That is the trick - behind "https and ssh share a mirror": both normalize to the same string, so both - hash to the same key. + 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 @@ -1063,11 +1063,11 @@ ref into its own directory and reconcile them; phora just reads two trees out of 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 underwrites the rest of "Under the hood." A URL import being a +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 is not an implementation -detail behind the model; it is the model. +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 @@ -1147,8 +1147,9 @@ 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 collide by smearing a path into the next file's content. With it, the digest -is a faithful fingerprint of the projected tree. +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 @@ -1178,8 +1179,8 @@ 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 as it -did before per-target versions existed. +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 @@ -1222,7 +1223,7 @@ does not move. [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 the whole trick: +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. @@ -1316,9 +1317,9 @@ serially behind a lock. phora fetches, resolves, and digests its sources across 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 byte-identical -to the old serial path, so nothing about a lock or a registry record depends on how -many threads ran. +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 @@ -1345,7 +1346,7 @@ takes seriously is mutual exclusion and crash safety: 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 you recoverable rather than half-deployed. + 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. @@ -1405,8 +1406,8 @@ diff (last trusted commit → candidate commit) is computed from. Because trust 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 as it did before -any of this existed. Under `--frozen`, the resolver consults the lock and refuses to +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