φορά • a carrying, motion
From the zero-grade φρ- of φέρω (phérō, "to carry, bear")
Pronunciation: /ˈfo.ra/
Phora is a git-based artifact package manager and multiplexer. It mirrors git repositories — or imports plain-https resources (tarballs, zips, single files) as content-addressed synthetic commits — picks out the paths you want (its offer) and projects them into local target directories, pinned to exact commits, verifiable by content hash, and recoverable after interruption.
Use it to distribute shared config, editor setups, prompt/skill bundles, or release assets from one or more repos (or URLs) into the places on disk that consume them.
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/srnnkls/phora/releases/latest/download/phora-installer.sh | shbrew install srnnkls/phora/phoracargo install phoraDownload an archive for your platform from the releases page. Prebuilt targets:
x86_64-unknown-linux-gnuaarch64-unknown-linux-gnux86_64-unknown-linux-muslaarch64-unknown-linux-muslx86_64-apple-darwinaarch64-apple-darwin
Every release artifact ships with a SHA-256 checksum and an SLSA build-provenance attestation, verifiable with gh attestation verify <file> --repo srnnkls/phora.
cargo install --path .
# or, during development:
mise run build # cargo buildRequires a Rust toolchain (edition 2024).
- Source — provenance: where bytes come from, pinned by
branch,tag, orrev. A source owns its offer:rootre-anchors the slice andinclude/exclude(gitignore syntax) composeinclude − excludeinto the leaf set the source publishes. Declare its remote as a forge (host+repo), a local path (path = "/dir"), a literal git URL (git = "…"), or a downloadable resource (url = "https://…"). - Offer — the leaf set a source publishes, named relative to its
root. With noincludethe offer is everything in the source minus VCS metadata (.git/); anincludenarrows it andexcludeprunes it (exclude wins; no!re-inclusion). Dotfiles match like any other path. - Artifact — one offered leaf, identified by its full offered path. The unit a target takes, renames, and deploys.
- Target — a local directory artifacts are projected into, with a chosen
layout. A target draws from its explicit
sourcesallow-list. - Binding — a target's link to a source. The source owns the offer; the binding
owns the take: its
takesubsets and renames the offer for that target alone, andcollapsecontrols how the taken set materializes. See Bindings. - Transitive dependency — a source that is itself a phora project. Mark it
transitive = true, import it into a target withimports = [...], and its ownphora.tomltargets compose into your workspace under that target's path. See Transitive dependencies. - Lock —
phora.lockpins each source to a resolved commit so syncs are reproducible (phora.local.tomlgets a companionphora.local.lock).phora updatebumps it. - Registry — per-project state under the state root (
XDG_STATE_HOMEor, by default,~/.local/state/phoraon Linux and~/Library/Application Support/phoraon macOS) recording what was deployed where (commit + content digest), so phora can detect drift, conflicts, and orphans. Bare mirrors live under the cache root (XDG_CACHE_HOMEor, by default,~/.cache/phoraon Linux and~/Library/Caches/phoraon macOS), in itsgit/subdirectory. See State & locations.
The model splits cleanly into who-owns-what:
| 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 leaf, identified by its full offered path |
| collapse | target | how a taken set materializes: per-leaf, or one dir symlink/subtree |
Phora keeps its shared state in two XDG-rooted trees:
| Root | Holds | Override | Linux default | macOS default |
|---|---|---|---|---|
| Cache | git mirrors (regenerable) | XDG_CACHE_HOME |
~/.cache/phora |
~/Library/Caches/phora |
| State | registry (deploy journal, locks) | XDG_STATE_HOME |
~/.local/state/phora |
~/Library/Application Support/phora |
A project may pin either root in phora.toml with a [paths] table — cache and
state are optional and independent — which makes project-local installs and
hermetic tests possible without exporting the XDG vars:
[paths]
cache = ".phora/cache" # git mirrors live under <root>/git/
state = ".phora/state" # registry, locks, and journal live under <root>/projects/A configured path is itself the root: relative values resolve under the project
root, absolute values are used as-is, and no phora leaf is appended. Resolution
precedence is config, then the XDG_* env, then the platform default.
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 are intentionally unused: phora has no portable
data payload (the registry is machine-local, mirrors are regenerable) and no global
config root (config is project-local phora.toml). Neither tree is migrated — a
legacy ~/.phora is abandoned; mirrors re-clone and the registry rebuilds on the
next sync.
Each sync takes an exclusive OS lock on state.lock under the project's registry
directory, so two syncs of the same project on one machine serialize and never
corrupt the registry or journal.
That lock is only reliable on a local filesystem. On a network filesystem — NFS, SMB, or CIFS — file locks are advisory and best-effort: the kernel may not honor them across hosts, so two machines syncing the same state root at once are not mutually excluded. Phora cannot build a cross-host lock over these mounts (there is no lock server); when it detects the state root on a network filesystem it prints a one-line advisory and proceeds. Cross-machine safety there is your responsibility.
This matters most for a shared $HOME (the state root defaults under your home
directory). If the same home directory is mounted on several machines — a common
lab/cluster setup — do not run concurrent syncs of the same project from two
machines; serialize them, or give each machine its own state root by pointing
[paths].state at machine-local storage.
# Add a source. Shorthands persist as a forge source (host + repo), not an expanded URL.
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
# 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
# --root/--include/--exclude on `add` shape the SOURCE offer (source-owned), not a binding.
phora add me/dotfiles --to neovim --as nvim --root nvim
# Fetch sources, resolve commits, project artifacts into targets
phora sync
phora sync --prune # also remove artifacts no longer selected
phora sync --force # overwrite locally-modified files without prompting
phora sync --frozen # refuse to fetch or re-resolve — every source must be pinned in the lock
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, approve interactively
phora trust tropos --list # show tropos's hooks without approving anything
phora trust tropos --show <path> # 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
phora update myconfigs # one source
# Inspect state
phora list # per-target deployment status
phora verify # re-hash deployed files, exit non-zero on mismatch
phora where --source loqui # reverse-lookup registry (by source/artifact/commit/digest)
phora preview # dry-run: the full tree a sync would project (offline, from the lock)
phora preview --target home # one target; --source <s> limits to one source
phora preview --files # expand each artifact to its files
phora preview --json # machine-readable plan
# Stop managing an artifact but keep its files on disk
phora eject <artifact> --source <source> --target <target>
phora uneject <artifact> --source <source> --target <target>
# Maintenance / debugging
phora rebuild-registry # reconstruct registry from lock + on-disk targets
phora check-match --source <source> <path> # debug include/exclude matching
phora explain <target> <source> [path] # offline: which include/exclude offered a path, and how `take` resolves itsource and target group the registry commands; bind/unbind edit which
sources a target deploys. add/rm are top-level sugar over the source namespace.
# Sources (`add` is identical to `source add`)
phora source add owner/repo --name myconfigs --branch main
phora source list # name, resolved remote, refspec
phora source show myconfigs # effective config + targets that deploy it
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 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
# 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--to/--from name the target an edge attaches to. phora add <url> --to T1 --to T2
adds the source then binds it to each target atomically — the whole desugar is
applied to one config-text string and written once, so a failure leaves nothing
behind. A --to target that does not exist prompts to create it (flat layout,
path ./T) on an interactive terminal, and errors with a phora target add hint
off a TTY. --local on a mutating command writes phora.local.toml instead of
phora.toml; source rm/rm take no --local, since their scrub spans both
files.
A bare phora add <url> (no --to) deploys into the project: it ensures
[targets.default] (path ., flat layout) and binds the source into it. Set
[defaults] auto_target = false to opt out — then a bare add only declares the
source, and it deploys nowhere until bound. --to always routes to exactly the
named target(s) and never touches [targets.default].
bind onto a target with no sources key creates the list with the bound
source(s); the target deploys exactly its listed sources (nothing until bound).
phora preview is the dry-run projection view: per target, it shows each binding's
identity (the [targets.<t>.sources] table key, defaulting to the source name), the artifacts it selects,
and the destinations they'd land at under the target's layout — without writing
anything. Commits come from the lock and the tree from the mirror, with no network.
An unsynced source is annotated (not locked, needs sync, or link working tree gone) rather than fetched, and the command still exits 0. Predicted flat-layout
collisions render as warnings. Where check-match is a single-path probe, preview
is the whole-tree view.
home
dotfiles@a1b2c3d4 editor -> /home/me/deploy/editor
dotfiles@a1b2c3d4 lint -> /home/me/deploy/lint
--files expands each artifact to the files it would deploy; --json emits the
same plan as a machine-readable document.
When sync finds a target file that was modified outside phora, or a foreign file
where an artifact wants to land, it prompts (on a TTY):
[s]kip / [o]verwrite / [e]ject / [a]bort
Non-interactive runs skip such files unless --force is given.
Hooks run shell commands after a sync. A target's on_change fires once after a
sync that added or modified that target's artifacts (pure removals don't
trigger it — that's what the global post_sync escape hatch is for); the global
[hooks] post_sync runs after every sync. Hooks are declared only in
phora.toml / phora.local.toml.
[targets.neovim.hooks]
# a bare string runs under `sh -c`
on_change = "nvim --headless +'Lazy! sync' +qa"
[targets.editors.hooks]
# a table picks the shell; an array runs several in declared order (deduped)
on_change = [
{ run = "stylua .", shell = "bash -c" },
"git -C ~/.config add -A",
]
[hooks]
post_sync = "notify-send 'phora synced'" # runs every sync (when = "always")A hook value is a command string, a { run = "...", shell = "..." } table
(shell optional, default sh -c), or an array mixing both. Each hook sees
phora's full environment plus, for on_change:
| Variable | Value |
|---|---|
PHORA_TARGET |
the target name |
PHORA_CHANGED |
newline-separated deployed paths of changed artifacts |
PHORA_CHANGED_NAMES |
newline-separated artifact names |
Artifacts land on disk before the hook runs. Hook success is recorded, so a
no-op sync runs no on_change; a hook that exits non-zero is not recorded, makes
phora sync exit non-zero, leaves the deployed files in place, and re-fires on
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
sync complete
Trust boundary. Hooks come only from the consumer's config. A synced source
tree that happens to carry a hook-shaped phora.toml is inert content — it is
never read as config and never executes.
Files can be rendered per-machine with 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.
Variables come from a flat [vars] table:
[vars]
greeting = "hello"
editor = "nvim"{# editor/motd.tmpl → deploys as editor/motd #}
{{ greeting }} from {{ editor }}The .tmpl suffix is the opt-in by default; a refined binding can widen it to
arbitrary globs or turn it off:
[targets.editor.sources]
# render these paths too, in addition to *.tmpl:
wide = { source = "dotfiles", template = ["*.conf", "config/*"] }
# render nothing, even .tmpl files:
plain = { source = "dotfiles", template = false }Rendering is strict: referencing an undefined variable aborts that artifact's
export — its sibling artifacts still deploy. phora.local.toml overrides vars
per key — keys it omits keep their base value — so each machine fills in its own:
# phora.local.toml — overlays phora.toml, never committed
[vars]
greeting = "hi from this laptop"Integrity. Phora hashes the rendered bytes, so phora verify checks the
deployed output, not the template. The lock records source bytes only: two
machines with different vars produce byte-identical locks, keeping the integrity
check machine-independent. Editing a variable marks the affected artifacts
outdated, and the next phora sync re-renders and redeploys them — no new commit
needed. phora preview --files shows the deployed name and flags what renders:
home
dotfiles@a1b2c3d4 editor -> /home/me/.config/editor
motd (templated)
static.txt
Phora reads phora.toml from the working directory, optionally overlaid by
phora.local.toml (same schema; local values win per-key). See
phora.example.toml for a complete example.
version = 1
# protocol = "ssh" # global default for forge sources (default https)
# [defaults]
# auto_target = true # bare `add` populates [targets.default] (default true)
[hosts.github]
auth = { type = "token", env = "GITHUB_TOKEN" } # remote is built in; just add auth
[sources.dotfiles]
host = "github" # forge remote: host + repo (or use git = "…" for a literal URL)
repo = "me/dotfiles"
branch = "main" # or tag = "...", or rev = "<sha>"; 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"]
[targets.neovim]
path = "~/.config/nvim"
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.editor.sources]
nvim = { source = "dotfiles", take = ["nvim/**"] }Target sources are an explicit allow-list: ["a", "b"] deploys those two,
[] (or an omitted sources key) deploys nothing. Edit the list with phora bind/phora unbind rather than by hand; bind onto a target with no sources
key creates it.
[defaults] auto_target (bool, default true) controls bare-add DX: when
on, phora add <url> without --to ensures [targets.default] (path ., flat)
and binds the source into it; set it false to make bare add declare-only.
--to is unaffected — it always routes to the named target(s).
Hosts supply remote templates and auth. github, gitlab, codeberg,
sr.ht, and bitbucket ship built in (both https and ssh); a [hosts.X] block
adds a new forge or overrides a built-in's remote/auth. Auth is either
{ type = "token", env = "VAR" } or { type = "ssh", key = "~/.ssh/key" }.
Source flags: allow_symlinks (default off), preserve_executable (default on),
deploy ("copy" | "link", default "copy"; "link" is local-overlay-only — see
Link mode).
Layouts decide how an artifact a from a binding i (its identity — the
[targets.<t>.sources] table key, defaulting to the source name) is placed in a target:
| Layout | Path |
|---|---|
flat (default) |
a |
by-source |
i/a |
{ type = "prefixed", sep="-" } |
i-a |
A target's sources says which sources it consumes — and, per source, how. Each
entry is a binding: the edge from a target to a source. The source owns the offer
(root/include/exclude); the binding owns the take — take subsets and renames
that offer for one target, without touching the source or any other target.
A target's sources takes one of two forms — never both at once:
- Flat list of bare names —
sources = ["dotfiles", "loqui"]. Every source is consumed at its whole offer. This is the all-bare, zero-settings form (each element is equivalent toname = {}, which omitstake). - Keyed table —
[targets.<t>.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;sourceis written only on divergence, when the identity differs from the source name. A bare entry inside a refined (keyed) target isname = {}. A binding may settake,collapse,template, and a per-target ref (branch/tag/rev); the offer scope itself (root/include/exclude) is not a binding key.
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 gitignore glob (any entry with
*,?,[,], or a trailing/, e.g."nvim/**") — expands over the offer set only, never widening it; - a rename table
{ "src" = "dest" }— the offered leafsrcis consumed and emitted atdestinstead (destructive: it does not also land atsrc).
A literal or rename src that is not in the offer is a hard error (a take may not
widen the offer; the diagnostic suggests the closest offered leaf). A glob that
matches nothing warns but does not fail. An omitted take takes the whole offer;
take = [] takes nothing.
[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" }] }Restriction. take (or any other refinement) on a binding backed by a url source
is a config error — a url source has no offer to subset. branch/tag/rev
on a binding backed by a url source or a deploy = "link" source are config
errors too — a url has no ref to resolve, and a link source live-links a working
tree rather than a pinned commit.
Binding scope is rejected. root, include, exclude, and map are no longer
binding keys: setting any of them on a [targets.<t>.sources] entry is a hard parse
error with a did-you-mean diagnostic — it redirects root/include/exclude to the
source offer ([sources.<name>]) and map to the target take rename form. This is
pre-alpha; there is no migration shim.
Identity (the table key). A binding's identity is the
[targets.<t>.sources] table key; it defaults to the source name and you write
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
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/…):
[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/**"] }Per-target version (branch/tag/rev). A binding may also set its own ref —
exactly as it sets its own take. The source's ref is the default;
a binding's ref overrides it for that target alone; a bare binding inherits the
source's ref. As on a source, set at most one ref per binding (precedence within a
binding is rev > tag > branch).
Each distinct ref gets its own lock entry, at its own resolved commit. Bindings that don't override the ref collapse to the source's ref and share one lock entry, so a config that names no binding refs locks byte-for-byte as before; a ref-overriding binding records its own entry. Resolution still does one fetch per source — that single fetch covers every ref the source's bindings name.
To bind one source at two versions, give each binding a distinct key, each naming the
same source and pinning its own ref:
[targets.tools]
path = "~/.local/tools"
layout = "by-source" # stable/… and canary/… resolve to different commits
[targets.tools.sources]
stable = { source = "fzf", tag = "v0.55.0" }
canary = { source = "fzf", tag = "v0.56.0" }CLI. phora bind <source>… --to <target> adds bindings; --as, --take <entry>…,
and --branch/--tag/--rev refine the binding. A --take entry is a leaf, a glob,
or a src=dest rename (the = form writes the { src = dest } rename table). Any
binding refinement writes a keyed table entry; with no refinement it appends a bare
source name to the target's flat list (or, if the target is already a keyed table,
writes name = {}). --branch/--tag/--rev write a table binding pinning that ref
for the target. Because --as sets a single binding identity, it cannot apply to
multiple sources. --root is source-owned, not a binding key: bind --root writes
root onto each named [sources.<name>] table (and errors if a named source is not
declared in the file being edited). phora unbind <identity>… --from <target>
removes bindings by their identity.
phora add <url> and phora source add <url> carry the source-owned offer flags
--root, --include <glob>…, and --exclude <glob>…, which shape the NEW source's
offer (they land on [sources.<name>], never on a binding). With --to <target>,
phora add also accepts --as to set the binding identity (it requires exactly one
--to target, erroring with multiple --to or none). The ref flags stay
source-level on add: phora add's --branch/--tag add a source at a version, so
per-target ref overrides are a bind concern only (bind --branch/--tag/--rev).
Local/symlink overlays (--local/--symlink) accept neither --to nor binding
refinement flags.
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:
[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. A rename entry is { "<offered-leaf>" = "<dest>" }: the
key is a path the source offers, the value the path it deploys as under the target's
layout.
- Destructive. The renamed leaf is emitted only at
dest, not also at its original path. Asrcalready covered by a glob in the sametakeis consumed out of that glob, so it is not double-emitted. - The
srcmust be offered. A rename whosesrcis not in the offer is a hard error (atakemay not widen the offer); the diagnostic suggests the closest offered leaf. A leaf named both as a literaltakeand as a renamesrcis also rejected. - Portable
dest. Adestmust be a forward-slashed relative path inside the target root: an absolute path, a..escape, or a backslash is rejected. Nested dests are allowed. - No within-binding clash. Two entries resolving to the same destination (case-
insensitively, NFC-folded) are a config error; one
srcrenamed to two different dests is rejected too. - Fan-out without duplication. The same source leaf renamed to different dests
across bindings and targets is each its own binding under its own table key, naming
the same
source; their dests differ, so they never clash. The source is fetched once. - Copy and link both work. Default
deploy = "copy"materializes the leaf;deploy = "link"(local-path only — see Link mode) makes the dest a symlink to the source leaf in the working tree.
Overlay. A binding's take lives on the binding, and a phora.local.toml sources
list replaces the base target's list wholesale — it does not merge per binding. A
local override of a target's sources must therefore restate every binding it
wants, including their take; base takes it omits are dropped for that target.
A binding's collapse controls how a taken set materializes: per-leaf artifacts, or
one directory artifact (a directory symlink under deploy = "link", a subtree copy
under deploy = "copy"). It is a binding-level opt (and has a mount-parity table —
see Transitive dependencies), exempt from the
binding-scope rejection alongside template and take.
- Omitted — 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-dir exclude blocks collapse and the directory falls back per-leaf with a warning; undercopy, 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. It is a hard error, naming the directory, if a within-dir exclude (underlink) or a per-leaf rename makes whole-directory collapse impossible. This is the analogue of dotter'srecurse: request the directory symlink/subtree, and fail loudly when it cannot be honored.
[targets.editors.sources]
# force a per-leaf snapshot even though the whole tree is taken:
nvim = { source = "dotfiles", take = ["nvim/**"], collapse = false }A source declares its remote in exactly one kind — never more than one:
- Forge:
host = "<alias>"+repo = "<owner/repo>", resolved at sync time from the host'sremotetemplate.hostmay be omitted whenrepois set, in which case it defaults togithub(repo = "owner/repo"is github shorthand). - Local:
path = "<dir-or-file>", a filesystem path used verbatim as the remote — exactly like agit = "/abs/local"URL. - Literal:
git = "<url>", any https,ssh://, or scp-style (git@host:path) remote. - Url:
url = "https://…", a downloadable resource (see below).
[sources.tropos]
host = "github" # built in; omit to default to github
repo = "srnnkls/tropos"
branch = "main"
[sources.internal]
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
branch = "main"Back-compat aliases. git = "/abs/local" still declares a local source.
host + path (forge owner/repo) is a deprecated alias for host + repo.
Breaking change: a bare
path = "owner/repo"(no host) now means a LOCAL path, not a github forge source. The github shorthand moved to barerepo = "owner/repo".
A host's remote is either a single template string (https) or a
{ https = "…", ssh = "…" } table. Templates fill three placeholders:
| Placeholder | Value |
|---|---|
{path} |
the source's repo (owner/repo), verbatim |
{owner} |
the first /-segment of repo |
{repo} |
the remainder (so {owner}/{repo} ≡ {path} at any depth — GitLab subgroups) |
[hosts.company]
remote = { https = "https://git.company.com/{path}.git", ssh = "git@git.company.com:{path}.git" }Built-in forges. github, gitlab, codeberg, sr.ht, and bitbucket
ship as remote tables with both https and ssh shapes, so no template is needed
for them. A [hosts.X] block of the same name overrides the built-in's remote
or adds auth; changing a host's remote re-points every source on that host
with no per-source edits.
Protocol. protocol = "https" | "ssh" selects which template key a forge
source resolves through. It defaults to https, can be set globally at the top
level, and is overridable per source. Selecting ssh against a host whose
remote has no ssh key is a config error. (protocol is ignored for literal
git and local path sources.)
The forge and literal forms of one repo — and its https and ssh remotes —
share a single mirror under the cache root's git/ subdirectory (see
State & locations), so switching kind or protocol never
re-clones or refetches.
A url = "https://…" source is one of the four kinds (forge XOR local XOR git
XOR url). It downloads a resource and imports its contents as a source, then
discovers/exports/deploys exactly like a git source. branch, tag, rev, and
root have no meaning for a static resource and are config errors on a url source;
include/exclude still select files.
[sources.fzf-bin]
url = "https://github.com/junegunn/fzf/releases/download/0.55.0/fzf-0.55.0-linux_amd64.tar.gz"
digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
include = ["fzf"]Formats. tar, tar.gz/tgz, and zip, detected by content (magic bytes); a non-archive URL becomes a single file named from the URL basename.
Auto-strip. When an archive has exactly one top-level directory it is stripped
automatically, so version-stamped release tarballs (fzf-0.55.0/…) need no
per-version root — and root re-selection is unavailable on url sources anyway.
Only include/exclude apply; root/branch/tag/rev are config errors.
Integrity. An optional digest = "<algo>:<hex>" (sha256: or blake3:, 64
hex chars) is verified before extraction; a mismatch errors, naming the source
with expected vs actual.
Determinism. Content is imported as a content-addressed synthetic git commit
(fixed identity, fixed time, constant message), so identical bytes yield an
identical commit and no lock churn. The synthetic commit's time is fixed at epoch+1
(1 second), not epoch 0, since some filesystems (FAT32, HFS+) clamp a 0 mtime —
which would otherwise make phora verify report every url-sourced file as modified.
phora sync of unchanged content is a no-op; phora update (or --force)
re-downloads, and the lock advances only if the content changed. phora verify
re-hashes deployed files with the same guarantees as git sources.
Out of scope (for now). Auth for private assets and forge release-tag resolution (latest tag → asset URL) are future work; v1 targets public URLs.
By default deploy = "copy" materializes a reflink-style copy of each artifact
from the committed git ODB — point-in-time, content-hashed, verifiable. For a
tight dev loop, deploy = "link" instead symlinks the artifact destination
at the source's live working tree (<source path>/<root>/<artifact>, absolute).
Uncommitted edits in the checkout are visible through the target immediately, with
no re-sync.
Two guardrails apply:
- Local path only. A link source must be a local source:
path = "/dir"(or thegit = "/dir"alias), a local filesystem path.deploy = "link"on a remote URL is a config error that names the source. A relative path counts as local only if it exists relative to the working directory; a relative path that does not yet exist is rejected as "not local". - Portable paths in shared config.
deploy = "link"is allowed in eitherphora.tomlorphora.local.toml. A committed (phora.toml) link source over an absolute path syncs but prints a non-fatal stderr warning that names the source: an absolute checkout path is machine-specific and rarely portable across machines. A committed link over a relative (portable) path warns nothing. Machine-specific checkouts still belong inphora.local.toml, which never warns.
Linked artifacts sit outside the integrity model: their registry record carries
a linked marker and no per-file hashes. phora verify skips them, drift detection
never reports them modified or foreign, phora list shows them as linked, and
phora rebuild-registry reconstructs the marker without hashing. --prune removes
an orphaned linked artifact by deleting the symlink only. If the working-tree target
is deleted or renamed the link reads as missing and is redeployed on the next sync.
Switching a source between link and copy replaces the destination on the next
sync (symlink ⇄ materialized copy, with full integrity restored on copy). If a
symlink cannot be created (e.g. on Windows without the privilege), phora warns,
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
deploy = "link"phora add --local <path> writes that overlay for you: it records
path = "<abspath>" for a local source in phora.local.toml (never phora.toml).
phora add --symlink <path> does the same and adds deploy = "link" to live-link it.
So far a source has been a flat bag of artifacts. A transitive dependency is a
source that is itself a phora project — it ships its own phora.toml with its own
[sources] and [targets]. Instead of re-typing that project's whole layout into
your config, you import it and phora composes its targets straight into your
workspace.
Take srnnkls/tropos, a toolkit of
agent-harness artifacts — skills, commands, agents, workflows. Its loqui skill
hands the agent language-specific coding guidelines, but those live in a separate
repo, srnnkls/loqui, and the skill expects them
vendored underneath it at skills/loqui/reference/loqui/. So tropos declares loqui
as one of its own sources and lets phora compose it into that spot. Mark tropos
transitive = true, import it, and phora follows that edge.
# your phora.toml
[sources.tropos]
host = "github"
repo = "srnnkls/tropos"
branch = "main"
transitive = true
[targets.claude]
path = "~/.claude"
imports = ["tropos"] # mount tropos's own targets under ~/.claude# inside srnnkls/tropos, its own phora.toml — the slice that matters here:
[sources.loqui]
host = "github"
repo = "srnnkls/loqui" # the language guidelines the loqui skill leans on
[targets.loqui]
path = "skills/loqui/reference/loqui" # relative — composes UNDER the importing anchor
sources = ["loqui"]A phora sync now 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.
One imports line, and tropos's dependency rode along. A target can import several at
once — imports = ["tropos", "work-config"] — each composing under the same anchor.
- The importing target's
pathis the anchor. Each dep target's ownpathis taken as relative and joined under it — tropos'sloquitarget atpath = "skills/loqui/reference/loqui", imported into your target at~/.claude, deploys to~/.claude/skills/loqui/reference/loqui. - The dep's own layout governs its artifacts, not yours. If that target declares
layout = "by-source", loqui's trees nest one level deeper under the source identity rather than landing flat — and that's tropos's call, not yours, even when yourclaudetarget isprefixed. The anchor's layout is never re-applied to a mounted subtree. - Nothing silently merges. A dep's sources are namespaced per dep instance. If
both you and tropos define a source named
loquipointing at different repos, yourloquiserves your targets and tropos's is a distinct instance serving its own. Import a second config that pulls its ownloquiand the two stay separate too. And a dep that imports its own deps composes recursively, with a cycle guard so a diamond collapses to a single fetch instead of looping forever. - Real collisions are hard errors. If two composed dep targets resolve to the
same destination, the sync stops and names the path (
composed targets resolve to the same destination) rather than letting one quietly clobber the other.
A consumer subsets what a mounted dep contributes with target-owned [take] and
[collapse] tables, keyed by the imported dep's anchor (the composed destination
the dep target lands at). This is the mount-level analogue of a binding's take and
collapse: it is the consumer's own slice of a composed subtree, the dep cannot
override it.
[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" = falseAn 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.
A dep's phora.toml is untrusted input, so a composed dep can only ever write
inside its anchor. phora rejects, at compose or write time:
- a dep target path that escapes the anchor with
.., is absolute, or carries an unsafe path component; - a write whose anchor ancestor is a symlink (no following a planted link out of the tree);
- writes into protected paths — your
phora.toml/phora.lock,.git, and phora's own state and cache roots; deploy = "link"on a transitive source (a link would point at an unconfinable mirror path); your own link sources are unaffected.
A dep's inner sources resolve their remotes against your host registry, so the
dep records intent (host + repo) and your config decides the protocol and the
forge URL. An inner source with an absolute-path or file:// remote is rejected:
a dep cannot reach back onto the consumer's local filesystem.
Here is the sharp edge. A tropos target might carry a hook — say on_change = "mise trust && mise install" to provision the toolchain it just laid down — a shell
command its author wants run after the files land. That command would run on your
machine, from a repo you don't control. So phora never trusts a dep's hooks
implicitly. On the first sync, discovered dep hooks are stripped — recorded, but
not run — and the sync tells you so. You approve them deliberately:
phora trust tropos --list # show each hook: its command, its commit-pinned preimage,
# and the dep surface around it (see below)
phora trust tropos # same, then prompt [y/N] per hook; a yes is recordedWhat --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 <path> 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
trust at all. A trusted hook runs on the next sync without a prompt — but the moment
the dep 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 (CI) surfaces the same notice but 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 dep.
phora sync --frozen refuses to fetch or re-resolve anything: every source — root,
imported dep, and nested dep alike — must already be pinned in the lock. A miss
hard-errors, naming the source (and, for a nested dep, its depth) so a drifted or
dropped pin can't pass silently. It is the offline, "the lock is the law" mode for
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.
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.
A worktree is just a directory you run phora sync from; sync builds the managed
state there. It is cheap to re-run: an unchanged lock means no refetch.
Carrying ignored or local files (.env, editor settings, submodules) across
worktrees is out of scope — use git-worktreeinclude
for that. Migration: move any [worktree].includes entries into a
.worktreeinclude and drive them with git-worktreeinclude.
mise run check # clippy (pedantic, -D warnings) + rustfmt --check + tests
mise run test # cargo test
mise run fmt # cargo fmtmise run test-integration # scrut suites under tests/scrut/ against a release buildThe scrut suites drive the shipped binary end to end and double as runnable usage
docs. 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.