use crate::git_tree::{
GitTreeError, REPO_DIR, for_each_ref_config, is_ancestor, ls_tree, merge_base, show_file,
};
use std::path::Path;
pub mod edit;
pub mod follow;
pub use follow::{Governance, GoverningConfig};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigBranch {
pub name: String,
pub tip_oid: String,
pub tip_short_oid: String,
pub tip_timestamp_unix: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Lineage {
pub branch: ConfigBranch,
pub files: Vec<String>,
}
fn short(oid: &str) -> String {
oid.get(..8).unwrap_or(oid).to_string()
}
pub fn config_branches(workspace: &Path) -> Result<Vec<ConfigBranch>, GitTreeError> {
parse_branches(&for_each_ref_config(&workspace.join(REPO_DIR))?)
}
fn parse_branches(stdout: &[u8]) -> Result<Vec<ConfigBranch>, GitTreeError> {
let text = String::from_utf8_lossy(stdout);
let mut out = Vec::new();
for line in text.lines() {
let mut it = line.splitn(3, ' ');
match (it.next(), it.next(), it.next()) {
(Some(refname), Some(oid), Some(ts)) => out.push(ConfigBranch {
name: refname
.strip_prefix("config/")
.unwrap_or(refname)
.to_string(),
tip_short_oid: short(oid),
tip_oid: oid.to_string(),
tip_timestamp_unix: ts
.parse()
.map_err(|_| GitTreeError::LogFormat(line.to_string()))?,
}),
_ => return Err(GitTreeError::LogFormat(line.to_string())),
}
}
Ok(out)
}
pub fn config_tree(workspace: &Path, refspec: &str) -> Result<Vec<String>, GitTreeError> {
tree_paths(&workspace.join(REPO_DIR), refspec)
}
fn tree_paths(repo: &Path, refspec: &str) -> Result<Vec<String>, GitTreeError> {
let out = ls_tree(repo, refspec)?;
Ok(String::from_utf8_lossy(&out)
.lines()
.map(str::to_string)
.collect())
}
pub fn lineages(workspace: &Path) -> Result<Vec<Lineage>, GitTreeError> {
let repo = workspace.join(REPO_DIR);
let branches = parse_branches(&for_each_ref_config(&repo)?)?;
let mut out = Vec::new();
for branch in branches {
let files = tree_paths(&repo, &branch.tip_oid)?;
out.push(Lineage { branch, files });
}
Ok(out)
}
pub fn config_file(workspace: &Path, refspec: &str, path: &str) -> Result<Vec<u8>, GitTreeError> {
show_file(&workspace.join(REPO_DIR), refspec, path)
}
pub fn governing_config(
workspace: &Path,
agent_tip: &str,
) -> Result<GoverningConfig, GitTreeError> {
let repo = workspace.join(REPO_DIR);
let branches = parse_branches(&for_each_ref_config(&repo)?)?;
let mut best: Option<String> = None;
for b in &branches {
let Some(base) = merge_base(&repo, agent_tip, &b.tip_oid)? else {
continue;
};
best = Some(match best {
None => base,
Some(prev) if prev == base => prev,
Some(prev) => nearest(&repo, prev, base)?,
});
}
let fork = best.ok_or_else(|| {
GitTreeError::Governing(format!(
"no config/* ancestor for {agent_tip} — every agent forks off a config commit"
))
})?;
let (oid, governance) = follow::resolve(&repo, &branches, &fork)?;
let files = tree_paths(&repo, &oid)?;
Ok(GoverningConfig {
short_oid: short(&oid),
oid,
governance,
files,
})
}
fn nearest(repo: &Path, a: String, b: String) -> Result<String, GitTreeError> {
if is_ancestor(repo, &a, &b)? {
return Ok(b);
}
if is_ancestor(repo, &b, &a)? {
return Ok(a);
}
Err(GitTreeError::Governing(format!(
"ambiguous governing config: {a} and {b} are incomparable ancestors — declined"
)))
}
#[cfg(test)]
mod tests;