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>,
pub rev: Option<String>,
}
impl WorkspaceConfig {
pub fn directory(base: impl Into<PathBuf>) -> Self {
Self {
base: base.into(),
mode: WorkspaceMode::Directory,
repo: None,
rev: None,
}
}
pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
Self {
base: base.into(),
mode: WorkspaceMode::GitWorktree,
repo: None,
rev: 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()),
rev: None,
}
}
pub fn with_rev(mut self, rev: impl Into<String>) -> Self {
self.rev = Some(rev.into());
self
}
}
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>,
cleanup_on_drop: bool,
}
impl AgentWorkspace {
pub fn reopen_git_worktree(repo: &Path, path: &Path) -> Result<Self, String> {
let repo = repo.canonicalize().map_err(|e| e.to_string())?;
let path = path.canonicalize().map_err(|e| e.to_string())?;
let root = git_repo_root(&path)
.ok_or("retained directory is not a git worktree")?
.canonicalize()
.map_err(|e| e.to_string())?;
if root != path || repo == path || !path.join(".git").is_file() {
return Err(
"retained path must be a linked worktree root, not the user's checkout".into(),
);
}
let common = |dir: &Path| -> Result<PathBuf, String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err("cannot identify retained worktree repository".into());
}
PathBuf::from(
String::from_utf8(out.stdout)
.map_err(|e| e.to_string())?
.trim(),
)
.canonicalize()
.map_err(|e| e.to_string())
};
if common(&repo)? != common(&path)? {
return Err("retained worktree belongs to another repository".into());
}
Ok(Self {
path,
mode: WorkspaceMode::GitWorktree,
repo_root: Some(repo),
cleanup_on_drop: false,
})
}
pub fn enable_cleanup(&mut self) {
self.cleanup_on_drop = true;
}
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,
cleanup_on_drop: true,
})
}
WorkspaceMode::GitWorktree => {
use std::ffi::OsStr;
let rev = config.rev.as_deref().unwrap_or("HEAD");
if rev.is_empty() || rev.starts_with('-') {
return Err(format!("invalid worktree revision {rev:?}"));
}
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(rev),
],
)?;
Ok(Self {
path,
mode: WorkspaceMode::GitWorktree,
repo_root: Some(repo_root),
cleanup_on_drop: true,
})
}
}
}
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) {
if !self.cleanup_on_drop {
return;
}
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);
}
fn git_out(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn reopening_retained_work_is_nondestructive_and_repository_bound() {
let repo = unique_base("reopen-repo");
let other = unique_base("reopen-other");
let base = unique_base("reopen-worktrees");
for root in [&repo, &other] {
std::fs::create_dir_all(root).unwrap();
git_out(root, &["init", "-q"]);
git_out(
root,
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"--allow-empty",
"-qm",
"initial",
],
);
}
let workspace =
AgentWorkspace::provision(&WorkspaceConfig::git_worktree_at(&repo, &base), "retained")
.unwrap();
let path = workspace.path().to_path_buf();
std::fs::write(path.join("partial.txt"), "unfinished").unwrap();
std::mem::forget(workspace);
assert!(AgentWorkspace::reopen_git_worktree(&other, &path).is_err());
assert!(AgentWorkspace::reopen_git_worktree(&repo, &repo).is_err());
let reopened = AgentWorkspace::reopen_git_worktree(&repo, &path).unwrap();
drop(reopened);
assert_eq!(
std::fs::read_to_string(path.join("partial.txt")).unwrap(),
"unfinished"
);
let mut delivered = AgentWorkspace::reopen_git_worktree(&repo, &path).unwrap();
delivered.enable_cleanup();
drop(delivered);
assert!(!path.exists());
assert!(repo.is_dir());
for root in [repo, other, base] {
let _ = std::fs::remove_dir_all(root);
}
}
#[test]
fn git_worktree_checks_out_the_requested_rev_not_head() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let repo = unique_base("rev-repo");
std::fs::create_dir_all(&repo).unwrap();
let commit = ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q"];
git_out(&repo, &["init", "-q"]);
git_out(
&repo,
&[&commit[..], &["--allow-empty", "-m", "one"]].concat(),
);
let first = git_out(&repo, &["rev-parse", "HEAD"]);
git_out(
&repo,
&[&commit[..], &["--allow-empty", "-m", "two"]].concat(),
);
let head = git_out(&repo, &["rev-parse", "HEAD"]);
assert_ne!(first, head);
let base = unique_base("rev-wt");
let at_head =
AgentWorkspace::provision(&WorkspaceConfig::git_worktree_at(&repo, &base), "h")
.unwrap();
assert_eq!(git_out(at_head.path(), &["rev-parse", "HEAD"]), head);
drop(at_head);
let cfg = WorkspaceConfig::git_worktree_at(&repo, &base).with_rev(first.clone());
let ws = AgentWorkspace::provision(&cfg, "r").unwrap();
assert_eq!(git_out(ws.path(), &["rev-parse", "HEAD"]), first);
drop(ws);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn git_worktree_refuses_a_rev_that_git_would_parse_as_a_flag() {
let base = unique_base("rev-flag");
for rev in ["", "-b", "--orphan=x"] {
let cfg = WorkspaceConfig::git_worktree_at(&base, &base).with_rev(rev);
let err = AgentWorkspace::provision(&cfg, "x").unwrap_err();
assert!(err.contains("invalid worktree revision"), "{rev:?}: {err}");
}
}
#[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);
}
}