use crate::template::GitRunner;
use std::io;
use std::path::{Path, PathBuf};
pub const REPO_DIR: &str = "repo.git";
pub const AGENTS_DIR: &str = "agents";
pub const AGENT_REF_PREFIX: &str = "agents/";
pub const CONFIG_REF_PREFIX: &str = "config/";
pub const DEFAULT_CONFIG_REF: &str = "config/default";
pub fn config_ref(name: &str) -> String {
format!("{CONFIG_REF_PREFIX}{name}")
}
pub const CONTROL_PATHS: &[&str] = &[
"manifest.yaml",
"workflow.yaml",
"providers.yaml",
"version",
"souls",
];
pub fn repo_git(workspace: &Path) -> PathBuf {
workspace.join(REPO_DIR)
}
pub fn agent_worktree(workspace: &Path, agent_id: &str) -> PathBuf {
workspace.join(AGENTS_DIR).join(agent_id)
}
pub fn agent_ref(agent_id: &str) -> String {
format!("{AGENT_REF_PREFIX}{agent_id}")
}
pub fn config_head(workspace: &Path, config_ref: &str, git: &dyn GitRunner) -> io::Result<String> {
let refspec = format!("refs/heads/{config_ref}");
git.run_capture(&repo_git(workspace), &["rev-parse", "--verify", &refspec])
}
fn ref_names(workspace: &Path, prefix: &str, git: &dyn GitRunner) -> io::Result<Vec<String>> {
let pattern = format!("refs/heads/{prefix}");
let out = git.run_capture(
&repo_git(workspace),
&["for-each-ref", "--format=%(refname:short)", &pattern],
)?;
Ok(out
.lines()
.map(str::trim)
.filter_map(|r| r.strip_prefix(prefix))
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect())
}
pub fn agent_ids(workspace: &Path, git: &dyn GitRunner) -> io::Result<Vec<String>> {
ref_names(workspace, AGENT_REF_PREFIX, git)
}
pub fn config_names(workspace: &Path, git: &dyn GitRunner) -> io::Result<Vec<String>> {
ref_names(workspace, CONFIG_REF_PREFIX, git)
}
pub fn config_lineage(
workspace: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> io::Result<Vec<(String, String)>> {
let repo = repo_git(workspace);
let heads = git.run_capture(
&repo,
&["for-each-ref", "--format=%(refname)", "refs/heads/config/"],
)?;
let target = agent_ref(agent_id);
Ok(heads
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.filter_map(|head| {
let base = git
.run_capture(&repo, &["merge-base", &target, head])
.ok()?;
Some((head.to_owned(), base))
})
.collect())
}
pub fn governing_config(
workspace: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> io::Result<String> {
let repo = repo_git(workspace);
let mut best: Option<String> = None;
for (_, base) in config_lineage(workspace, agent_id, git)? {
best = Some(match best {
None => base,
Some(prev) if prev == base => prev,
Some(prev) => nearest(&repo, prev, base, git)?,
});
}
best.ok_or_else(|| {
io::Error::other(format!(
"no config/* ancestor for {} — every agent forks off a config commit (§2.2)",
agent_ref(agent_id)
))
})
}
fn nearest(repo: &Path, a: String, b: String, git: &dyn GitRunner) -> io::Result<String> {
if git
.run(repo, &["merge-base", "--is-ancestor", &a, &b])
.is_ok()
{
return Ok(b);
}
if git
.run(repo, &["merge-base", "--is-ancestor", &b, &a])
.is_ok()
{
return Ok(a);
}
Err(io::Error::other(format!(
"governing config is ambiguous: candidates {a} and {b} are incomparable ancestors \
— declined (§2.2, PRINCIPLES)"
)))
}
pub fn show_control(
workspace: &Path,
commit: &str,
path: &str,
git: &dyn GitRunner,
) -> io::Result<String> {
let spec = format!("{commit}:{path}");
git.run_capture(&repo_git(workspace), &["show", &spec])
}
pub fn control_exists(workspace: &Path, commit: &str, path: &str, git: &dyn GitRunner) -> bool {
let spec = format!("{commit}:{path}");
git.run(&repo_git(workspace), &["cat-file", "-e", &spec])
.is_ok()
}
mod guard;
pub use guard::{LayoutError, UnknownAgent, agent_exists, require, require_agent};
#[cfg(test)]
pub(crate) mod fixture;
#[cfg(test)]
mod tests;