use crate::template::GitRunner;
use crate::workspace;
use slices::{SLICES, copy_dir_all, copy_matching};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
mod delete;
mod slices;
pub use delete::delete;
#[cfg(test)]
mod tests;
pub const BUNDLE_FILE: &str = "agents.bundle";
#[derive(Debug, thiserror::Error)]
pub enum ArchiveError {
#[error(transparent)]
Layout(#[from] workspace::LayoutError),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("git {op}: {source}")]
Git {
op: &'static str,
#[source]
source: io::Error,
},
#[error("no branch matches agent id {0:?} in the workspace")]
UnknownAgent(String),
#[error("bundle {0} not found")]
BundleMissing(PathBuf),
#[error("bundle names no branches")]
EmptyBundle,
#[error("bundle branches {0:?} share no common subtree root")]
MalformedBundle(Vec<String>),
#[error("replay destination {0} already exists")]
DestExists(PathBuf),
}
pub fn bundle(
ws: &Path,
agent_id: &str,
out_dir: &Path,
git: &dyn GitRunner,
) -> Result<(), ArchiveError> {
workspace::require(ws)?;
let repo = workspace::repo_git(ws);
let mut refs = subtree_refs(&repo, agent_id, git).map_err(|source| ArchiveError::Git {
op: "branch --list",
source,
})?;
if refs.is_empty() {
return Err(ArchiveError::UnknownAgent(agent_id.to_owned()));
}
refs.extend(governing_lineage(ws, agent_id, git)?);
fs::create_dir_all(out_dir)?;
let bundle_path = out_dir.join(BUNDLE_FILE);
let bundle_str = bundle_path.to_string_lossy().into_owned();
let mut args: Vec<&str> = vec!["bundle", "create", &bundle_str];
args.extend(refs.iter().map(String::as_str));
git.run(&repo, &args).map_err(|source| ArchiveError::Git {
op: "bundle create",
source,
})?;
for slice in SLICES {
copy_matching(&ws.join(slice), &out_dir.join(slice), agent_id)?;
}
Ok(())
}
pub fn replay(
archive: &Path,
scratch_base: &Path,
git: &dyn GitRunner,
) -> Result<PathBuf, ArchiveError> {
let bundle_path = archive.join(BUNDLE_FILE);
if !bundle_path.exists() {
return Err(ArchiveError::BundleMissing(bundle_path));
}
let heads = bundle_heads(archive, &bundle_path, git)?;
let primary = primary_head(&heads)?;
let scratch = scratch_base.join(primary);
if scratch.exists() {
return Err(ArchiveError::DestExists(scratch));
}
let repo = workspace::repo_git(&scratch);
fs::create_dir_all(&repo)?;
let bundle_abs = fs::canonicalize(&bundle_path)?;
let bundle_arg = bundle_abs.to_string_lossy().into_owned();
run(git, &repo, &["init", "-q", "--bare"], "init")?;
run(
git,
&repo,
&["fetch", &bundle_arg, "refs/heads/*:refs/heads/*"],
"fetch",
)?;
let primary_ref = workspace::agent_ref(primary);
let primary_wt = workspace::agent_worktree(&scratch, primary);
let primary_wt_str = primary_wt.to_string_lossy().into_owned();
run(
git,
&repo,
&["worktree", "add", &primary_wt_str, &primary_ref],
"worktree add",
)?;
for slice in SLICES {
let src = archive.join(slice);
if src.is_dir() {
copy_dir_all(&src, &scratch.join(slice))?;
}
}
Ok(scratch)
}
pub fn replay_cli(archive: &Path) -> Result<PathBuf, ArchiveError> {
let roots = crate::harness_root::resolve().map_err(io::Error::other)?;
replay(
archive,
&roots.data.join("replays"),
&crate::template::RealGit::new(),
)
}
fn subtree_refs(repo: &Path, agent_id: &str, git: &dyn GitRunner) -> io::Result<Vec<String>> {
let subtree_root = workspace::agent_ref(agent_id);
let descendants = format!("{subtree_root}-*");
let out = git.run_capture(
repo,
&[
"branch",
"--list",
"--format=%(refname:short)",
subtree_root.as_str(),
&descendants,
],
)?;
Ok(out
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect())
}
fn governing_lineage(
ws: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> Result<Vec<String>, ArchiveError> {
let lineage =
workspace::config_lineage(ws, &workspace::agent_ref(agent_id), git).map_err(|source| {
ArchiveError::Git {
op: "config lineage",
source,
}
})?;
Ok(lineage.into_iter().map(|(head, _)| head).collect())
}
fn bundle_heads(
dir: &Path,
bundle_path: &Path,
git: &dyn GitRunner,
) -> Result<Vec<String>, ArchiveError> {
let bundle_str = bundle_path.to_string_lossy().into_owned();
let out = git
.run_capture(dir, &["bundle", "list-heads", &bundle_str])
.map_err(|source| ArchiveError::Git {
op: "bundle list-heads",
source,
})?;
Ok(out
.lines()
.filter_map(|l| {
let refname = l.split_whitespace().nth(1)?;
let short = refname.strip_prefix("refs/heads/").unwrap_or(refname);
Some(short.strip_prefix(workspace::AGENT_REF_PREFIX)?.to_owned())
})
.collect())
}
fn primary_head(heads: &[String]) -> Result<&str, ArchiveError> {
let primary = heads
.iter()
.min_by_key(|h| h.len())
.ok_or(ArchiveError::EmptyBundle)?;
let prefix = format!("{primary}-");
for h in heads {
if h != primary && !h.starts_with(&prefix) {
return Err(ArchiveError::MalformedBundle(heads.to_vec()));
}
}
Ok(primary)
}
fn run(
git: &dyn GitRunner,
dest: &Path,
args: &[&str],
op: &'static str,
) -> Result<(), ArchiveError> {
git.run(dest, args)
.map_err(|source| ArchiveError::Git { op, source })
}