1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct WorktreeInfo {
22 pub id: String,
24 pub issue_number: u64,
25 pub path: PathBuf,
27 pub branch_name: String,
28 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#[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 pub fn new_with_discovery(
74 worktree_base_name: Option<&str>,
75 max_concurrency: usize,
76 ) -> Result<Self> {
77 let repo_path = miyabi_core::find_git_root(None)?;
79
80 tracing::info!("Discovered Git repository root at: {:?}", repo_path);
81
82 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 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 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 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 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 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 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 pub async fn create_worktree(&self, issue_number: u64) -> Result<WorktreeInfo> {
194 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 {
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 {
222 let repo = Repository::open(&self.repo_path)
224 .map_err(|e| MiyabiError::Git(format!("Failed to open repository: {}", e)))?;
225
226 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 let main_branch = self.get_main_branch(&repo)?;
238
239 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 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 } 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 {
285 let mut worktrees = self.worktrees.lock().await;
286 worktrees.insert(worktree_id.clone(), worktree_info.clone());
287 }
288
289 {
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 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 {
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 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 }
348 }
349 }
350
351 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 } else {
359 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 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 {
400 let repo = Repository::open(&self.repo_path)
401 .map_err(|e| MiyabiError::Git(format!("Failed to open repository: {}", e)))?;
402
403 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 } {
419 let mut worktrees = self.worktrees.lock().await;
420 worktrees.remove(worktree_id);
421 }
422
423 {
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 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 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 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 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 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 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 pub async fn list_worktrees(&self) -> Vec<WorktreeInfo> {
541 let worktrees = self.worktrees.lock().await;
542 worktrees.values().cloned().collect()
543 }
544
545 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 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 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 fn get_main_branch(&self, _repo: &Repository) -> Result<String> {
596 miyabi_core::get_main_branch(&self.repo_path)
598 }
599
600 pub async fn telemetry_report(&self) -> String {
602 let telemetry = self.telemetry.lock().await;
603 telemetry.generate_report()
604 }
605
606 pub async fn telemetry_stats(&self) -> crate::telemetry::TelemetryStats {
608 let telemetry = self.telemetry.lock().await;
609 telemetry.generate_stats()
610 }
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct WorktreeStats {
616 pub total: usize,
618 pub active: usize,
619 pub idle: usize,
621 pub completed: usize,
622 pub failed: usize,
624 pub max_concurrency: usize,
625 pub available_slots: usize,
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use serial_test::serial;
633
634 #[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 }