use anyhow::{Context, Result};
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use tokio::process::Command;
#[async_trait]
pub trait WorkspaceProvider: Send + Sync {
async fn provision(&self, slot_id: &str) -> Result<PathBuf>;
async fn release(&self, slot_id: &str, path: &Path) -> Result<Option<String>>;
}
#[derive(Debug, Clone)]
pub struct CwdProvider {
project_root: PathBuf,
}
impl CwdProvider {
pub fn new(project_root: impl Into<PathBuf>) -> Self {
Self {
project_root: project_root.into(),
}
}
}
#[async_trait]
impl WorkspaceProvider for CwdProvider {
async fn provision(&self, _slot_id: &str) -> Result<PathBuf> {
Ok(self.project_root.clone())
}
async fn release(&self, _slot_id: &str, _path: &Path) -> Result<Option<String>> {
Ok(None)
}
}
#[derive(Debug, Clone)]
pub struct GitWorktreeProvider {
project_root: PathBuf,
agent_name: String,
}
impl GitWorktreeProvider {
pub fn new(project_root: impl Into<PathBuf>, agent_name: impl Into<String>) -> Self {
Self {
project_root: project_root.into(),
agent_name: agent_name.into(),
}
}
fn safe_agent(&self) -> String {
let sanitised: String = self
.agent_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect();
let trimmed = sanitised.trim_matches('-');
if trimmed.is_empty() {
"agent".to_string()
} else {
trimmed.chars().take(30).collect()
}
}
fn branch_name(&self, slot_id: &str) -> String {
let short = &slot_id[..slot_id.len().min(8)];
format!("koda/wt/{}-{short}", self.safe_agent())
}
async fn is_git_repo(&self) -> bool {
let Ok(out) = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(&self.project_root)
.output()
.await
else {
return false;
};
out.status.success()
}
fn worktree_path(&self, slot_id: &str) -> PathBuf {
self.project_root
.join(".koda")
.join("worktrees")
.join(slot_id)
}
}
#[async_trait]
impl WorkspaceProvider for GitWorktreeProvider {
async fn provision(&self, slot_id: &str) -> Result<PathBuf> {
if slot_id.is_empty() || slot_id.contains('/') || slot_id.contains('\\') {
anyhow::bail!("Invalid slot_id for worktree: {slot_id:?}");
}
if !self.is_git_repo().await {
tracing::debug!(
"Not a git repo or git unavailable — skipping worktree isolation for {slot_id}"
);
return Ok(self.project_root.clone());
}
let wt_path = self.worktree_path(slot_id);
let branch = self.branch_name(slot_id);
if wt_path.exists() {
tracing::debug!("Reusing existing worktree: {}", wt_path.display());
return Ok(wt_path);
}
std::fs::create_dir_all(wt_path.parent().unwrap_or(&self.project_root))
.context("Failed to create .koda/worktrees/ directory")?;
let out = Command::new("git")
.args([
"worktree",
"add",
"-b",
&branch,
&wt_path.to_string_lossy(),
"HEAD",
])
.current_dir(&self.project_root)
.output()
.await
.context("Failed to spawn git worktree add")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("git worktree add failed: {stderr}");
}
tracing::info!(
"Provisioned worktree {} (branch {branch})",
wt_path.display()
);
Ok(wt_path)
}
async fn release(&self, slot_id: &str, path: &Path) -> Result<Option<String>> {
if path == self.project_root {
return Ok(None);
}
if !path.exists() {
return Ok(None);
}
let branch = self.branch_name(slot_id);
let status = Command::new("git")
.args(["status", "--short"])
.current_dir(path)
.output()
.await
.context("Failed to run git status in worktree")?;
let is_dirty = !String::from_utf8_lossy(&status.stdout).trim().is_empty();
if is_dirty {
Command::new("git")
.args(["add", "-A"])
.current_dir(path)
.output()
.await
.context("git add -A in worktree")?;
let commit_msg = format!("koda: sub-agent '{}' changes", self.agent_name);
let committed = Command::new("git")
.args([
"-c",
"user.name=koda",
"-c",
"user.email=koda@localhost",
"commit",
"-m",
&commit_msg,
])
.current_dir(path)
.output()
.await
.context("git commit in worktree")?;
if !committed.status.success() {
let stderr = String::from_utf8_lossy(&committed.stderr);
tracing::warn!("Worktree auto-commit failed: {stderr}");
}
self.remove_worktree(path).await;
let hint = format!(
"🌿 Sub-agent '{}' left changes on branch {branch}\n\
Review: git diff HEAD...{branch}\n\
Merge: git merge {branch}\n\
Discard: git branch -D {branch}",
self.agent_name
);
tracing::info!("Worktree committed to branch {branch}");
return Ok(Some(hint));
}
self.remove_worktree(path).await;
let _ = Command::new("git")
.args(["branch", "-D", &branch])
.current_dir(&self.project_root)
.output()
.await;
tracing::info!("Removed clean worktree for slot {slot_id}");
Ok(None)
}
}
impl GitWorktreeProvider {
async fn remove_worktree(&self, path: &Path) {
let out = Command::new("git")
.args(["worktree", "remove", "--force", &path.to_string_lossy()])
.current_dir(&self.project_root)
.output()
.await;
match out {
Ok(o) if o.status.success() => {}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!("git worktree remove failed ({stderr}), falling back to rm -rf");
let _ = tokio::fs::remove_dir_all(path).await;
}
Err(e) => {
tracing::warn!("Could not spawn git worktree remove: {e}");
let _ = tokio::fs::remove_dir_all(path).await;
}
}
}
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone)]
pub struct ClonefileProvider {
project_root: PathBuf,
clones_root: PathBuf,
}
#[cfg(target_os = "macos")]
impl ClonefileProvider {
pub fn new(project_root: impl Into<PathBuf>) -> Result<Self> {
let project_root = project_root.into();
let canonical = project_root
.canonicalize()
.with_context(|| format!("canonicalize project_root {}", project_root.display()))?;
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.context("$HOME not set; cannot derive ClonefileProvider clones_root")?;
let hash = Self::project_hash(&canonical);
let clones_root = home.join(".koda").join("clones").join(hash);
Ok(Self {
project_root: canonical,
clones_root,
})
}
pub fn with_clones_root(
project_root: impl Into<PathBuf>,
clones_root: impl Into<PathBuf>,
) -> Result<Self> {
let project_root = project_root.into();
let canonical = project_root
.canonicalize()
.with_context(|| format!("canonicalize project_root {}", project_root.display()))?;
Ok(Self {
project_root: canonical,
clones_root: clones_root.into(),
})
}
fn clone_path(&self, slot_id: &str) -> PathBuf {
self.clones_root.join(slot_id)
}
fn project_hash(canonical: &Path) -> String {
use std::os::unix::ffi::OsStrExt;
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
let mut hash = FNV_OFFSET;
for byte in canonical.as_os_str().as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{hash:016x}")
}
fn clonefile_sync(src: &Path, dst: &Path) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let src_c = CString::new(src.as_os_str().as_bytes())
.with_context(|| format!("src path contains NUL: {}", src.display()))?;
let dst_c = CString::new(dst.as_os_str().as_bytes())
.with_context(|| format!("dst path contains NUL: {}", dst.display()))?;
let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
if rc == 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
let raw = err.raw_os_error().unwrap_or(0);
let msg = match raw {
libc::ENOTSUP => format!(
"clonefile({}, {}) returned ENOTSUP — destination volume is not APFS. \
Use GitWorktreeProvider or CwdProvider on this volume.",
src.display(),
dst.display()
),
libc::EXDEV => format!(
"clonefile({}, {}) returned EXDEV — source and destination are on \
different volumes. Either move ~/.koda onto the same volume as the \
project, or use GitWorktreeProvider.",
src.display(),
dst.display()
),
_ => format!(
"clonefile({}, {}) failed: {err} (errno {raw})",
src.display(),
dst.display()
),
};
anyhow::bail!(msg)
}
}
#[cfg(target_os = "macos")]
#[async_trait]
impl WorkspaceProvider for ClonefileProvider {
async fn provision(&self, slot_id: &str) -> Result<PathBuf> {
if slot_id.is_empty() || slot_id.contains('/') || slot_id.contains('\\') {
anyhow::bail!("Invalid slot_id for clonefile: {slot_id:?}");
}
let dst = self.clone_path(slot_id);
if dst.exists() {
tracing::debug!("Reusing existing clone: {}", dst.display());
return Ok(dst);
}
if let Some(parent) = dst.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create clones_root {}", parent.display()))?;
}
let src = self.project_root.clone();
let dst_clone = dst.clone();
tokio::task::spawn_blocking(move || Self::clonefile_sync(&src, &dst_clone))
.await
.context("spawn_blocking for clonefile")??;
tracing::info!("Provisioned clone {} for slot {slot_id}", dst.display());
Ok(dst)
}
async fn release(&self, slot_id: &str, path: &Path) -> Result<Option<String>> {
let canonical = match path.canonicalize() {
Ok(c) => c,
Err(_) => return Ok(None), };
let canonical_root = self.clones_root.canonicalize().with_context(|| {
format!(
"canonicalize clones_root {} for release safety check",
self.clones_root.display()
)
})?;
if !canonical.starts_with(&canonical_root) {
anyhow::bail!(
"refusing to release {} — not under clones_root {}",
canonical.display(),
canonical_root.display()
);
}
if let Err(e) = tokio::fs::remove_dir_all(&canonical).await {
tracing::warn!(
"clonefile release of slot {slot_id} at {} hit error: {e}",
canonical.display()
);
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn cwd_provider_returns_root_unchanged() {
let root = PathBuf::from("/tmp/some-project");
let p = CwdProvider::new(&root);
assert_eq!(p.provision("slot-1").await.unwrap(), root);
}
#[tokio::test]
async fn cwd_provider_release_is_noop() {
let p = CwdProvider::new("/tmp/x");
assert!(
p.release("slot-1", Path::new("/tmp/x"))
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn cwd_provider_provision_is_idempotent() {
let p = CwdProvider::new("/tmp/x");
let a = p.provision("slot-1").await.unwrap();
let b = p.provision("slot-1").await.unwrap();
assert_eq!(a, b);
}
#[test]
fn safe_agent_strips_bad_chars() {
let p = GitWorktreeProvider::new("/tmp/proj", "my agent/name!");
assert_eq!(p.safe_agent(), "my-agent-name");
let p2 = GitWorktreeProvider::new("/tmp/proj", "!!!");
assert_eq!(p2.safe_agent(), "agent");
}
#[test]
fn branch_name_is_readable() {
let p = GitWorktreeProvider::new("/tmp/proj", "refactor");
let b = p.branch_name("abcdef1234567890");
assert_eq!(b, "koda/wt/refactor-abcdef12");
}
#[test]
fn invalid_slot_id_rejected() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let p = GitWorktreeProvider::new("/tmp/x", "agent");
rt.block_on(async {
assert!(p.provision("").await.is_err());
assert!(p.provision("foo/bar").await.is_err());
assert!(p.provision("foo\\bar").await.is_err());
});
}
async fn init_repo(path: &Path) {
for args in [
vec!["init"],
vec![
"-c",
"user.name=test",
"-c",
"user.email=t@t",
"commit",
"--allow-empty",
"-m",
"init",
],
] {
Command::new("git")
.args(&args)
.current_dir(path)
.output()
.await
.unwrap();
}
}
#[tokio::test]
async fn provision_not_git_repo_falls_back() {
let tmp = tempfile::tempdir().unwrap();
let p = GitWorktreeProvider::new(tmp.path(), "agent");
let result = p.provision("slot-abc").await.unwrap();
assert_eq!(result, tmp.path());
}
#[tokio::test]
async fn provision_in_git_repo() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path()).await;
let p = GitWorktreeProvider::new(tmp.path(), "my-agent");
let wt = p.provision("slot-001").await.unwrap();
assert!(wt.exists());
assert!(wt.ends_with("slot-001"));
assert!(
wt.join(".git").exists() || wt.join("HEAD").exists() || {
std::fs::read_to_string(wt.join(".git"))
.map(|s| s.contains("gitdir"))
.unwrap_or(false)
}
);
}
#[tokio::test]
async fn provision_reuses_existing_worktree() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path()).await;
let p = GitWorktreeProvider::new(tmp.path(), "agent");
let a = p.provision("slot-reuse").await.unwrap();
let b = p.provision("slot-reuse").await.unwrap();
assert_eq!(a, b);
}
#[tokio::test]
async fn release_clean_worktree_removes_it() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path()).await;
let p = GitWorktreeProvider::new(tmp.path(), "agent");
let wt = p.provision("slot-clean").await.unwrap();
assert!(wt.exists());
let hint = p.release("slot-clean", &wt).await.unwrap();
assert!(hint.is_none(), "clean worktree should leave no hint");
assert!(!wt.exists(), "clean worktree dir should be removed");
}
#[tokio::test]
async fn release_dirty_worktree_commits_and_hints() {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path()).await;
let p = GitWorktreeProvider::new(tmp.path(), "refactor");
let wt = p.provision("slot-dirty").await.unwrap();
std::fs::write(wt.join("output.rs"), "// generated").unwrap();
let hint = p.release("slot-dirty", &wt).await.unwrap();
let hint = hint.expect("dirty release must return a hint");
assert!(!wt.exists(), "worktree dir should be removed after commit");
let branch = "koda/wt/refactor-slot-dir"; assert!(hint.contains(branch), "{hint}");
assert!(hint.contains("git diff HEAD"), "{hint}");
assert!(hint.contains("git merge"), "{hint}");
}
#[tokio::test]
async fn release_fallback_path_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let p = GitWorktreeProvider::new(tmp.path(), "agent");
let hint = p.release("slot-x", tmp.path()).await.unwrap();
assert!(hint.is_none());
}
#[cfg(target_os = "macos")]
mod clonefile {
use super::*;
async fn clonefile_supported_at(dir: &Path) -> bool {
let src = dir.join(".probe-src");
let dst = dir.join(".probe-dst");
tokio::fs::write(&src, b"x").await.unwrap();
let result = tokio::task::spawn_blocking({
let src = src.clone();
let dst = dst.clone();
move || ClonefileProvider::clonefile_sync(&src, &dst)
})
.await
.unwrap();
let _ = tokio::fs::remove_file(&src).await;
let _ = tokio::fs::remove_file(&dst).await;
result.is_ok()
}
fn make_provider(tmp: &Path) -> (PathBuf, PathBuf, ClonefileProvider) {
let project = tmp.join("project");
let clones = tmp.join("clones");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join("hello.txt"), "world").unwrap();
let p = ClonefileProvider::with_clones_root(&project, &clones).unwrap();
(project, clones, p)
}
#[tokio::test]
async fn project_hash_is_stable_and_pathlike() {
let tmp = tempfile::tempdir().unwrap();
let canonical = tmp.path().canonicalize().unwrap();
let a = ClonefileProvider::project_hash(&canonical);
let b = ClonefileProvider::project_hash(&canonical);
assert_eq!(a, b, "hash must be deterministic across calls");
assert_eq!(a.len(), 16, "hash must be 16 hex chars");
assert!(
a.chars().all(|c| c.is_ascii_hexdigit()),
"hash must be hex-only: {a}"
);
}
#[tokio::test]
async fn project_hash_differs_for_different_paths() {
let a = ClonefileProvider::project_hash(Path::new("/tmp/proj-a"));
let b = ClonefileProvider::project_hash(Path::new("/tmp/proj-b"));
assert_ne!(a, b);
}
#[tokio::test]
async fn invalid_slot_id_rejected() {
let tmp = tempfile::tempdir().unwrap();
let (_, _, p) = make_provider(tmp.path());
assert!(p.provision("").await.is_err());
assert!(p.provision("foo/bar").await.is_err());
assert!(p.provision("foo\\bar").await.is_err());
}
#[tokio::test]
async fn provision_clones_project_tree() {
let tmp = tempfile::tempdir().unwrap();
if !clonefile_supported_at(tmp.path()).await {
eprintln!("skipping: tempdir is not on an APFS volume");
return;
}
let (_, clones, p) = make_provider(tmp.path());
let dst = p.provision("slot-1").await.unwrap();
assert_eq!(dst, clones.join("slot-1"));
assert!(dst.exists(), "clone dir should exist");
let cloned = std::fs::read_to_string(dst.join("hello.txt")).unwrap();
assert_eq!(cloned, "world");
}
#[tokio::test]
async fn provision_is_copy_on_write() {
let tmp = tempfile::tempdir().unwrap();
if !clonefile_supported_at(tmp.path()).await {
eprintln!("skipping: tempdir is not on an APFS volume");
return;
}
let (project, _, p) = make_provider(tmp.path());
let dst = p.provision("slot-cow").await.unwrap();
std::fs::write(dst.join("hello.txt"), "clobbered").unwrap();
std::fs::write(dst.join("new.txt"), "only-in-clone").unwrap();
let src_hello = std::fs::read_to_string(project.join("hello.txt")).unwrap();
assert_eq!(src_hello, "world");
assert!(!project.join("new.txt").exists());
}
#[tokio::test]
async fn provision_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
if !clonefile_supported_at(tmp.path()).await {
eprintln!("skipping: tempdir is not on an APFS volume");
return;
}
let (_, _, p) = make_provider(tmp.path());
let a = p.provision("slot-resume").await.unwrap();
std::fs::write(a.join("marker.txt"), "persisted").unwrap();
let b = p.provision("slot-resume").await.unwrap();
assert_eq!(a, b);
assert_eq!(
std::fs::read_to_string(b.join("marker.txt")).unwrap(),
"persisted"
);
}
#[tokio::test]
async fn release_removes_clone() {
let tmp = tempfile::tempdir().unwrap();
if !clonefile_supported_at(tmp.path()).await {
eprintln!("skipping: tempdir is not on an APFS volume");
return;
}
let (_, _, p) = make_provider(tmp.path());
let dst = p.provision("slot-rm").await.unwrap();
assert!(dst.exists());
let hint = p.release("slot-rm", &dst).await.unwrap();
assert!(hint.is_none());
assert!(!dst.exists(), "clone dir should be gone");
}
#[tokio::test]
async fn release_missing_path_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let (_, clones, p) = make_provider(tmp.path());
let phantom = clones.join("never-existed");
let result = p.release("never-existed", &phantom).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn release_refuses_path_outside_clones_root() {
let tmp = tempfile::tempdir().unwrap();
let (project, _, p) = make_provider(tmp.path());
let result = p.release("slot-bad", &project).await;
assert!(
result.is_err(),
"release of a path outside clones_root must error"
);
assert!(project.join("hello.txt").exists());
}
}
}