miyabi_worktree/
manager.rs

1//! Worktree manager for parallel agent execution
2//!
3//! Manages Git worktrees for isolated parallel task execution
4
5use crate::{
6    paths::{normalize_path, WorktreePaths},
7    telemetry::{TelemetryCollector, WorktreeEvent},
8};
9use git2::{BranchType, Repository, RepositoryState};
10use miyabi_types::error::{MiyabiError, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Instant;
16use tokio::sync::{Mutex, Semaphore};
17use uuid::Uuid;
18
19/// Worktree information
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct WorktreeInfo {
22    /// Unique identifier
23    pub id: String,
24    pub issue_number: u64,
25    /// File path
26    pub path: PathBuf,
27    pub branch_name: String,
28    /// Creation timestamp
29    pub created_at: chrono::DateTime<chrono::Utc>,
30    pub status: WorktreeStatus,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum WorktreeStatus {
35    Active,
36    Idle,
37    Completed,
38    Failed,
39}
40
41/// Worktree manager for parallel execution
42#[derive(Clone)]
43pub struct WorktreeManager {
44    repo_path: PathBuf,
45    worktree_paths: WorktreePaths,
46    max_concurrency: usize,
47    semaphore: Arc<Semaphore>,
48    worktrees: Arc<Mutex<HashMap<String, WorktreeInfo>>>,
49    telemetry: Arc<Mutex<TelemetryCollector>>,
50}
51
52impl WorktreeManager {
53    /// Create a new WorktreeManager with automatic Git repository discovery
54    ///
55    /// This is the recommended constructor. It automatically discovers the Git
56    /// repository root from the current directory, even if running from a subdirectory.
57    ///
58    /// # Arguments
59    /// * `worktree_base_name` - Name of the worktree base directory (default: ".worktrees")
60    /// * `max_concurrency` - Maximum number of concurrent worktrees
61    ///
62    /// # Examples
63    ///
64    /// ```no_run
65    /// use miyabi_worktree::WorktreeManager;
66    ///
67    /// # async fn example() -> miyabi_types::error::Result<()> {
68    /// // Works from any subdirectory within a Git repository
69    /// let manager = WorktreeManager::new_with_discovery(Some(".worktrees"), 3)?;
70    /// # Ok(())
71    /// # }
72    /// ```
73    pub fn new_with_discovery(
74        worktree_base_name: Option<&str>,
75        max_concurrency: usize,
76    ) -> Result<Self> {
77        // Discover Git repository root from current directory
78        let repo_path = miyabi_core::find_git_root(None)?;
79
80        tracing::info!("Discovered Git repository root at: {:?}", repo_path);
81
82        // Create worktree base directory relative to repo root
83        let base_component = worktree_base_name
84            .map(PathBuf::from)
85            .unwrap_or_else(|| PathBuf::from(".worktrees"));
86        let worktree_base = repo_path.join(base_component);
87
88        Self::new(repo_path, worktree_base, max_concurrency)
89    }
90
91    /// Create a new WorktreeManager
92    ///
93    /// # Arguments
94    /// * `repo_path` - Path to the main repository
95    /// * `worktree_base` - Base directory for worktrees
96    /// * `max_concurrency` - Maximum number of concurrent worktrees
97    ///
98    /// # Note
99    /// Consider using `new_with_discovery()` instead, which automatically
100    /// finds the Git repository root even when running from a subdirectory.
101    pub fn new(
102        repo_path: impl AsRef<Path>,
103        worktree_base: impl AsRef<Path>,
104        max_concurrency: usize,
105    ) -> Result<Self> {
106        let repo_path = repo_path.as_ref().to_path_buf();
107        let worktree_base = normalize_path(worktree_base.as_ref());
108        let worktree_paths = WorktreePaths::new(&worktree_base);
109
110        // Check if repo_path exists
111        if !repo_path.exists() {
112            return Err(MiyabiError::Git(format!(
113                "Repository path does not exist: {:?}\n\
114                 Hint: Make sure you're running this command from the git repository root, \
115                 or the repository directory has been deleted.",
116                repo_path
117            )));
118        }
119
120        // Validate repository can be opened
121        let repo = Repository::open(&repo_path).map_err(|e| {
122            let git_error = e.to_string();
123            MiyabiError::Git(format!(
124                "Failed to open git repository at {:?}\n\
125                 Git error: {}\n\
126                 Hint: This directory may not be a valid git repository. \
127                 Try running 'git status' to verify the repository state, \
128                 or initialize a new repository with 'git init'.",
129                repo_path, git_error
130            ))
131        })?;
132
133        // Check repository state
134        let state = repo.state();
135        if state != RepositoryState::Clean {
136            tracing::warn!(
137                "Repository is not in a clean state: {:?}. This may cause issues with worktree operations.",
138                state
139            );
140        }
141
142        // Check if there are uncommitted changes (warning only)
143        if let Ok(statuses) = repo.statuses(None) {
144            let uncommitted_count = statuses.len();
145            if uncommitted_count > 0 {
146                tracing::warn!(
147                    "Repository has {} uncommitted change(s).\n\
148                     \n\
149                     Recommended actions:\n\
150                     1. Commit changes: git add . && git commit -m \"Your message\"\n\
151                     2. Stash changes: git stash\n\
152                     3. Proceed anyway (current worktree operations will continue)\n\
153                     \n\
154                     Note: Worktree operations will proceed, but conflicts may occur.",
155                    uncommitted_count
156                );
157            }
158        }
159
160        // Create worktree base directory if it doesn't exist
161        std::fs::create_dir_all(worktree_paths.base()).map_err(|e| {
162            MiyabiError::Io(std::io::Error::new(
163                e.kind(),
164                format!(
165                    "Failed to create worktree base directory at {:?}: {}\n\
166                     Hint: Check file permissions and available disk space.",
167                    worktree_paths.base(),
168                    e
169                ),
170            ))
171        })?;
172
173        tracing::info!(
174            "WorktreeManager initialized: repo={:?}, worktree_base={:?}, max_concurrency={}",
175            repo_path,
176            worktree_paths.base(),
177            max_concurrency
178        );
179
180        Ok(Self {
181            repo_path,
182            worktree_paths,
183            max_concurrency,
184            semaphore: Arc::new(Semaphore::new(max_concurrency)),
185            worktrees: Arc::new(Mutex::new(HashMap::new())),
186            telemetry: Arc::new(Mutex::new(TelemetryCollector::new())),
187        })
188    }
189
190    /// Create a new worktree for an issue
191    ///
192    /// Returns the path to the created worktree
193    pub async fn create_worktree(&self, issue_number: u64) -> Result<WorktreeInfo> {
194        // Acquire semaphore permit for concurrency control
195        let _permit = self
196            .semaphore
197            .acquire()
198            .await
199            .map_err(|e| MiyabiError::Unknown(format!("Failed to acquire semaphore: {}", e)))?;
200
201        let worktree_id = Uuid::new_v4().to_string();
202        let worktree_path =
203            self.worktree_paths
204                .join(format!("issue-{}-{}", issue_number, &worktree_id[..8]));
205        let branch_name = format!("feature/issue-{}", issue_number);
206
207        // Record telemetry: CreateStart
208        {
209            let mut telemetry = self.telemetry.lock().await;
210            telemetry.record(WorktreeEvent::CreateStart {
211                worktree_id: worktree_id.clone(),
212                branch_name: branch_name.clone(),
213            });
214        }
215
216        let start_time = Instant::now();
217
218        tracing::info!("Creating worktree for issue #{} at {:?}", issue_number, worktree_path);
219
220        // Perform all git2 operations in a scope to ensure repo is dropped before await
221        {
222            // Open repository
223            let repo = Repository::open(&self.repo_path)
224                .map_err(|e| MiyabiError::Git(format!("Failed to open repository: {}", e)))?;
225
226            // Check repository state (warning only, don't block worktree creation)
227            let state = repo.state();
228            if state != RepositoryState::Clean {
229                tracing::warn!(
230                    "Repository is not in a clean state: {:?}. \
231                     Worktree creation will proceed, but be aware of potential conflicts.",
232                    state
233                );
234            }
235
236            // Get main branch (try 'main' first, then 'master')
237            let main_branch = self.get_main_branch(&repo)?;
238
239            // Create new branch from main
240            let head_commit = repo
241                .find_branch(&main_branch, BranchType::Local)
242                .map_err(|e| MiyabiError::Git(format!("Failed to find main branch: {}", e)))?
243                .get()
244                .peel_to_commit()
245                .map_err(|e| MiyabiError::Git(format!("Failed to get main commit: {}", e)))?;
246
247            // Check if branch already exists
248            let branch_exists = repo.find_branch(&branch_name, BranchType::Local).is_ok();
249
250            if branch_exists {
251                tracing::warn!("Branch {} already exists, using existing branch", branch_name);
252            } else {
253                repo.branch(&branch_name, &head_commit, false)
254                    .map_err(|e| MiyabiError::Git(format!("Failed to create branch: {}", e)))?;
255            }
256        } // repo is dropped here, before the await point
257
258        // Create worktree using git command (git2 doesn't support worktree creation directly)
259        let output = tokio::process::Command::new("git")
260            .arg("worktree")
261            .arg("add")
262            .arg(&worktree_path)
263            .arg(&branch_name)
264            .current_dir(&self.repo_path)
265            .output()
266            .await
267            .map_err(|e| MiyabiError::Git(format!("Failed to execute git worktree add: {}", e)))?;
268
269        if !output.status.success() {
270            let stderr = String::from_utf8_lossy(&output.stderr);
271            return Err(MiyabiError::Git(format!("Failed to create worktree: {}", stderr)));
272        }
273
274        let worktree_info = WorktreeInfo {
275            id: worktree_id.clone(),
276            issue_number,
277            path: worktree_path.clone(),
278            branch_name: branch_name.clone(),
279            created_at: chrono::Utc::now(),
280            status: WorktreeStatus::Active,
281        };
282
283        // Store worktree info
284        {
285            let mut worktrees = self.worktrees.lock().await;
286            worktrees.insert(worktree_id.clone(), worktree_info.clone());
287        }
288
289        // Record telemetry: CreateComplete
290        {
291            let mut telemetry = self.telemetry.lock().await;
292            telemetry.record(WorktreeEvent::CreateComplete {
293                worktree_id: worktree_id.clone(),
294                duration: start_time.elapsed(),
295            });
296        }
297
298        tracing::info!("Worktree created successfully at {:?}", worktree_path);
299
300        Ok(worktree_info)
301    }
302
303    /// Remove worktree after task completion with safety checks
304    ///
305    /// This method implements the safe worktree deletion protocol:
306    /// 1. Check if current directory is inside the worktree to be deleted
307    /// 2. If yes, change to repository root first
308    /// 3. Verify worktree exists
309    /// 4. Execute git worktree remove
310    /// 5. Run git worktree prune
311    ///
312    /// This prevents "Unable to read current working directory" errors
313    /// that occur when deleting a worktree while the shell is inside it.
314    pub async fn remove_worktree(&self, worktree_id: &str) -> Result<()> {
315        let worktree_info = {
316            let worktrees = self.worktrees.lock().await;
317            worktrees.get(worktree_id).cloned().ok_or_else(|| {
318                MiyabiError::Unknown(format!("Worktree {} not found", worktree_id))
319            })?
320        };
321
322        // Record telemetry: CleanupStart
323        {
324            let mut telemetry = self.telemetry.lock().await;
325            telemetry.record(WorktreeEvent::CleanupStart {
326                worktree_id: worktree_id.to_string(),
327            });
328        }
329
330        let start_time = Instant::now();
331
332        tracing::info!("Removing worktree {:?}", worktree_info.path);
333
334        // SAFETY CHECK: Ensure we're not in the worktree directory
335        // This prevents bash session crashes when deleting the current directory
336        if let Ok(current_dir) = std::env::current_dir() {
337            if current_dir.starts_with(&worktree_info.path) {
338                tracing::warn!(
339                    "Current directory is inside worktree to be deleted. Changing to repository root first."
340                );
341                if let Err(e) = std::env::set_current_dir(&self.repo_path) {
342                    tracing::error!(
343                        "Failed to change directory to repository root: {}. This may cause issues.",
344                        e
345                    );
346                    // Continue anyway - the git command uses current_dir() parameter
347                }
348            }
349        }
350
351        // Check if worktree path exists
352        if !worktree_info.path.exists() {
353            tracing::warn!(
354                "Worktree path does not exist: {:?}. It may have been already removed.",
355                worktree_info.path
356            );
357            // Continue to clean up tracking data
358        } else {
359            // Remove worktree using git command
360            // Always use repo_path as current_dir to avoid being inside the worktree
361            let output = tokio::process::Command::new("git")
362                .arg("worktree")
363                .arg("remove")
364                .arg(&worktree_info.path)
365                .arg("--force")
366                .current_dir(&self.repo_path)
367                .output()
368                .await
369                .map_err(|e| {
370                    MiyabiError::Git(format!("Failed to execute git worktree remove: {}", e))
371                })?;
372
373            if !output.status.success() {
374                let stderr = String::from_utf8_lossy(&output.stderr);
375                tracing::warn!("Failed to remove worktree: {}", stderr);
376            }
377        }
378
379        // Run git worktree prune to clean up worktree metadata
380        // This removes stale worktree administrative files
381        let prune_output = tokio::process::Command::new("git")
382            .arg("worktree")
383            .arg("prune")
384            .current_dir(&self.repo_path)
385            .output()
386            .await
387            .map_err(|e| {
388                MiyabiError::Git(format!("Failed to execute git worktree prune: {}", e))
389            })?;
390
391        if !prune_output.status.success() {
392            let stderr = String::from_utf8_lossy(&prune_output.stderr);
393            tracing::warn!("Failed to prune worktrees: {}", stderr);
394        } else {
395            tracing::info!("✅ git worktree prune completed successfully");
396        }
397
398        // Remove branch (in a scope to ensure repo is dropped before await)
399        {
400            let repo = Repository::open(&self.repo_path)
401                .map_err(|e| MiyabiError::Git(format!("Failed to open repository: {}", e)))?;
402
403            // Check if branch exists and delete it
404            let branch_result = repo.find_branch(&worktree_info.branch_name, BranchType::Local);
405            if let Ok(mut branch) = branch_result {
406                branch
407                    .delete()
408                    .map_err(|e| MiyabiError::Git(format!("Failed to delete branch: {}", e)))?;
409            } else {
410                tracing::debug!(
411                    "Branch {} not found, skipping deletion",
412                    worktree_info.branch_name
413                );
414            }
415        } // repo is dropped here, before the await point
416
417        // Remove from tracked worktrees
418        {
419            let mut worktrees = self.worktrees.lock().await;
420            worktrees.remove(worktree_id);
421        }
422
423        // Record telemetry: CleanupComplete
424        {
425            let mut telemetry = self.telemetry.lock().await;
426            telemetry.record(WorktreeEvent::CleanupComplete {
427                worktree_id: worktree_id.to_string(),
428                duration: start_time.elapsed(),
429            });
430        }
431
432        tracing::info!("Worktree removed successfully");
433
434        Ok(())
435    }
436
437    /// Push worktree changes to remote
438    pub async fn push_worktree(&self, worktree_id: &str) -> Result<()> {
439        let worktree_info = {
440            let worktrees = self.worktrees.lock().await;
441            worktrees.get(worktree_id).cloned().ok_or_else(|| {
442                MiyabiError::Unknown(format!("Worktree {} not found", worktree_id))
443            })?
444        };
445
446        tracing::info!("Pushing worktree branch {}", worktree_info.branch_name);
447
448        let output = tokio::process::Command::new("git")
449            .arg("push")
450            .arg("origin")
451            .arg(&worktree_info.branch_name)
452            .arg("--set-upstream")
453            .current_dir(&worktree_info.path)
454            .output()
455            .await
456            .map_err(|e| MiyabiError::Git(format!("Failed to execute git push: {}", e)))?;
457
458        if !output.status.success() {
459            let stderr = String::from_utf8_lossy(&output.stderr);
460            return Err(MiyabiError::Git(format!("Failed to push: {}", stderr)));
461        }
462
463        tracing::info!("Worktree pushed successfully");
464
465        Ok(())
466    }
467
468    /// Merge worktree branch into main
469    pub async fn merge_worktree(&self, worktree_id: &str) -> Result<()> {
470        let worktree_info = {
471            let worktrees = self.worktrees.lock().await;
472            worktrees.get(worktree_id).cloned().ok_or_else(|| {
473                MiyabiError::Unknown(format!("Worktree {} not found", worktree_id))
474            })?
475        };
476
477        let main_branch = {
478            let repo = Repository::open(&self.repo_path)
479                .map_err(|e| MiyabiError::Git(format!("Failed to open repository: {}", e)))?;
480            self.get_main_branch(&repo)?
481        };
482
483        tracing::info!("Merging branch {} into {}", worktree_info.branch_name, main_branch);
484
485        // Checkout main branch
486        let output = tokio::process::Command::new("git")
487            .arg("checkout")
488            .arg(&main_branch)
489            .current_dir(&self.repo_path)
490            .output()
491            .await
492            .map_err(|e| MiyabiError::Git(format!("Failed to checkout main: {}", e)))?;
493
494        if !output.status.success() {
495            let stderr = String::from_utf8_lossy(&output.stderr);
496            return Err(MiyabiError::Git(format!("Failed to checkout main: {}", stderr)));
497        }
498
499        // Merge feature branch
500        let output = tokio::process::Command::new("git")
501            .arg("merge")
502            .arg(&worktree_info.branch_name)
503            .arg("--no-ff")
504            .current_dir(&self.repo_path)
505            .output()
506            .await
507            .map_err(|e| MiyabiError::Git(format!("Failed to merge: {}", e)))?;
508
509        if !output.status.success() {
510            let stderr = String::from_utf8_lossy(&output.stderr);
511            return Err(MiyabiError::Git(format!("Merge failed: {}", stderr)));
512        }
513
514        tracing::info!("Branch merged successfully");
515
516        Ok(())
517    }
518
519    /// Update worktree status
520    pub async fn update_status(&self, worktree_id: &str, status: WorktreeStatus) -> Result<()> {
521        let mut worktrees = self.worktrees.lock().await;
522        if let Some(info) = worktrees.get_mut(worktree_id) {
523            info.status = status;
524            Ok(())
525        } else {
526            Err(MiyabiError::Unknown(format!("Worktree {} not found", worktree_id)))
527        }
528    }
529
530    /// Get worktree information
531    pub async fn get_worktree(&self, worktree_id: &str) -> Result<WorktreeInfo> {
532        let worktrees = self.worktrees.lock().await;
533        worktrees
534            .get(worktree_id)
535            .cloned()
536            .ok_or_else(|| MiyabiError::Unknown(format!("Worktree {} not found", worktree_id)))
537    }
538
539    /// List all worktrees
540    pub async fn list_worktrees(&self) -> Vec<WorktreeInfo> {
541        let worktrees = self.worktrees.lock().await;
542        worktrees.values().cloned().collect()
543    }
544
545    /// Get worktree statistics
546    pub async fn stats(&self) -> WorktreeStats {
547        let worktrees = self.worktrees.lock().await;
548        let total = worktrees.len();
549        let active = worktrees.values().filter(|w| w.status == WorktreeStatus::Active).count();
550        let idle = worktrees.values().filter(|w| w.status == WorktreeStatus::Idle).count();
551        let completed =
552            worktrees.values().filter(|w| w.status == WorktreeStatus::Completed).count();
553        let failed = worktrees.values().filter(|w| w.status == WorktreeStatus::Failed).count();
554
555        WorktreeStats {
556            total,
557            active,
558            idle,
559            completed,
560            failed,
561            max_concurrency: self.max_concurrency,
562            available_slots: self.semaphore.available_permits(),
563        }
564    }
565
566    /// Cleanup all worktrees
567    pub async fn cleanup_all(&self) -> Result<()> {
568        tracing::info!("Cleaning up all worktrees");
569
570        let worktree_ids: Vec<String> = {
571            let worktrees = self.worktrees.lock().await;
572            worktrees.keys().cloned().collect()
573        };
574
575        for id in worktree_ids {
576            if let Err(e) = self.remove_worktree(&id).await {
577                tracing::warn!("Failed to remove worktree {}: {}", id, e);
578            }
579        }
580
581        // Run git worktree prune
582        let _ = tokio::process::Command::new("git")
583            .arg("worktree")
584            .arg("prune")
585            .current_dir(&self.repo_path)
586            .output()
587            .await;
588
589        tracing::info!("Cleanup completed");
590
591        Ok(())
592    }
593
594    /// Get main branch name (tries 'main' then 'master')
595    fn get_main_branch(&self, _repo: &Repository) -> Result<String> {
596        // Use the miyabi-core git utility
597        miyabi_core::get_main_branch(&self.repo_path)
598    }
599
600    /// Get telemetry report (human-readable)
601    pub async fn telemetry_report(&self) -> String {
602        let telemetry = self.telemetry.lock().await;
603        telemetry.generate_report()
604    }
605
606    /// Get telemetry statistics
607    pub async fn telemetry_stats(&self) -> crate::telemetry::TelemetryStats {
608        let telemetry = self.telemetry.lock().await;
609        telemetry.generate_stats()
610    }
611}
612
613/// Worktree statistics
614#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct WorktreeStats {
616    /// Total
617    pub total: usize,
618    pub active: usize,
619    /// Idle
620    pub idle: usize,
621    pub completed: usize,
622    /// Failed
623    pub failed: usize,
624    pub max_concurrency: usize,
625    /// Available slots
626    pub available_slots: usize,
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use serial_test::serial;
633
634    // Note: These tests require a valid git repository and are marked as serial
635    // to avoid conflicts when running in parallel
636
637    #[tokio::test]
638    #[serial]
639    async fn test_worktree_info_serialization() {
640        let info = WorktreeInfo {
641            id: "test-id".to_string(),
642            issue_number: 123,
643            path: PathBuf::from("/tmp/worktree"),
644            branch_name: "feature/issue-123".to_string(),
645            created_at: chrono::Utc::now(),
646            status: WorktreeStatus::Active,
647        };
648
649        let json = serde_json::to_string(&info).unwrap();
650        let deserialized: WorktreeInfo = serde_json::from_str(&json).unwrap();
651
652        assert_eq!(info.id, deserialized.id);
653        assert_eq!(info.issue_number, deserialized.issue_number);
654        assert_eq!(info.status, deserialized.status);
655    }
656
657    #[test]
658    fn test_worktree_status_equality() {
659        assert_eq!(WorktreeStatus::Active, WorktreeStatus::Active);
660        assert_ne!(WorktreeStatus::Active, WorktreeStatus::Idle);
661    }
662
663    #[test]
664    fn test_worktree_stats_creation() {
665        let stats = WorktreeStats {
666            total: 10,
667            active: 3,
668            idle: 2,
669            completed: 4,
670            failed: 1,
671            max_concurrency: 5,
672            available_slots: 2,
673        };
674
675        assert_eq!(stats.total, 10);
676        assert_eq!(stats.active, 3);
677        assert_eq!(stats.available_slots, 2);
678    }
679
680    // Integration tests would require a real git repository
681    // These are skipped in CI/CD but can be run manually
682}