1use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22use std::time::{Duration, Instant};
23
24use serde::{Deserialize, Serialize};
25use tokio::sync::RwLock;
26
27const WORKTREE_MAX_AGE_SECS: u64 = 86_400;
28
29pub struct WorktreeManager {
31 repo_path: PathBuf,
33 worktree_base: PathBuf,
35 worktrees: RwLock<HashMap<String, AgentWorktree>>,
37 config: WorktreeConfig,
39}
40
41#[derive(Debug, Clone)]
43pub struct WorktreeConfig {
44 pub max_age: Duration,
46 pub auto_cleanup: bool,
48 pub prefix: String,
50 pub max_worktrees: usize,
52}
53
54impl Default for WorktreeConfig {
55 fn default() -> Self {
56 Self {
57 max_age: Duration::from_secs(WORKTREE_MAX_AGE_SECS),
58 auto_cleanup: true,
59 prefix: "agent-wt-".to_string(),
60 max_worktrees: 10,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct AgentWorktree {
68 pub agent_id: String,
70 pub path: PathBuf,
72 pub branch: String,
74 #[serde(skip, default = "Instant::now")]
76 pub created_at: Instant,
77 #[serde(skip, default = "Instant::now")]
79 pub last_accessed: Instant,
80 pub has_changes: bool,
82 pub purpose: String,
84}
85
86impl AgentWorktree {
87 pub fn is_stale(&self, max_age: Duration) -> bool {
89 self.last_accessed.elapsed() > max_age
90 }
91
92 pub fn age(&self) -> Duration {
94 self.created_at.elapsed()
95 }
96}
97
98#[derive(Debug)]
100pub enum WorktreeResult {
101 Created(AgentWorktree),
103 AlreadyExists(AgentWorktree),
105 Removed {
107 path: PathBuf,
109 },
110 Error(String),
112}
113
114impl WorktreeResult {
115 pub fn is_success(&self) -> bool {
117 matches!(
118 self,
119 WorktreeResult::Created(_)
120 | WorktreeResult::AlreadyExists(_)
121 | WorktreeResult::Removed { .. }
122 )
123 }
124
125 pub fn worktree(&self) -> Option<&AgentWorktree> {
127 match self {
128 WorktreeResult::Created(wt) | WorktreeResult::AlreadyExists(wt) => Some(wt),
129 _ => None,
130 }
131 }
132}
133
134#[derive(Debug, Clone)]
136pub struct GitWorktreeInfo {
137 pub path: PathBuf,
139 pub head: String,
141 pub branch: Option<String>,
143 pub bare: bool,
145}
146
147impl WorktreeManager {
148 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
150 let repo_path = repo_path.into();
151 let worktree_base = repo_path.join(".worktrees");
152
153 Self {
154 repo_path,
155 worktree_base,
156 worktrees: RwLock::new(HashMap::new()),
157 config: WorktreeConfig::default(),
158 }
159 }
160
161 pub fn with_config(repo_path: impl Into<PathBuf>, config: WorktreeConfig) -> Self {
163 let repo_path = repo_path.into();
164 let worktree_base = repo_path.join(".worktrees");
165
166 Self {
167 repo_path,
168 worktree_base,
169 worktrees: RwLock::new(HashMap::new()),
170 config,
171 }
172 }
173
174 pub fn with_worktree_base(mut self, base: impl Into<PathBuf>) -> Self {
176 self.worktree_base = base.into();
177 self
178 }
179
180 pub async fn get_or_create_worktree(
182 &self,
183 agent_id: &str,
184 branch: &str,
185 purpose: &str,
186 ) -> WorktreeResult {
187 {
189 let worktrees = self.worktrees.read().await;
190 if let Some(existing) = worktrees.get(agent_id) {
191 return WorktreeResult::AlreadyExists(existing.clone());
192 }
193 }
194
195 let worktrees = self.worktrees.read().await;
197 if worktrees.len() >= self.config.max_worktrees {
198 drop(worktrees);
199
200 if self.config.auto_cleanup {
202 self.cleanup_stale_worktrees().await;
203
204 let worktrees = self.worktrees.read().await;
205 if worktrees.len() >= self.config.max_worktrees {
206 return WorktreeResult::Error(format!(
207 "Maximum worktrees ({}) reached",
208 self.config.max_worktrees
209 ));
210 }
211 } else {
212 return WorktreeResult::Error(format!(
213 "Maximum worktrees ({}) reached",
214 self.config.max_worktrees
215 ));
216 }
217 } else {
218 drop(worktrees);
219 }
220
221 self.create_worktree(agent_id, branch, purpose).await
223 }
224
225 async fn create_worktree(&self, agent_id: &str, branch: &str, purpose: &str) -> WorktreeResult {
227 if let Err(e) = std::fs::create_dir_all(&self.worktree_base) {
229 return WorktreeResult::Error(format!("Failed to create worktree base: {}", e));
230 }
231
232 let worktree_name = format!(
234 "{}{}",
235 self.config.prefix,
236 agent_id.replace(['/', '\\', ' '], "-")
237 );
238 let worktree_path = self.worktree_base.join(&worktree_name);
239
240 let branch_exists = self.branch_exists(branch);
242
243 let mut cmd = Command::new("git");
245 cmd.current_dir(&self.repo_path).arg("worktree").arg("add");
246
247 if branch_exists {
248 cmd.arg(&worktree_path).arg(branch);
250 } else {
251 cmd.arg("-b").arg(branch).arg(&worktree_path);
253 }
254
255 let output = match cmd.output() {
256 Ok(o) => o,
257 Err(e) => {
258 return WorktreeResult::Error(format!("Failed to run git worktree add: {}", e));
259 }
260 };
261
262 if !output.status.success() {
263 let stderr = String::from_utf8_lossy(&output.stderr);
264 return WorktreeResult::Error(format!("git worktree add failed: {}", stderr));
265 }
266
267 let worktree = AgentWorktree {
269 agent_id: agent_id.to_string(),
270 path: worktree_path,
271 branch: branch.to_string(),
272 created_at: Instant::now(),
273 last_accessed: Instant::now(),
274 has_changes: false,
275 purpose: purpose.to_string(),
276 };
277
278 self.worktrees
280 .write()
281 .await
282 .insert(agent_id.to_string(), worktree.clone());
283
284 WorktreeResult::Created(worktree)
285 }
286
287 fn branch_exists(&self, branch: &str) -> bool {
289 let output = Command::new("git")
290 .current_dir(&self.repo_path)
291 .args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)])
292 .output();
293
294 matches!(output, Ok(o) if o.status.success())
295 }
296
297 pub async fn get_worktree(&self, agent_id: &str) -> Option<AgentWorktree> {
299 let mut worktrees = self.worktrees.write().await;
300 if let Some(worktree) = worktrees.get_mut(agent_id) {
301 worktree.last_accessed = Instant::now();
303 Some(worktree.clone())
304 } else {
305 None
306 }
307 }
308
309 pub async fn remove_worktree(&self, agent_id: &str, force: bool) -> WorktreeResult {
311 let worktree = {
312 let worktrees = self.worktrees.read().await;
313 worktrees.get(agent_id).cloned()
314 };
315
316 let worktree = match worktree {
317 Some(wt) => wt,
318 None => {
319 return WorktreeResult::Error(format!("No worktree found for agent {}", agent_id));
320 }
321 };
322
323 if !force && self.has_uncommitted_changes(&worktree.path) {
325 return WorktreeResult::Error(
326 "Worktree has uncommitted changes. Use force=true to remove anyway.".to_string(),
327 );
328 }
329
330 let mut cmd = Command::new("git");
332 cmd.current_dir(&self.repo_path)
333 .args(["worktree", "remove"]);
334
335 if force {
336 cmd.arg("--force");
337 }
338
339 cmd.arg(&worktree.path);
340
341 let output = match cmd.output() {
342 Ok(o) => o,
343 Err(e) => {
344 return WorktreeResult::Error(format!("Failed to run git worktree remove: {}", e));
345 }
346 };
347
348 if !output.status.success() {
349 let stderr = String::from_utf8_lossy(&output.stderr);
350 return WorktreeResult::Error(format!("git worktree remove failed: {}", stderr));
351 }
352
353 self.worktrees.write().await.remove(agent_id);
355
356 WorktreeResult::Removed {
357 path: worktree.path,
358 }
359 }
360
361 fn has_uncommitted_changes(&self, worktree_path: &Path) -> bool {
363 let output = Command::new("git")
364 .current_dir(worktree_path)
365 .args(["status", "--porcelain"])
366 .output();
367
368 match output {
369 Ok(o) if o.status.success() => !o.stdout.is_empty(),
370 _ => false, }
372 }
373
374 pub async fn cleanup_stale_worktrees(&self) -> Vec<String> {
376 let mut removed = Vec::new();
377 let stale_agents: Vec<String> = {
378 let worktrees = self.worktrees.read().await;
379 worktrees
380 .iter()
381 .filter(|(_, wt)| wt.is_stale(self.config.max_age) && !wt.has_changes)
382 .map(|(id, _)| id.clone())
383 .collect()
384 };
385
386 for agent_id in stale_agents {
387 if let WorktreeResult::Removed { .. } = self.remove_worktree(&agent_id, false).await {
388 removed.push(agent_id);
389 }
390 }
391
392 removed
393 }
394
395 pub async fn list_all_worktrees(&self) -> Result<Vec<GitWorktreeInfo>, String> {
397 let output = Command::new("git")
398 .current_dir(&self.repo_path)
399 .args(["worktree", "list", "--porcelain"])
400 .output()
401 .map_err(|e| format!("Failed to run git worktree list: {}", e))?;
402
403 if !output.status.success() {
404 let stderr = String::from_utf8_lossy(&output.stderr);
405 return Err(format!("git worktree list failed: {}", stderr));
406 }
407
408 let stdout = String::from_utf8_lossy(&output.stdout);
409 Ok(Self::parse_worktree_list(&stdout))
410 }
411
412 fn parse_worktree_list(output: &str) -> Vec<GitWorktreeInfo> {
414 let mut worktrees = Vec::new();
415 let mut current: Option<GitWorktreeInfo> = None;
416
417 for line in output.lines() {
418 if line.starts_with("worktree ") {
419 if let Some(wt) = current.take() {
421 worktrees.push(wt);
422 }
423 current = Some(GitWorktreeInfo {
425 path: PathBuf::from(line.trim_start_matches("worktree ")),
426 head: String::new(),
427 branch: None,
428 bare: false,
429 });
430 } else if let Some(ref mut wt) = current {
431 if line.starts_with("HEAD ") {
432 wt.head = line.trim_start_matches("HEAD ").to_string();
433 } else if line.starts_with("branch refs/heads/") {
434 wt.branch = Some(line.trim_start_matches("branch refs/heads/").to_string());
435 } else if line == "bare" {
436 wt.bare = true;
437 }
438 }
439 }
440
441 if let Some(wt) = current {
443 worktrees.push(wt);
444 }
445
446 worktrees
447 }
448
449 pub async fn list_tracked_worktrees(&self) -> Vec<AgentWorktree> {
451 self.worktrees.read().await.values().cloned().collect()
452 }
453
454 pub async fn sync_with_git(&self) -> Result<SyncResult, String> {
456 let git_worktrees = self.list_all_worktrees().await?;
457 let mut tracked = self.worktrees.write().await;
458
459 let mut added = 0;
460 let mut removed = 0;
461
462 for git_wt in &git_worktrees {
464 if git_wt.bare || git_wt.branch.is_none() {
466 continue;
467 }
468
469 let path_str = git_wt.path.to_string_lossy();
471 if path_str.contains(&self.config.prefix) {
472 if let Some(name) = git_wt.path.file_name() {
474 let name_str = name.to_string_lossy();
475 if let Some(agent_id) = name_str.strip_prefix(&self.config.prefix)
476 && !tracked.contains_key(agent_id)
477 {
478 tracked.insert(
479 agent_id.to_string(),
480 AgentWorktree {
481 agent_id: agent_id.to_string(),
482 path: git_wt.path.clone(),
483 branch: git_wt.branch.clone().unwrap_or_default(),
484 created_at: Instant::now(),
485 last_accessed: Instant::now(),
486 has_changes: false,
487 purpose: "Discovered via sync".to_string(),
488 },
489 );
490 added += 1;
491 }
492 }
493 }
494 }
495
496 let git_paths: std::collections::HashSet<_> =
498 git_worktrees.iter().map(|wt| &wt.path).collect();
499 let to_remove: Vec<_> = tracked
500 .iter()
501 .filter(|(_, wt)| !git_paths.contains(&wt.path))
502 .map(|(id, _)| id.clone())
503 .collect();
504
505 for id in to_remove {
506 tracked.remove(&id);
507 removed += 1;
508 }
509
510 Ok(SyncResult { added, removed })
511 }
512
513 pub async fn update_changes_status(&self, agent_id: &str) -> bool {
515 let mut worktrees = self.worktrees.write().await;
516 if let Some(worktree) = worktrees.get_mut(agent_id) {
517 worktree.has_changes = self.has_uncommitted_changes(&worktree.path);
518 worktree.has_changes
519 } else {
520 false
521 }
522 }
523
524 pub async fn get_working_directory(&self, agent_id: &str) -> PathBuf {
526 let worktrees = self.worktrees.read().await;
527 if let Some(worktree) = worktrees.get(agent_id) {
528 worktree.path.clone()
529 } else {
530 self.repo_path.clone()
531 }
532 }
533
534 pub async fn get_stats(&self) -> WorktreeStats {
536 let worktrees = self.worktrees.read().await;
537
538 WorktreeStats {
539 total_tracked: worktrees.len(),
540 with_changes: worktrees.values().filter(|wt| wt.has_changes).count(),
541 stale: worktrees
542 .values()
543 .filter(|wt| wt.is_stale(self.config.max_age))
544 .count(),
545 max_allowed: self.config.max_worktrees,
546 }
547 }
548}
549
550#[derive(Debug, Clone)]
552pub struct SyncResult {
553 pub added: usize,
555 pub removed: usize,
557}
558
559#[derive(Debug, Clone)]
561pub struct WorktreeStats {
562 pub total_tracked: usize,
564 pub with_changes: usize,
566 pub stale: usize,
568 pub max_allowed: usize,
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::env;
576
577 #[test]
578 fn test_worktree_config_default() {
579 let config = WorktreeConfig::default();
580 assert_eq!(config.max_worktrees, 10);
581 assert!(config.auto_cleanup);
582 assert_eq!(config.prefix, "agent-wt-");
583 }
584
585 #[test]
586 fn test_agent_worktree_staleness() {
587 let worktree = AgentWorktree {
588 agent_id: "test-agent".to_string(),
589 path: PathBuf::from("/tmp/test"),
590 branch: "feature".to_string(),
591 created_at: Instant::now() - Duration::from_secs(3600),
592 last_accessed: Instant::now() - Duration::from_secs(3600),
593 has_changes: false,
594 purpose: "test".to_string(),
595 };
596
597 assert!(worktree.is_stale(Duration::from_secs(1800)));
599 assert!(!worktree.is_stale(Duration::from_secs(7200)));
601 }
602
603 #[test]
604 fn test_parse_worktree_list() {
605 let output = r#"worktree /home/user/repo
606HEAD abc123
607branch refs/heads/main
608
609worktree /home/user/repo/.worktrees/feature
610HEAD def456
611branch refs/heads/feature
612"#;
613
614 let worktrees = WorktreeManager::parse_worktree_list(output);
615 assert_eq!(worktrees.len(), 2);
616
617 assert_eq!(worktrees[0].path, PathBuf::from("/home/user/repo"));
618 assert_eq!(worktrees[0].head, "abc123");
619 assert_eq!(worktrees[0].branch, Some("main".to_string()));
620
621 assert_eq!(
622 worktrees[1].path,
623 PathBuf::from("/home/user/repo/.worktrees/feature")
624 );
625 assert_eq!(worktrees[1].branch, Some("feature".to_string()));
626 }
627
628 #[test]
629 fn test_worktree_result_success() {
630 let worktree = AgentWorktree {
631 agent_id: "test".to_string(),
632 path: PathBuf::from("/tmp/test"),
633 branch: "main".to_string(),
634 created_at: Instant::now(),
635 last_accessed: Instant::now(),
636 has_changes: false,
637 purpose: "test".to_string(),
638 };
639
640 let created = WorktreeResult::Created(worktree.clone());
641 assert!(created.is_success());
642 assert!(created.worktree().is_some());
643
644 let exists = WorktreeResult::AlreadyExists(worktree);
645 assert!(exists.is_success());
646
647 let removed = WorktreeResult::Removed {
648 path: PathBuf::from("/tmp/test"),
649 };
650 assert!(removed.is_success());
651
652 let error = WorktreeResult::Error("test error".to_string());
653 assert!(!error.is_success());
654 assert!(error.worktree().is_none());
655 }
656
657 #[tokio::test]
658 async fn test_worktree_manager_creation() {
659 let temp_dir = env::temp_dir().join("test-worktree-manager");
660 let manager = WorktreeManager::new(&temp_dir);
661
662 assert_eq!(manager.repo_path, temp_dir);
663 assert_eq!(manager.worktree_base, temp_dir.join(".worktrees"));
664 }
665
666 #[tokio::test]
667 async fn test_worktree_stats() {
668 let temp_dir = env::temp_dir().join("test-worktree-stats");
669 let manager = WorktreeManager::new(&temp_dir);
670
671 {
673 let mut worktrees = manager.worktrees.write().await;
674 worktrees.insert(
675 "agent-1".to_string(),
676 AgentWorktree {
677 agent_id: "agent-1".to_string(),
678 path: PathBuf::from("/tmp/wt1"),
679 branch: "feature-1".to_string(),
680 created_at: Instant::now(),
681 last_accessed: Instant::now(),
682 has_changes: false,
683 purpose: "test".to_string(),
684 },
685 );
686 worktrees.insert(
687 "agent-2".to_string(),
688 AgentWorktree {
689 agent_id: "agent-2".to_string(),
690 path: PathBuf::from("/tmp/wt2"),
691 branch: "feature-2".to_string(),
692 created_at: Instant::now(),
693 last_accessed: Instant::now(),
694 has_changes: true, purpose: "test".to_string(),
696 },
697 );
698 }
699
700 let stats = manager.get_stats().await;
701 assert_eq!(stats.total_tracked, 2);
702 assert_eq!(stats.with_changes, 1);
703 assert_eq!(stats.max_allowed, 10);
704 }
705
706 #[tokio::test]
707 async fn test_get_working_directory() {
708 let temp_dir = env::temp_dir().join("test-working-dir");
709 let manager = WorktreeManager::new(&temp_dir);
710
711 let dir = manager.get_working_directory("unknown-agent").await;
713 assert_eq!(dir, temp_dir);
714
715 {
717 let mut worktrees = manager.worktrees.write().await;
718 worktrees.insert(
719 "agent-1".to_string(),
720 AgentWorktree {
721 agent_id: "agent-1".to_string(),
722 path: PathBuf::from("/tmp/agent-1-worktree"),
723 branch: "feature".to_string(),
724 created_at: Instant::now(),
725 last_accessed: Instant::now(),
726 has_changes: false,
727 purpose: "test".to_string(),
728 },
729 );
730 }
731
732 let dir = manager.get_working_directory("agent-1").await;
734 assert_eq!(dir, PathBuf::from("/tmp/agent-1-worktree"));
735 }
736
737 #[tokio::test]
738 async fn test_list_tracked_worktrees() {
739 let temp_dir = env::temp_dir().join("test-list-tracked");
740 let manager = WorktreeManager::new(&temp_dir);
741
742 {
744 let mut worktrees = manager.worktrees.write().await;
745 for i in 0..3 {
746 worktrees.insert(
747 format!("agent-{}", i),
748 AgentWorktree {
749 agent_id: format!("agent-{}", i),
750 path: PathBuf::from(format!("/tmp/wt{}", i)),
751 branch: format!("feature-{}", i),
752 created_at: Instant::now(),
753 last_accessed: Instant::now(),
754 has_changes: false,
755 purpose: "test".to_string(),
756 },
757 );
758 }
759 }
760
761 let tracked = manager.list_tracked_worktrees().await;
762 assert_eq!(tracked.len(), 3);
763 }
764
765 #[test]
766 fn test_worktree_age() {
767 let worktree = AgentWorktree {
768 agent_id: "test".to_string(),
769 path: PathBuf::from("/tmp/test"),
770 branch: "main".to_string(),
771 created_at: Instant::now() - Duration::from_secs(120),
772 last_accessed: Instant::now(),
773 has_changes: false,
774 purpose: "test".to_string(),
775 };
776
777 let age = worktree.age();
778 assert!(age >= Duration::from_secs(119)); }
780}