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 agent_exists(workspace: &Path, agent_id: &str, git: &dyn GitRunner) -> bool {
let refspec = format!("refs/heads/{}", agent_ref(agent_id));
git.run(
&repo_git(workspace),
&["rev-parse", "--verify", "--quiet", &refspec],
)
.is_ok()
}
#[derive(Debug, thiserror::Error)]
pub enum LayoutError {
#[error(
"{0} uses the retired per-conversation layout (a `root/` primary worktree with \
control files at the repo root); the current layout is one repo per workspace — \
`<workspace>/repo.git` (bare) with `config/*` branches and `agents/*` refs \
(ARCH §2.2). Pre-v1 clean break (§10): no migration — create a fresh workspace \
with `lernie new` and re-author its config"
)]
OldLayout(PathBuf),
#[error("{0} is not a workspace (no repo.git) — create one with `lernie new` (ARCH §2.2)")]
NotAWorkspace(PathBuf),
}
pub fn require(workspace: &Path) -> Result<(), LayoutError> {
if repo_git(workspace).is_dir() {
return Ok(());
}
if workspace.join("root").join(".git").exists() {
return Err(LayoutError::OldLayout(workspace.to_path_buf()));
}
Err(LayoutError::NotAWorkspace(workspace.to_path_buf()))
}
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])
}
pub fn agent_ids(workspace: &Path, git: &dyn GitRunner) -> io::Result<Vec<String>> {
let out = git.run_capture(
&repo_git(workspace),
&[
"for-each-ref",
"--format=%(refname:short)",
"refs/heads/agents/",
],
)?;
Ok(out
.lines()
.map(str::trim)
.filter_map(|r| r.strip_prefix(AGENT_REF_PREFIX))
.filter(|id| !id.is_empty())
.map(str::to_string)
.collect())
}
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()
}
#[cfg(test)]
pub(crate) mod fixture;
#[cfg(test)]
mod tests;