miyabi_worktree/
state.rs

1//! Worktree state tracking and management
2//!
3//! Provides real-time tracking of Git Worktree states, including detection of
4//! orphaned, stuck, and idle worktrees, with integration to TaskMetadata.
5
6use chrono::{DateTime, Utc};
7use miyabi_core::{find_git_root, TaskMetadataManager};
8use miyabi_types::error::{MiyabiError, Result};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13/// Comprehensive worktree state information
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorktreeState {
16    /// Worktree path
17    pub path: PathBuf,
18    /// Git branch name
19    pub branch: String,
20    /// Associated issue number (if any)
21    pub issue_number: Option<u64>,
22    /// Current status
23    pub status: WorktreeStatusDetailed,
24    /// Last access/modification time
25    pub last_accessed: DateTime<Utc>,
26    /// Whether the worktree has a lock file
27    pub is_locked: bool,
28    /// Whether there are uncommitted changes
29    pub has_uncommitted_changes: bool,
30    /// Disk space usage in bytes
31    pub disk_usage: u64,
32}
33
34/// Detailed worktree status
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum WorktreeStatusDetailed {
37    /// Currently being used by an agent
38    Active,
39    /// Waiting/idle (recently used)
40    Idle,
41    /// Stuck (no activity for extended period)
42    Stuck,
43    /// Orphaned (no corresponding TaskMetadata)
44    Orphaned,
45    /// Corrupted (git errors, missing files, etc.)
46    Corrupted,
47}
48
49impl std::fmt::Display for WorktreeStatusDetailed {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            WorktreeStatusDetailed::Active => write!(f, "Active"),
53            WorktreeStatusDetailed::Idle => write!(f, "Idle"),
54            WorktreeStatusDetailed::Stuck => write!(f, "Stuck"),
55            WorktreeStatusDetailed::Orphaned => write!(f, "Orphaned"),
56            WorktreeStatusDetailed::Corrupted => write!(f, "Corrupted"),
57        }
58    }
59}
60
61/// Worktree state manager
62pub struct WorktreeStateManager {
63    project_root: PathBuf,
64    worktree_base: PathBuf,
65    task_metadata_manager: TaskMetadataManager,
66}
67
68impl WorktreeStateManager {
69    /// Create a new WorktreeStateManager
70    pub fn new(project_root: PathBuf) -> Result<Self> {
71        // Automatically resolve the git repository root. When invoked from a
72        // subdirectory, this ensures we look in the canonical project root for
73        // both `.worktrees/` and `.miyabi/tasks/`.
74        let resolved_root = match find_git_root(Some(&project_root)) {
75            Ok(root) => {
76                if root != project_root {
77                    tracing::debug!(
78                        "Resolved git repository root {:?} from {:?}",
79                        root,
80                        project_root
81                    );
82                }
83                root
84            },
85            Err(err) => {
86                tracing::debug!(
87                    "WorktreeStateManager fallback to provided path {:?}: {}",
88                    project_root,
89                    err
90                );
91                project_root.clone()
92            },
93        };
94
95        let worktree_base = resolved_root.join(".worktrees");
96        let task_metadata_manager = TaskMetadataManager::new(&resolved_root)
97            .map_err(|e| MiyabiError::Io(std::io::Error::other(e.to_string())))?;
98
99        Ok(Self {
100            project_root: resolved_root,
101            worktree_base,
102            task_metadata_manager,
103        })
104    }
105
106    /// Scan all worktrees and return their states
107    pub fn scan_worktrees(&self) -> Result<Vec<WorktreeState>> {
108        let mut states = Vec::new();
109
110        // Check if worktree directory exists
111        if !self.worktree_base.exists() {
112            return Ok(states);
113        }
114
115        // Iterate through worktree directories
116        let entries = std::fs::read_dir(&self.worktree_base).map_err(MiyabiError::Io)?;
117
118        for entry in entries {
119            let entry = entry.map_err(MiyabiError::Io)?;
120            let path = entry.path();
121
122            if path.is_dir() {
123                if let Ok(state) = self.get_worktree_state(&path) {
124                    states.push(state);
125                }
126            }
127        }
128
129        // Sort by last accessed time (most recent first)
130        states.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
131
132        Ok(states)
133    }
134
135    /// Get the state of a specific worktree
136    pub fn get_worktree_state(&self, path: &Path) -> Result<WorktreeState> {
137        // Check if path exists
138        if !path.exists() {
139            return Err(MiyabiError::Io(std::io::Error::new(
140                std::io::ErrorKind::NotFound,
141                format!("Worktree not found: {}", path.display()),
142            )));
143        }
144
145        // Get branch name from directory name (e.g., "issue-123" -> "issue-123")
146        let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
147        let branch = dir_name.to_string();
148
149        // Extract issue number from directory name
150        let issue_number = self.extract_issue_number(dir_name);
151
152        // Determine status
153        let status = self.determine_status(path, issue_number)?;
154
155        // Get last accessed time
156        let last_accessed = self.get_last_accessed(path)?;
157
158        // Check if locked
159        let is_locked = self.is_locked(path);
160
161        // Check for uncommitted changes
162        let has_uncommitted_changes = self.has_uncommitted_changes(path)?;
163
164        // Calculate disk usage
165        let disk_usage = self.calculate_disk_usage(path)?;
166
167        Ok(WorktreeState {
168            path: path.to_path_buf(),
169            branch,
170            issue_number,
171            status,
172            last_accessed,
173            is_locked,
174            has_uncommitted_changes,
175            disk_usage,
176        })
177    }
178
179    /// Find orphaned worktrees (no corresponding TaskMetadata)
180    pub fn find_orphaned_worktrees(&self) -> Result<Vec<WorktreeState>> {
181        let all_states = self.scan_worktrees()?;
182
183        Ok(all_states
184            .into_iter()
185            .filter(|s| s.status == WorktreeStatusDetailed::Orphaned)
186            .collect())
187    }
188
189    /// Find stuck worktrees (no activity for extended period)
190    pub fn find_stuck_worktrees(&self, timeout: Duration) -> Result<Vec<WorktreeState>> {
191        let all_states = self.scan_worktrees()?;
192        let now = Utc::now();
193
194        Ok(all_states
195            .into_iter()
196            .filter(|s| {
197                s.status == WorktreeStatusDetailed::Stuck
198                    || (now - s.last_accessed).num_seconds() > timeout.as_secs() as i64
199            })
200            .collect())
201    }
202
203    /// Clean up a specific worktree
204    pub fn cleanup_worktree(&self, path: &Path) -> Result<()> {
205        if !path.exists() {
206            return Ok(());
207        }
208
209        // Remove worktree using git
210        let _repo = git2::Repository::open(&self.project_root)
211            .map_err(|e| MiyabiError::Git(e.to_string()))?;
212
213        // Use git worktree remove command
214        let status = std::process::Command::new("git")
215            .arg("worktree")
216            .arg("remove")
217            .arg("--force")
218            .arg(path)
219            .current_dir(&self.project_root)
220            .status()
221            .map_err(MiyabiError::Io)?;
222
223        if !status.success() {
224            // If git worktree remove fails, try manual deletion
225            std::fs::remove_dir_all(path).map_err(MiyabiError::Io)?;
226        }
227
228        Ok(())
229    }
230
231    /// Clean up all orphaned worktrees
232    pub fn cleanup_orphaned(&self) -> Result<usize> {
233        let orphaned = self.find_orphaned_worktrees()?;
234        let count = orphaned.len();
235
236        for worktree in orphaned {
237            self.cleanup_worktree(&worktree.path)?;
238        }
239
240        Ok(count)
241    }
242
243    /// Clean up all known worktrees regardless of status
244    pub fn cleanup_all(&self) -> Result<usize> {
245        let worktrees = self.scan_worktrees()?;
246        let mut cleaned = 0usize;
247        let mut errors = Vec::new();
248
249        for worktree in worktrees {
250            match self.cleanup_worktree(&worktree.path) {
251                Ok(_) => cleaned += 1,
252                Err(e) => {
253                    errors.push(format!("{} ({})", worktree.path.display(), e));
254                },
255            }
256        }
257
258        if errors.is_empty() {
259            Ok(cleaned)
260        } else {
261            Err(MiyabiError::Unknown(format!(
262                "Failed to clean some worktrees: {}",
263                errors.join(", ")
264            )))
265        }
266    }
267
268    /// Synchronize worktree states with TaskMetadata
269    pub fn sync_with_metadata(&self) -> Result<()> {
270        let worktrees = self.scan_worktrees()?;
271        let all_tasks = self
272            .task_metadata_manager
273            .list_all()
274            .map_err(|e| MiyabiError::Io(std::io::Error::other(e.to_string())))?;
275
276        // Create a map of issue numbers to task metadata
277        let task_map: std::collections::HashMap<u64, _> =
278            all_tasks.into_iter().filter_map(|t| t.issue_number.map(|n| (n, t))).collect();
279
280        // Check each worktree
281        for worktree in worktrees {
282            if let Some(issue_num) = worktree.issue_number {
283                // Check if there's corresponding task metadata
284                if !task_map.contains_key(&issue_num) {
285                    tracing::warn!(
286                        "Orphaned worktree detected: {} (issue #{})",
287                        worktree.path.display(),
288                        issue_num
289                    );
290                }
291            }
292        }
293
294        Ok(())
295    }
296
297    // Helper methods
298
299    fn extract_issue_number(&self, dir_name: &str) -> Option<u64> {
300        // Try to extract issue number from patterns like:
301        // - "issue-123"
302        // - "issue-123-feature"
303        // - "123-bugfix"
304
305        if let Some(captures) = regex::Regex::new(r"issue[_-]?(\d+)").ok()?.captures(dir_name) {
306            return captures.get(1)?.as_str().parse().ok();
307        }
308
309        // Try simple numeric prefix
310        if let Some(captures) = regex::Regex::new(r"^(\d+)").ok()?.captures(dir_name) {
311            return captures.get(1)?.as_str().parse().ok();
312        }
313
314        None
315    }
316
317    fn determine_status(
318        &self,
319        path: &Path,
320        issue_number: Option<u64>,
321    ) -> Result<WorktreeStatusDetailed> {
322        // Check if corrupted (git errors)
323        if git2::Repository::open(path).is_err() {
324            return Ok(WorktreeStatusDetailed::Corrupted);
325        }
326
327        // Check if orphaned (no corresponding task metadata)
328        if let Some(issue_num) = issue_number {
329            let tasks = self
330                .task_metadata_manager
331                .find_by_issue(issue_num)
332                .map_err(|e| MiyabiError::Io(std::io::Error::other(e.to_string())))?;
333            if tasks.is_empty() {
334                return Ok(WorktreeStatusDetailed::Orphaned);
335            }
336
337            // Check if task is running
338            if let Some(task) = tasks.first() {
339                use miyabi_core::TaskStatus;
340                match task.status {
341                    TaskStatus::Running => return Ok(WorktreeStatusDetailed::Active),
342                    TaskStatus::Success | TaskStatus::Failed | TaskStatus::Cancelled => {
343                        return Ok(WorktreeStatusDetailed::Idle);
344                    },
345                    _ => {},
346                }
347            }
348        }
349
350        // Check last accessed time to determine if stuck
351        let last_accessed = self.get_last_accessed(path)?;
352        let elapsed = Utc::now() - last_accessed;
353
354        if elapsed.num_hours() > 24 {
355            Ok(WorktreeStatusDetailed::Stuck)
356        } else if elapsed.num_hours() > 1 {
357            Ok(WorktreeStatusDetailed::Idle)
358        } else {
359            Ok(WorktreeStatusDetailed::Active)
360        }
361    }
362
363    fn get_last_accessed(&self, path: &Path) -> Result<DateTime<Utc>> {
364        let metadata = std::fs::metadata(path).map_err(MiyabiError::Io)?;
365
366        let modified = metadata.modified().map_err(MiyabiError::Io)?;
367
368        Ok(DateTime::from(modified))
369    }
370
371    fn is_locked(&self, path: &Path) -> bool {
372        path.join(".git").join("index.lock").exists()
373    }
374
375    fn has_uncommitted_changes(&self, path: &Path) -> Result<bool> {
376        let repo = git2::Repository::open(path).map_err(|e| MiyabiError::Git(e.to_string()))?;
377
378        let statuses = repo.statuses(None).map_err(|e| MiyabiError::Git(e.to_string()))?;
379
380        Ok(!statuses.is_empty())
381    }
382
383    fn calculate_disk_usage(&self, path: &Path) -> Result<u64> {
384        Self::calculate_disk_usage_recursive(path)
385    }
386
387    fn calculate_disk_usage_recursive(path: &Path) -> Result<u64> {
388        let mut total = 0;
389
390        if path.is_dir() {
391            for entry in std::fs::read_dir(path).map_err(MiyabiError::Io)? {
392                let entry = entry.map_err(MiyabiError::Io)?;
393                let entry_path = entry.path();
394
395                if entry_path.is_file() {
396                    if let Ok(metadata) = std::fs::metadata(&entry_path) {
397                        total += metadata.len();
398                    }
399                } else if entry_path.is_dir() {
400                    total += Self::calculate_disk_usage_recursive(&entry_path)?;
401                }
402            }
403        }
404
405        Ok(total)
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use tempfile::TempDir;
413
414    #[test]
415    fn test_extract_issue_number() {
416        let temp_dir = TempDir::new().unwrap();
417        let manager = WorktreeStateManager::new(temp_dir.path().to_path_buf()).unwrap();
418
419        assert_eq!(manager.extract_issue_number("issue-123"), Some(123));
420        assert_eq!(manager.extract_issue_number("issue_456"), Some(456));
421        assert_eq!(manager.extract_issue_number("issue-789-feature"), Some(789));
422        assert_eq!(manager.extract_issue_number("123-bugfix"), Some(123));
423        assert_eq!(manager.extract_issue_number("no-number"), None);
424    }
425
426    #[test]
427    fn test_worktree_state_manager_creation() {
428        let temp_dir = TempDir::new().unwrap();
429        let manager = WorktreeStateManager::new(temp_dir.path().to_path_buf());
430        assert!(manager.is_ok());
431    }
432
433    #[test]
434    fn test_scan_worktrees_empty() {
435        let temp_dir = TempDir::new().unwrap();
436        let manager = WorktreeStateManager::new(temp_dir.path().to_path_buf()).unwrap();
437
438        let states = manager.scan_worktrees().unwrap();
439        assert_eq!(states.len(), 0);
440    }
441
442    #[test]
443    fn test_cleanup_all_with_no_worktrees() {
444        let temp_dir = TempDir::new().unwrap();
445        let manager = WorktreeStateManager::new(temp_dir.path().to_path_buf()).unwrap();
446
447        let cleaned = manager.cleanup_all().unwrap();
448        assert_eq!(cleaned, 0);
449    }
450
451    #[test]
452    fn test_state_manager_resolves_git_root() {
453        let temp_dir = TempDir::new().unwrap();
454        let repo_path = temp_dir.path();
455
456        // Initialize git repository
457        let init_output = std::process::Command::new("git")
458            .args(["init"])
459            .current_dir(repo_path)
460            .output()
461            .expect("git init should be invokable");
462        assert!(init_output.status.success(), "git init did not exit successfully");
463
464        let subdir = repo_path.join("nested");
465        std::fs::create_dir(&subdir).unwrap();
466
467        // Create manager from subdirectory path
468        let manager = WorktreeStateManager::new(subdir.clone()).unwrap();
469
470        // Task metadata directory should exist at repository root
471        assert!(repo_path.join(".miyabi").join("tasks").exists());
472        assert!(!subdir.join(".miyabi").exists());
473
474        // Manager still scans (even if no worktrees)
475        let states = manager.scan_worktrees().unwrap();
476        assert!(states.is_empty());
477    }
478}