use crate::types::AgentSpec;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::{Path, PathBuf};
pub const WORKSPACE_METADATA_KEY: &str = "workspace";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceMode {
Directory,
GitWorktree,
}
#[derive(Debug, Clone)]
pub struct WorkspaceConfig {
pub base: PathBuf,
pub mode: WorkspaceMode,
pub repo: Option<PathBuf>,
}
impl WorkspaceConfig {
pub fn directory(base: impl Into<PathBuf>) -> Self {
Self {
base: base.into(),
mode: WorkspaceMode::Directory,
repo: None,
}
}
pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
Self {
base: base.into(),
mode: WorkspaceMode::GitWorktree,
repo: None,
}
}
pub fn git_worktree_at(repo: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
Self {
base: base.into(),
mode: WorkspaceMode::GitWorktree,
repo: Some(repo.into()),
}
}
}
fn sanitize(name: &str) -> String {
let s: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
if s.is_empty() {
"agent".to_string()
} else {
s
}
}
#[derive(Debug)]
pub struct AgentWorkspace {
path: PathBuf,
mode: WorkspaceMode,
repo_root: Option<PathBuf>,
}
impl AgentWorkspace {
pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
let path = config.base.join(sanitize(agent_name));
match config.mode {
WorkspaceMode::Directory => {
std::fs::create_dir_all(&path)
.map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
Ok(Self {
path,
mode: WorkspaceMode::Directory,
repo_root: None,
})
}
WorkspaceMode::GitWorktree => {
use std::ffi::OsStr;
let repo_hint = config.repo.as_ref().unwrap_or(&config.base);
let repo_root = git_repo_root(repo_hint).ok_or_else(|| {
format!(
"git_worktree workspace requires {} to be inside a git repo",
repo_hint.display()
)
})?;
std::fs::create_dir_all(&config.base)
.map_err(|e| format!("create workspace base {}: {e}", config.base.display()))?;
let _ = run_git(
&repo_root,
&[
OsStr::new("worktree"),
OsStr::new("remove"),
OsStr::new("--force"),
path.as_os_str(),
],
);
let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
if path.exists() {
let _ = std::fs::remove_dir_all(&path);
}
run_git(
&repo_root,
&[
OsStr::new("worktree"),
OsStr::new("add"),
OsStr::new("--detach"),
path.as_os_str(),
OsStr::new("HEAD"),
],
)?;
Ok(Self {
path,
mode: WorkspaceMode::GitWorktree,
repo_root: Some(repo_root),
})
}
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
spec.metadata.insert(
WORKSPACE_METADATA_KEY.to_string(),
Value::String(self.path.to_string_lossy().into_owned()),
);
spec
}
}
impl Drop for AgentWorkspace {
fn drop(&mut self) {
match self.mode {
WorkspaceMode::Directory => {
let _ = std::fs::remove_dir_all(&self.path);
}
WorkspaceMode::GitWorktree => {
if let Some(root) = &self.repo_root {
use std::ffi::OsStr;
let _ = run_git(
root,
&[
OsStr::new("worktree"),
OsStr::new("remove"),
OsStr::new("--force"),
self.path.as_os_str(),
],
);
let _ = std::fs::remove_dir_all(&self.path);
}
}
}
}
}
fn git_repo_root(dir: &Path) -> Option<PathBuf> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
if root.is_empty() {
None
} else {
Some(PathBuf::from(root))
}
}
fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(args)
.output()
.map_err(|e| format!("git {:?}: {e}", args))?;
if out.status.success() {
Ok(())
} else {
Err(format!(
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr).trim()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_base(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
}
#[test]
fn directory_workspace_is_created_injected_and_cleaned() {
let base = unique_base("dir");
let cfg = WorkspaceConfig::directory(&base);
let path;
{
let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
path = ws.path().to_path_buf();
assert!(path.exists() && path.is_dir());
assert_eq!(path.parent().unwrap(), base);
assert!(!path.to_string_lossy().contains(".."));
let spec = ws.inject(AgentSpec::new("alice", "sys"));
assert_eq!(
spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
&Value::String(path.to_string_lossy().into_owned())
);
}
assert!(!path.exists(), "workspace should be removed on drop");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn git_worktree_at_provisions_outside_the_repo() {
if std::process::Command::new("git").arg("--version").output().is_err() {
return;
}
let repo = unique_base("repo");
std::fs::create_dir_all(&repo).unwrap();
for args in [
vec!["init", "-q"],
vec!["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
let base = unique_base("wt-base");
let cfg = WorkspaceConfig::git_worktree_at(&repo, &base);
let path;
{
let ws = AgentWorkspace::provision(&cfg, "session-1").unwrap();
path = ws.path().to_path_buf();
assert!(path.starts_with(&base), "worktree must live under base, not the repo");
assert!(path.join(".git").exists(), "worktree checkout expected");
let out = std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(out.stdout.is_empty(), "repo status must stay clean");
}
assert!(!path.exists(), "worktree removed on drop");
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn distinct_agents_get_distinct_dirs() {
let base = unique_base("distinct");
let cfg = WorkspaceConfig::directory(&base);
let a = AgentWorkspace::provision(&cfg, "a").unwrap();
let b = AgentWorkspace::provision(&cfg, "b").unwrap();
assert_ne!(a.path(), b.path());
drop(a);
drop(b);
let _ = std::fs::remove_dir_all(&base);
}
}