use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
const WORKTREE_MAX_AGE_SECS: u64 = 86_400;
pub struct WorktreeManager {
repo_path: PathBuf,
worktree_base: PathBuf,
worktrees: RwLock<HashMap<String, AgentWorktree>>,
config: WorktreeConfig,
}
#[derive(Debug, Clone)]
pub struct WorktreeConfig {
pub max_age: Duration,
pub auto_cleanup: bool,
pub prefix: String,
pub max_worktrees: usize,
}
impl Default for WorktreeConfig {
fn default() -> Self {
Self {
max_age: Duration::from_secs(WORKTREE_MAX_AGE_SECS),
auto_cleanup: true,
prefix: "agent-wt-".to_string(),
max_worktrees: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentWorktree {
pub agent_id: String,
pub path: PathBuf,
pub branch: String,
#[serde(skip, default = "Instant::now")]
pub created_at: Instant,
#[serde(skip, default = "Instant::now")]
pub last_accessed: Instant,
pub has_changes: bool,
pub purpose: String,
}
impl AgentWorktree {
pub fn is_stale(&self, max_age: Duration) -> bool {
self.last_accessed.elapsed() > max_age
}
pub fn age(&self) -> Duration {
self.created_at.elapsed()
}
}
#[derive(Debug)]
pub enum WorktreeResult {
Created(AgentWorktree),
AlreadyExists(AgentWorktree),
Removed {
path: PathBuf,
},
Error(String),
}
impl WorktreeResult {
pub fn is_success(&self) -> bool {
matches!(
self,
WorktreeResult::Created(_)
| WorktreeResult::AlreadyExists(_)
| WorktreeResult::Removed { .. }
)
}
pub fn worktree(&self) -> Option<&AgentWorktree> {
match self {
WorktreeResult::Created(wt) | WorktreeResult::AlreadyExists(wt) => Some(wt),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct GitWorktreeInfo {
pub path: PathBuf,
pub head: String,
pub branch: Option<String>,
pub bare: bool,
}
impl WorktreeManager {
pub fn new(repo_path: impl Into<PathBuf>) -> Self {
let repo_path = repo_path.into();
let worktree_base = repo_path.join(".worktrees");
Self {
repo_path,
worktree_base,
worktrees: RwLock::new(HashMap::new()),
config: WorktreeConfig::default(),
}
}
pub fn with_config(repo_path: impl Into<PathBuf>, config: WorktreeConfig) -> Self {
let repo_path = repo_path.into();
let worktree_base = repo_path.join(".worktrees");
Self {
repo_path,
worktree_base,
worktrees: RwLock::new(HashMap::new()),
config,
}
}
pub fn with_worktree_base(mut self, base: impl Into<PathBuf>) -> Self {
self.worktree_base = base.into();
self
}
pub async fn get_or_create_worktree(
&self,
agent_id: &str,
branch: &str,
purpose: &str,
) -> WorktreeResult {
{
let worktrees = self.worktrees.read().await;
if let Some(existing) = worktrees.get(agent_id) {
return WorktreeResult::AlreadyExists(existing.clone());
}
}
let worktrees = self.worktrees.read().await;
if worktrees.len() >= self.config.max_worktrees {
drop(worktrees);
if self.config.auto_cleanup {
self.cleanup_stale_worktrees().await;
let worktrees = self.worktrees.read().await;
if worktrees.len() >= self.config.max_worktrees {
return WorktreeResult::Error(format!(
"Maximum worktrees ({}) reached",
self.config.max_worktrees
));
}
} else {
return WorktreeResult::Error(format!(
"Maximum worktrees ({}) reached",
self.config.max_worktrees
));
}
} else {
drop(worktrees);
}
self.create_worktree(agent_id, branch, purpose).await
}
async fn create_worktree(&self, agent_id: &str, branch: &str, purpose: &str) -> WorktreeResult {
if let Err(e) = std::fs::create_dir_all(&self.worktree_base) {
return WorktreeResult::Error(format!("Failed to create worktree base: {}", e));
}
let worktree_name = format!(
"{}{}",
self.config.prefix,
agent_id.replace(['/', '\\', ' '], "-")
);
let worktree_path = self.worktree_base.join(&worktree_name);
let branch_exists = self.branch_exists(branch);
let mut cmd = Command::new("git");
cmd.current_dir(&self.repo_path).arg("worktree").arg("add");
if branch_exists {
cmd.arg(&worktree_path).arg(branch);
} else {
cmd.arg("-b").arg(branch).arg(&worktree_path);
}
let output = match cmd.output() {
Ok(o) => o,
Err(e) => {
return WorktreeResult::Error(format!("Failed to run git worktree add: {}", e));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return WorktreeResult::Error(format!("git worktree add failed: {}", stderr));
}
let worktree = AgentWorktree {
agent_id: agent_id.to_string(),
path: worktree_path,
branch: branch.to_string(),
created_at: Instant::now(),
last_accessed: Instant::now(),
has_changes: false,
purpose: purpose.to_string(),
};
self.worktrees
.write()
.await
.insert(agent_id.to_string(), worktree.clone());
WorktreeResult::Created(worktree)
}
fn branch_exists(&self, branch: &str) -> bool {
let output = Command::new("git")
.current_dir(&self.repo_path)
.args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)])
.output();
matches!(output, Ok(o) if o.status.success())
}
pub async fn get_worktree(&self, agent_id: &str) -> Option<AgentWorktree> {
let mut worktrees = self.worktrees.write().await;
if let Some(worktree) = worktrees.get_mut(agent_id) {
worktree.last_accessed = Instant::now();
Some(worktree.clone())
} else {
None
}
}
pub async fn remove_worktree(&self, agent_id: &str, force: bool) -> WorktreeResult {
let worktree = {
let worktrees = self.worktrees.read().await;
worktrees.get(agent_id).cloned()
};
let worktree = match worktree {
Some(wt) => wt,
None => {
return WorktreeResult::Error(format!("No worktree found for agent {}", agent_id));
}
};
if !force && self.has_uncommitted_changes(&worktree.path) {
return WorktreeResult::Error(
"Worktree has uncommitted changes. Use force=true to remove anyway.".to_string(),
);
}
let mut cmd = Command::new("git");
cmd.current_dir(&self.repo_path)
.args(["worktree", "remove"]);
if force {
cmd.arg("--force");
}
cmd.arg(&worktree.path);
let output = match cmd.output() {
Ok(o) => o,
Err(e) => {
return WorktreeResult::Error(format!("Failed to run git worktree remove: {}", e));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return WorktreeResult::Error(format!("git worktree remove failed: {}", stderr));
}
self.worktrees.write().await.remove(agent_id);
WorktreeResult::Removed {
path: worktree.path,
}
}
fn has_uncommitted_changes(&self, worktree_path: &Path) -> bool {
let output = Command::new("git")
.current_dir(worktree_path)
.args(["status", "--porcelain"])
.output();
match output {
Ok(o) if o.status.success() => !o.stdout.is_empty(),
_ => false, }
}
pub async fn cleanup_stale_worktrees(&self) -> Vec<String> {
let mut removed = Vec::new();
let stale_agents: Vec<String> = {
let worktrees = self.worktrees.read().await;
worktrees
.iter()
.filter(|(_, wt)| wt.is_stale(self.config.max_age) && !wt.has_changes)
.map(|(id, _)| id.clone())
.collect()
};
for agent_id in stale_agents {
if let WorktreeResult::Removed { .. } = self.remove_worktree(&agent_id, false).await {
removed.push(agent_id);
}
}
removed
}
pub async fn list_all_worktrees(&self) -> Result<Vec<GitWorktreeInfo>, String> {
let output = Command::new("git")
.current_dir(&self.repo_path)
.args(["worktree", "list", "--porcelain"])
.output()
.map_err(|e| format!("Failed to run git worktree list: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git worktree list failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(Self::parse_worktree_list(&stdout))
}
fn parse_worktree_list(output: &str) -> Vec<GitWorktreeInfo> {
let mut worktrees = Vec::new();
let mut current: Option<GitWorktreeInfo> = None;
for line in output.lines() {
if line.starts_with("worktree ") {
if let Some(wt) = current.take() {
worktrees.push(wt);
}
current = Some(GitWorktreeInfo {
path: PathBuf::from(line.trim_start_matches("worktree ")),
head: String::new(),
branch: None,
bare: false,
});
} else if let Some(ref mut wt) = current {
if line.starts_with("HEAD ") {
wt.head = line.trim_start_matches("HEAD ").to_string();
} else if line.starts_with("branch refs/heads/") {
wt.branch = Some(line.trim_start_matches("branch refs/heads/").to_string());
} else if line == "bare" {
wt.bare = true;
}
}
}
if let Some(wt) = current {
worktrees.push(wt);
}
worktrees
}
pub async fn list_tracked_worktrees(&self) -> Vec<AgentWorktree> {
self.worktrees.read().await.values().cloned().collect()
}
pub async fn sync_with_git(&self) -> Result<SyncResult, String> {
let git_worktrees = self.list_all_worktrees().await?;
let mut tracked = self.worktrees.write().await;
let mut added = 0;
let mut removed = 0;
for git_wt in &git_worktrees {
if git_wt.bare || git_wt.branch.is_none() {
continue;
}
let path_str = git_wt.path.to_string_lossy();
if path_str.contains(&self.config.prefix) {
if let Some(name) = git_wt.path.file_name() {
let name_str = name.to_string_lossy();
if let Some(agent_id) = name_str.strip_prefix(&self.config.prefix)
&& !tracked.contains_key(agent_id)
{
tracked.insert(
agent_id.to_string(),
AgentWorktree {
agent_id: agent_id.to_string(),
path: git_wt.path.clone(),
branch: git_wt.branch.clone().unwrap_or_default(),
created_at: Instant::now(),
last_accessed: Instant::now(),
has_changes: false,
purpose: "Discovered via sync".to_string(),
},
);
added += 1;
}
}
}
}
let git_paths: std::collections::HashSet<_> =
git_worktrees.iter().map(|wt| &wt.path).collect();
let to_remove: Vec<_> = tracked
.iter()
.filter(|(_, wt)| !git_paths.contains(&wt.path))
.map(|(id, _)| id.clone())
.collect();
for id in to_remove {
tracked.remove(&id);
removed += 1;
}
Ok(SyncResult { added, removed })
}
pub async fn update_changes_status(&self, agent_id: &str) -> bool {
let mut worktrees = self.worktrees.write().await;
if let Some(worktree) = worktrees.get_mut(agent_id) {
worktree.has_changes = self.has_uncommitted_changes(&worktree.path);
worktree.has_changes
} else {
false
}
}
pub async fn get_working_directory(&self, agent_id: &str) -> PathBuf {
let worktrees = self.worktrees.read().await;
if let Some(worktree) = worktrees.get(agent_id) {
worktree.path.clone()
} else {
self.repo_path.clone()
}
}
pub async fn get_stats(&self) -> WorktreeStats {
let worktrees = self.worktrees.read().await;
WorktreeStats {
total_tracked: worktrees.len(),
with_changes: worktrees.values().filter(|wt| wt.has_changes).count(),
stale: worktrees
.values()
.filter(|wt| wt.is_stale(self.config.max_age))
.count(),
max_allowed: self.config.max_worktrees,
}
}
}
#[derive(Debug, Clone)]
pub struct SyncResult {
pub added: usize,
pub removed: usize,
}
#[derive(Debug, Clone)]
pub struct WorktreeStats {
pub total_tracked: usize,
pub with_changes: usize,
pub stale: usize,
pub max_allowed: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_worktree_config_default() {
let config = WorktreeConfig::default();
assert_eq!(config.max_worktrees, 10);
assert!(config.auto_cleanup);
assert_eq!(config.prefix, "agent-wt-");
}
#[test]
fn test_agent_worktree_staleness() {
let worktree = AgentWorktree {
agent_id: "test-agent".to_string(),
path: PathBuf::from("/tmp/test"),
branch: "feature".to_string(),
created_at: Instant::now() - Duration::from_secs(3600),
last_accessed: Instant::now() - Duration::from_secs(3600),
has_changes: false,
purpose: "test".to_string(),
};
assert!(worktree.is_stale(Duration::from_secs(1800)));
assert!(!worktree.is_stale(Duration::from_secs(7200)));
}
#[test]
fn test_parse_worktree_list() {
let output = r#"worktree /home/user/repo
HEAD abc123
branch refs/heads/main
worktree /home/user/repo/.worktrees/feature
HEAD def456
branch refs/heads/feature
"#;
let worktrees = WorktreeManager::parse_worktree_list(output);
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, PathBuf::from("/home/user/repo"));
assert_eq!(worktrees[0].head, "abc123");
assert_eq!(worktrees[0].branch, Some("main".to_string()));
assert_eq!(
worktrees[1].path,
PathBuf::from("/home/user/repo/.worktrees/feature")
);
assert_eq!(worktrees[1].branch, Some("feature".to_string()));
}
#[test]
fn test_worktree_result_success() {
let worktree = AgentWorktree {
agent_id: "test".to_string(),
path: PathBuf::from("/tmp/test"),
branch: "main".to_string(),
created_at: Instant::now(),
last_accessed: Instant::now(),
has_changes: false,
purpose: "test".to_string(),
};
let created = WorktreeResult::Created(worktree.clone());
assert!(created.is_success());
assert!(created.worktree().is_some());
let exists = WorktreeResult::AlreadyExists(worktree);
assert!(exists.is_success());
let removed = WorktreeResult::Removed {
path: PathBuf::from("/tmp/test"),
};
assert!(removed.is_success());
let error = WorktreeResult::Error("test error".to_string());
assert!(!error.is_success());
assert!(error.worktree().is_none());
}
#[tokio::test]
async fn test_worktree_manager_creation() {
let temp_dir = env::temp_dir().join("test-worktree-manager");
let manager = WorktreeManager::new(&temp_dir);
assert_eq!(manager.repo_path, temp_dir);
assert_eq!(manager.worktree_base, temp_dir.join(".worktrees"));
}
#[tokio::test]
async fn test_worktree_stats() {
let temp_dir = env::temp_dir().join("test-worktree-stats");
let manager = WorktreeManager::new(&temp_dir);
{
let mut worktrees = manager.worktrees.write().await;
worktrees.insert(
"agent-1".to_string(),
AgentWorktree {
agent_id: "agent-1".to_string(),
path: PathBuf::from("/tmp/wt1"),
branch: "feature-1".to_string(),
created_at: Instant::now(),
last_accessed: Instant::now(),
has_changes: false,
purpose: "test".to_string(),
},
);
worktrees.insert(
"agent-2".to_string(),
AgentWorktree {
agent_id: "agent-2".to_string(),
path: PathBuf::from("/tmp/wt2"),
branch: "feature-2".to_string(),
created_at: Instant::now(),
last_accessed: Instant::now(),
has_changes: true, purpose: "test".to_string(),
},
);
}
let stats = manager.get_stats().await;
assert_eq!(stats.total_tracked, 2);
assert_eq!(stats.with_changes, 1);
assert_eq!(stats.max_allowed, 10);
}
#[tokio::test]
async fn test_get_working_directory() {
let temp_dir = env::temp_dir().join("test-working-dir");
let manager = WorktreeManager::new(&temp_dir);
let dir = manager.get_working_directory("unknown-agent").await;
assert_eq!(dir, temp_dir);
{
let mut worktrees = manager.worktrees.write().await;
worktrees.insert(
"agent-1".to_string(),
AgentWorktree {
agent_id: "agent-1".to_string(),
path: PathBuf::from("/tmp/agent-1-worktree"),
branch: "feature".to_string(),
created_at: Instant::now(),
last_accessed: Instant::now(),
has_changes: false,
purpose: "test".to_string(),
},
);
}
let dir = manager.get_working_directory("agent-1").await;
assert_eq!(dir, PathBuf::from("/tmp/agent-1-worktree"));
}
#[tokio::test]
async fn test_list_tracked_worktrees() {
let temp_dir = env::temp_dir().join("test-list-tracked");
let manager = WorktreeManager::new(&temp_dir);
{
let mut worktrees = manager.worktrees.write().await;
for i in 0..3 {
worktrees.insert(
format!("agent-{}", i),
AgentWorktree {
agent_id: format!("agent-{}", i),
path: PathBuf::from(format!("/tmp/wt{}", i)),
branch: format!("feature-{}", i),
created_at: Instant::now(),
last_accessed: Instant::now(),
has_changes: false,
purpose: "test".to_string(),
},
);
}
}
let tracked = manager.list_tracked_worktrees().await;
assert_eq!(tracked.len(), 3);
}
#[test]
fn test_worktree_age() {
let worktree = AgentWorktree {
agent_id: "test".to_string(),
path: PathBuf::from("/tmp/test"),
branch: "main".to_string(),
created_at: Instant::now() - Duration::from_secs(120),
last_accessed: Instant::now(),
has_changes: false,
purpose: "test".to_string(),
};
let age = worktree.age();
assert!(age >= Duration::from_secs(119)); }
}