1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorktreeState {
16 pub path: PathBuf,
18 pub branch: String,
20 pub issue_number: Option<u64>,
22 pub status: WorktreeStatusDetailed,
24 pub last_accessed: DateTime<Utc>,
26 pub is_locked: bool,
28 pub has_uncommitted_changes: bool,
30 pub disk_usage: u64,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum WorktreeStatusDetailed {
37 Active,
39 Idle,
41 Stuck,
43 Orphaned,
45 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
61pub struct WorktreeStateManager {
63 project_root: PathBuf,
64 worktree_base: PathBuf,
65 task_metadata_manager: TaskMetadataManager,
66}
67
68impl WorktreeStateManager {
69 pub fn new(project_root: PathBuf) -> Result<Self> {
71 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 pub fn scan_worktrees(&self) -> Result<Vec<WorktreeState>> {
108 let mut states = Vec::new();
109
110 if !self.worktree_base.exists() {
112 return Ok(states);
113 }
114
115 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 states.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
131
132 Ok(states)
133 }
134
135 pub fn get_worktree_state(&self, path: &Path) -> Result<WorktreeState> {
137 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 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
147 let branch = dir_name.to_string();
148
149 let issue_number = self.extract_issue_number(dir_name);
151
152 let status = self.determine_status(path, issue_number)?;
154
155 let last_accessed = self.get_last_accessed(path)?;
157
158 let is_locked = self.is_locked(path);
160
161 let has_uncommitted_changes = self.has_uncommitted_changes(path)?;
163
164 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 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 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 pub fn cleanup_worktree(&self, path: &Path) -> Result<()> {
205 if !path.exists() {
206 return Ok(());
207 }
208
209 let _repo = git2::Repository::open(&self.project_root)
211 .map_err(|e| MiyabiError::Git(e.to_string()))?;
212
213 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 std::fs::remove_dir_all(path).map_err(MiyabiError::Io)?;
226 }
227
228 Ok(())
229 }
230
231 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 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 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 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 for worktree in worktrees {
282 if let Some(issue_num) = worktree.issue_number {
283 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 fn extract_issue_number(&self, dir_name: &str) -> Option<u64> {
300 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 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 if git2::Repository::open(path).is_err() {
324 return Ok(WorktreeStatusDetailed::Corrupted);
325 }
326
327 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 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 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 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 let manager = WorktreeStateManager::new(subdir.clone()).unwrap();
469
470 assert!(repo_path.join(".miyabi").join("tasks").exists());
472 assert!(!subdir.join(".miyabi").exists());
473
474 let states = manager.scan_worktrees().unwrap();
476 assert!(states.is_empty());
477 }
478}