Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@

# multi-agent review artifacts
.reviews/
.peer/
63 changes: 58 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ Requires a Rust toolchain (edition 2024).
layout. A target draws from its explicit `sources` allow-list.
- *Binding* — a target's link to a source. The source owns the offer; the binding
owns the *take*: its `take` subsets and renames the offer for that target alone,
and `collapse` controls how the taken set materializes. See [Bindings](#bindings).
and `collapse` controls how the taken set materializes. A binding can also opt
into a *history overlay*, which adds Git metadata to its copy deployment. See
[Bindings](#bindings) and [History overlay](#history-overlay).
- *Transitive dependency* — a source that is itself a phora project. Mark it
`transitive = true`, import it into a target with `imports = [...]`, and its own
`phora.toml` targets compose into your workspace under that target's path. See
Expand All @@ -102,6 +104,7 @@ The model splits cleanly into who-owns-what:
| 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 |
| history overlay | binding | Git metadata over one copy deployment; copied content remains authoritative |

### State & locations

Expand Down Expand Up @@ -170,6 +173,8 @@ phora add git@github.com:me/dotfiles.git --tag v1.2
# 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 add --history <source> --to <target> # add a source with a history-enabled binding
phora bind gitoxide --history --to resources --to docs # one history binding per repeated target
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
Expand Down Expand Up @@ -469,8 +474,10 @@ A target's `sources` takes one of two forms — never both at once:
key defaults to the source name; `source` is written only on divergence,
when the identity differs from the source name. A bare entry inside a refined
(keyed) target is `name = {}`. A binding may set `take`, `collapse`, `template`,
and a per-target ref (`branch`/`tag`/`rev`); the offer scope itself
(`root`/`include`/`exclude`) is not a binding key.
`history` (see [History overlay](#history-overlay)), and a per-target ref
(`branch`/`tag`/`rev`). A history binding takes the whole repository and rejects
`take`, `collapse`, and `template`; 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:

Expand Down Expand Up @@ -750,6 +757,50 @@ 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.

### History overlay

A history overlay is a binding-level opt-in for a Git-backed copy deployment. Phora
copies and verifies the content exactly as in ordinary copy mode: the deployed content
and its manifest remain authoritative. The overlay is orthogonal Git metadata that lets
`git log`, `git blame`, `git show`, and `git diff` work inside that deployment.

History belongs to `Binding.history`, not the source. The same source can therefore be
an ordinary copy binding for one target and history-enabled for another. A history
binding materializes the source's whole repository at the binding identity.

A common use is a project's committed manifest, which pins third-party references under
`resources/` for agents or other tools to inspect at the pinned version. The manifest
preserves the source and commit while `resources/gitoxide/` remains readable with its
upstream history:

```toml
[sources.gitoxide]
host = "github"
repo = "Byron/gitoxide"

[targets.resources]
path = "resources"

[targets.resources.sources.gitoxide]
history = true
```

`phora add --history <source> --to <target>` creates a history-enabled binding. To
apply the same existing source to several targets in one edit, repeat `--to`:

```bash
phora bind gitoxide --history --to resources --to docs
```

The overlay's mirrors are cache state. User branches and commits made inside a history
deployment are disposable cache-local state: a cache deletion or mirror reclone can lose
them, so push work elsewhere to retain it. A concurrent mirror refresh can also make a
user-run `git log` or `git blame` fail transiently; retry the command.

When a history deployment is inside your own Git work tree, the enclosing repository
sees it as an embedded repository. Add that deployment path to the enclosing
repository's `.gitignore` unless you intend to manage it there.

### Link mode (local development)

By default `deploy = "copy"` materializes a reflink-style copy of each artifact
Expand Down Expand Up @@ -975,8 +1026,10 @@ 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
state there. It is cheap to re-run: an unchanged lock means no refetch.
Here, a worktree is a source's live working tree in link mode or the project directory
from which you run `phora sync`; it is not a target history overlay. A history overlay
is a copy deployment with Git metadata (see [History overlay](#history-overlay)).
Running `phora sync` is cheap to repeat: an unchanged lock means no refetch.

Carrying ignored or local files (`.env`, editor settings, submodules) across
worktrees is out of scope — use [`git-worktreeinclude`](https://github.com/srnnkls/git-worktreeinclude)
Expand Down
1 change: 1 addition & 0 deletions scripts/arch-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ SOURCE_IO_OWNERS=(
src/source/http.rs
src/source/import.rs
src/source/worktree.rs
src/source/worktree_deploy.rs
)

LEGACY_INFRA=(
Expand Down
101 changes: 78 additions & 23 deletions src/cli/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,58 @@ use crate::source::{Protocol, is_local_path};
use super::config_edit::BindRefinement;
use super::{config_edit, load_config, read_config_text, render, target_config_file};

#[allow(
clippy::too_many_arguments,
reason = "CLI flag fan-out mirrors the `phora add` argument surface"
)]
pub(super) fn run_add(
url: &str,
targets: &[String],
name: Option<String>,
branch: Option<String>,
tag: Option<String>,
root: Option<String>,
include: Vec<String>,
exclude: Vec<String>,
local: bool,
symlink: bool,
refinement: &BindRefinement,
) -> Result<()> {
if refinement.r#as.is_some() && targets.len() != 1 {
pub(super) struct AddRequest<'a> {
pub(super) url: &'a str,
pub(super) targets: &'a [String],
pub(super) name: Option<String>,
pub(super) branch: Option<String>,
pub(super) tag: Option<String>,
pub(super) root: Option<String>,
pub(super) include: Vec<String>,
pub(super) exclude: Vec<String>,
pub(super) local: bool,
pub(super) symlink: bool,
pub(super) refinement: &'a BindRefinement,
}

pub(super) fn run_add(request: AddRequest<'_>) -> Result<()> {
if request.refinement.r#as.is_some() && request.targets.len() != 1 {
return Err(Error::Config(
"`--as` sets a single binding identity and needs exactly one `--to` target".to_owned(),
));
}
if !refinement.is_bare() && targets.is_empty() {
if !request.refinement.is_bare() && !request.refinement.history && request.targets.is_empty() {
return Err(Error::Config(
"refinement flags (`--as`/`--take`) need at least one `--to` target".to_owned(),
));
}
if (local || symlink) && (!targets.is_empty() || !refinement.is_bare()) {
if request.refinement.history && (request.local || request.symlink) {
return Err(Error::Config(
"`--local`/`--symlink` overlays do not support `--history`".to_owned(),
));
}
if (request.local || request.symlink)
&& (!request.targets.is_empty() || !request.refinement.is_bare())
{
return Err(Error::Config(
"`--local`/`--symlink` overlays do not support `--to`/refinement flags".to_owned(),
));
}

if !targets.is_empty() {
if !request.targets.is_empty() {
let AddRequest {
url,
targets,
name,
branch,
tag,
root,
include,
exclude,
local,
symlink,
refinement,
} = request;
return run_add_to_targets(
url,
targets,
Expand All @@ -57,7 +75,18 @@ pub(super) fn run_add(
refinement,
);
}
if local || symlink {
if request.local || request.symlink {
let AddRequest {
url,
name,
branch,
tag,
root,
include,
exclude,
symlink,
..
} = request;
return add_local(
url,
name,
Expand All @@ -72,6 +101,21 @@ pub(super) fn run_add(
);
}

run_unbound_add(request)
}

fn run_unbound_add(request: AddRequest<'_>) -> Result<()> {
let AddRequest {
url,
name,
branch,
tag,
root,
include,
exclude,
refinement,
..
} = request;
let mut parsed = resolve_add_source(url)?;
parsed.include = include;
parsed.exclude = exclude;
Expand All @@ -83,7 +127,12 @@ pub(super) fn run_add(
let doc_text =
std::fs::read_to_string("phora.toml").unwrap_or_else(|_| "version = 1\n".to_owned());
let auto_target = super::effective_auto_target();
let updated = if auto_target {
if refinement.history && !auto_target {
return Err(Error::Config(
"`--history` needs at least one `--to` target".to_owned(),
));
}
let mut updated = if auto_target {
add_to_default_target(
&doc_text,
&name,
Expand All @@ -102,6 +151,11 @@ pub(super) fn run_add(
root.as_deref(),
)?
};
if refinement.history {
updated =
config_edit::bind(&updated, "default", std::slice::from_ref(&name), refinement)?.text;
}
super::bind::guard_no_dangling_references(&updated, false)?;
std::fs::write("phora.toml", &updated)?;

let refspec = tag
Expand Down Expand Up @@ -282,6 +336,7 @@ fn add_local(
if symlink {
updated = inject_deploy_link(&updated, &name)?;
}
super::bind::guard_no_dangling_references(&updated, true)?;
std::fs::write("phora.local.toml", &updated)?;

println!("Added local source '{name}': {path}");
Expand Down
49 changes: 33 additions & 16 deletions src/cli/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ fn validate_merged_references(main_text: &str, local_text: &str) -> Result<()> {
let main = crate::config::Config::parse(main_text)?;
let local = crate::config::Config::parse(local_text)?;
let merged = merge_configs(main, Some(local));
merged.validate()?;
config_edit::validate_source_references(&merged)
}

Expand All @@ -78,14 +79,16 @@ fn target_exists(text: &str, target: &str) -> Result<bool> {

pub(super) fn run_bind(
sources: &[String],
to: &str,
targets: &[String],
local: bool,
refinement: &BindRefinement,
) -> Result<()> {
for source in sources {
SourceName::from_str(source)?;
}
TargetName::from_str(to)?;
for target in targets {
TargetName::from_str(target)?;
}

let cwd = Path::new(".");
let merged = merged_config(cwd)?;
Expand All @@ -94,30 +97,44 @@ pub(super) fn run_bind(
return Err(Error::Config(missing_source_message(source)));
}
}

let Some(target) = merged.targets.get(to) else {
return Err(Error::Config(missing_target_message(to, local)));
};
let target_paths = targets
.iter()
.map(|target| {
merged
.targets
.get(target)
.map(|target| target.path.to_string_lossy().into_owned())
.ok_or_else(|| Error::Config(missing_target_message(target, local)))
})
.collect::<Result<Vec<_>>>()?;

let file = target_config_file(local);
let original = read_config_text(file)?;

let mut text = original.clone();
if !target_exists(&text, to)? {
text = config_edit::upsert_target(&text, to, &target.path.to_string_lossy(), None)?;
}
let mut changed = false;
if let Some(root) = refinement.root.as_deref() {
text = config_edit::set_source_roots(&text, sources, root)?;
}
for (target, path) in targets.iter().zip(&target_paths) {
if !target_exists(&text, target)? {
text = config_edit::upsert_target(&text, target, path, None)?;
}
let result = config_edit::bind(&text, target, sources, refinement)?;
changed |= result.changed;
text = result.text;
}

let result = config_edit::bind(&text, to, sources, refinement)?;
if !result.changed && result.text == original {
render::print_bind_unchanged(sources, to);
if !changed && text == original {
for target in targets {
render::print_bind_unchanged(sources, target);
}
return Ok(());
}
guard_no_dangling_references(&result.text, local)?;
std::fs::write(file, &result.text)?;
render::print_bound(sources, to);
guard_no_dangling_references(&text, local)?;
std::fs::write(file, &text)?;
for target in targets {
render::print_bound(sources, target);
}
Ok(())
}

Expand Down
Loading
Loading