Skip to main content

brainwires_agents/
worktree.rs

1//! Git Worktree Management for Agent Isolation
2//!
3//! This module provides Git worktree management to allow agents to work in
4//! isolation without interfering with each other's changes. Each agent can
5//! have its own worktree, enabling parallel development work.
6//!
7//! # Key Concepts
8//!
9//! - **Worktree**: A separate working directory linked to the same Git repository
10//! - **WorktreeManager**: Manages creation, tracking, and cleanup of worktrees
11//! - **AgentWorktree**: Associates a worktree with a specific agent
12//!
13//! # Use Cases
14//!
15//! - Multiple agents working on different features simultaneously
16//! - Isolation of experimental changes
17//! - Safe build/test environments without affecting main working directory
18
19use 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
29/// Manages Git worktrees for agent isolation
30pub struct WorktreeManager {
31    /// The main repository path
32    repo_path: PathBuf,
33    /// Base directory for worktrees
34    worktree_base: PathBuf,
35    /// Active worktrees by agent ID
36    worktrees: RwLock<HashMap<String, AgentWorktree>>,
37    /// Configuration
38    config: WorktreeConfig,
39}
40
41/// Configuration for worktree management
42#[derive(Debug, Clone)]
43pub struct WorktreeConfig {
44    /// Maximum age before a worktree is considered stale
45    pub max_age: Duration,
46    /// Whether to auto-cleanup stale worktrees
47    pub auto_cleanup: bool,
48    /// Prefix for worktree directory names
49    pub prefix: String,
50    /// Maximum number of worktrees allowed
51    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/// A worktree associated with an agent
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct AgentWorktree {
68    /// Agent that owns this worktree
69    pub agent_id: String,
70    /// Path to the worktree
71    pub path: PathBuf,
72    /// Branch name in this worktree
73    pub branch: String,
74    /// When the worktree was created
75    #[serde(skip, default = "Instant::now")]
76    pub created_at: Instant,
77    /// When the worktree was last accessed
78    #[serde(skip, default = "Instant::now")]
79    pub last_accessed: Instant,
80    /// Whether the worktree has uncommitted changes
81    pub has_changes: bool,
82    /// Purpose/description of this worktree
83    pub purpose: String,
84}
85
86impl AgentWorktree {
87    /// Check if the worktree is stale
88    pub fn is_stale(&self, max_age: Duration) -> bool {
89        self.last_accessed.elapsed() > max_age
90    }
91
92    /// Get the age of this worktree
93    pub fn age(&self) -> Duration {
94        self.created_at.elapsed()
95    }
96}
97
98/// Result of worktree operations
99#[derive(Debug)]
100pub enum WorktreeResult {
101    /// Worktree created successfully
102    Created(AgentWorktree),
103    /// Worktree already exists
104    AlreadyExists(AgentWorktree),
105    /// Worktree removed successfully
106    Removed {
107        /// Path of the removed worktree.
108        path: PathBuf,
109    },
110    /// Operation failed
111    Error(String),
112}
113
114impl WorktreeResult {
115    /// Check if the operation was successful
116    pub fn is_success(&self) -> bool {
117        matches!(
118            self,
119            WorktreeResult::Created(_)
120                | WorktreeResult::AlreadyExists(_)
121                | WorktreeResult::Removed { .. }
122        )
123    }
124
125    /// Get the worktree if available
126    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/// Information about a Git worktree from `git worktree list`
135#[derive(Debug, Clone)]
136pub struct GitWorktreeInfo {
137    /// Path to the worktree
138    pub path: PathBuf,
139    /// HEAD commit
140    pub head: String,
141    /// Branch name (if any)
142    pub branch: Option<String>,
143    /// Whether this is bare
144    pub bare: bool,
145}
146
147impl WorktreeManager {
148    /// Create a new worktree manager
149    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    /// Create with custom configuration
162    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    /// Set a custom base directory for worktrees
175    pub fn with_worktree_base(mut self, base: impl Into<PathBuf>) -> Self {
176        self.worktree_base = base.into();
177        self
178    }
179
180    /// Create or get a worktree for an agent
181    pub async fn get_or_create_worktree(
182        &self,
183        agent_id: &str,
184        branch: &str,
185        purpose: &str,
186    ) -> WorktreeResult {
187        // Check if agent already has a worktree
188        {
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        // Check worktree limit
196        let worktrees = self.worktrees.read().await;
197        if worktrees.len() >= self.config.max_worktrees {
198            drop(worktrees);
199
200            // Try cleanup if auto-cleanup is enabled
201            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        // Create the worktree
222        self.create_worktree(agent_id, branch, purpose).await
223    }
224
225    /// Create a new worktree for an agent
226    async fn create_worktree(&self, agent_id: &str, branch: &str, purpose: &str) -> WorktreeResult {
227        // Ensure base directory exists
228        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        // Generate unique worktree path
233        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        // Check if branch exists
241        let branch_exists = self.branch_exists(branch);
242
243        // Build git worktree command
244        let mut cmd = Command::new("git");
245        cmd.current_dir(&self.repo_path).arg("worktree").arg("add");
246
247        if branch_exists {
248            // Checkout existing branch
249            cmd.arg(&worktree_path).arg(branch);
250        } else {
251            // Create new branch from current HEAD
252            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        // Create the worktree record
268        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        // Store in our tracking map
279        self.worktrees
280            .write()
281            .await
282            .insert(agent_id.to_string(), worktree.clone());
283
284        WorktreeResult::Created(worktree)
285    }
286
287    /// Check if a branch exists
288    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    /// Get an agent's worktree
298    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            // Update last accessed time
302            worktree.last_accessed = Instant::now();
303            Some(worktree.clone())
304        } else {
305            None
306        }
307    }
308
309    /// Remove an agent's worktree
310    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        // Check for uncommitted changes
324        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        // Remove the worktree
331        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        // Remove from tracking
354        self.worktrees.write().await.remove(agent_id);
355
356        WorktreeResult::Removed {
357            path: worktree.path,
358        }
359    }
360
361    /// Check if a worktree has uncommitted changes
362    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, // If we can't check, assume no changes
371        }
372    }
373
374    /// Cleanup stale worktrees
375    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    /// List all worktrees (both tracked and untracked)
396    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    /// Parse the porcelain output of `git worktree list`
413    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                // Save previous entry if any
420                if let Some(wt) = current.take() {
421                    worktrees.push(wt);
422                }
423                // Start new entry
424                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        // Don't forget the last entry
442        if let Some(wt) = current {
443            worktrees.push(wt);
444        }
445
446        worktrees
447    }
448
449    /// Get tracked worktrees
450    pub async fn list_tracked_worktrees(&self) -> Vec<AgentWorktree> {
451        self.worktrees.read().await.values().cloned().collect()
452    }
453
454    /// Sync tracking with actual git worktrees
455    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        // Find worktrees that exist in git but not in tracking
463        for git_wt in &git_worktrees {
464            // Skip the main worktree (no branch usually) and bare repos
465            if git_wt.bare || git_wt.branch.is_none() {
466                continue;
467            }
468
469            // Check if the path contains our prefix
470            let path_str = git_wt.path.to_string_lossy();
471            if path_str.contains(&self.config.prefix) {
472                // Try to extract agent ID from path
473                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        // Find tracked worktrees that no longer exist
497        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    /// Update the has_changes flag for a worktree
514    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    /// Get the working directory for an agent
525    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    /// Get statistics about worktrees
535    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/// Result of syncing tracking with git
551#[derive(Debug, Clone)]
552pub struct SyncResult {
553    /// Number of worktrees added to tracking
554    pub added: usize,
555    /// Number of worktrees removed from tracking
556    pub removed: usize,
557}
558
559/// Statistics about worktrees
560#[derive(Debug, Clone)]
561pub struct WorktreeStats {
562    /// Total tracked worktrees
563    pub total_tracked: usize,
564    /// Worktrees with uncommitted changes
565    pub with_changes: usize,
566    /// Stale worktrees
567    pub stale: usize,
568    /// Maximum allowed worktrees
569    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        // Should be stale after 30 minutes
598        assert!(worktree.is_stale(Duration::from_secs(1800)));
599        // Should not be stale after 2 hours
600        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        // Add some test worktrees directly to the tracking map
672        {
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, // Has changes
695                    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        // Without worktree, should return repo path
712        let dir = manager.get_working_directory("unknown-agent").await;
713        assert_eq!(dir, temp_dir);
714
715        // Add a worktree
716        {
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        // With worktree, should return worktree path
733        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        // Add test worktrees
743        {
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)); // Allow for slight timing variations
779    }
780}