Skip to main content

vibe_workspace/worktree/
status.rs

1//! Worktree status tracking and reporting
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime};
7use tokio::process::Command;
8use tracing::debug;
9
10use crate::worktree::config::WorktreeMergeDetectionConfig;
11use crate::worktree::merge_detection::detect_worktree_merge_status;
12
13/// Comprehensive information about a Git worktree
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorktreeInfo {
16    /// Path to the worktree directory
17    pub path: PathBuf,
18
19    /// Branch name associated with this worktree
20    pub branch: String,
21
22    /// Current HEAD commit SHA
23    pub head: String,
24
25    /// Task identifier used to create this worktree (if available)
26    pub task_id: Option<String>,
27
28    /// Detailed status information
29    pub status: WorktreeStatus,
30
31    /// Age of the worktree directory
32    pub age: Duration,
33
34    /// Whether this worktree is detached HEAD
35    pub is_detached: bool,
36}
37
38/// Detailed status information for a worktree
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WorktreeStatus {
41    /// Overall cleanliness of the worktree
42    pub is_clean: bool,
43
44    /// Severity level for UI display
45    pub severity: StatusSeverity,
46
47    /// List of uncommitted changed files
48    pub uncommitted_changes: Vec<String>,
49
50    /// List of untracked files
51    pub untracked_files: Vec<String>,
52
53    /// List of unpushed commits
54    pub unpushed_commits: Vec<CommitInfo>,
55
56    /// Remote branch tracking status
57    pub remote_status: RemoteStatus,
58
59    /// Merge detection information
60    pub merge_info: Option<MergeInfo>,
61
62    /// Number of commits ahead of remote
63    pub ahead_count: usize,
64
65    /// Number of commits behind remote
66    pub behind_count: usize,
67}
68
69/// Status severity levels for different types of issues
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub enum StatusSeverity {
72    /// ✅ No issues - clean worktree with everything synced
73    Clean,
74
75    /// ⚠️ Light warning - worktree issues (uncommitted/unsynced)
76    LightWarning,
77
78    /// ⚡ Warning - feature branch issues (stale, conflicts, etc.)
79    Warning,
80}
81
82/// Information about a commit
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CommitInfo {
85    /// Commit SHA (short form)
86    pub id: String,
87
88    /// Commit message (first line)
89    pub message: String,
90
91    /// Author name
92    pub author: String,
93
94    /// Commit timestamp
95    pub timestamp: SystemTime,
96}
97
98/// Remote branch tracking status
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub enum RemoteStatus {
101    /// No remote tracking branch configured
102    NoRemote,
103
104    /// Remote branch exists and is up to date
105    UpToDate,
106
107    /// Local is ahead of remote
108    Ahead(usize),
109
110    /// Local is behind remote
111    Behind(usize),
112
113    /// Both ahead and behind (diverged)
114    Diverged { ahead: usize, behind: usize },
115
116    /// Remote branch was deleted
117    RemoteDeleted,
118}
119
120/// Information about merge status detection
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct MergeInfo {
123    /// Whether the branch appears to be merged
124    pub is_merged: bool,
125
126    /// Method used to detect the merge
127    pub detection_method: String,
128
129    /// Additional information about the merge
130    pub details: Option<String>,
131
132    /// Confidence level (0.0 to 1.0)
133    pub confidence: f32,
134}
135
136impl WorktreeStatus {
137    /// Create a new empty status
138    pub fn new() -> Self {
139        Self {
140            is_clean: false,
141            severity: StatusSeverity::Warning,
142            uncommitted_changes: Vec::new(),
143            untracked_files: Vec::new(),
144            unpushed_commits: Vec::new(),
145            remote_status: RemoteStatus::NoRemote,
146            merge_info: None,
147            ahead_count: 0,
148            behind_count: 0,
149        }
150    }
151
152    /// Check if this worktree is safe to clean up
153    pub fn is_safe_to_cleanup(&self) -> bool {
154        self.is_clean
155            && self.uncommitted_changes.is_empty()
156            && self.untracked_files.is_empty()
157            && (self.unpushed_commits.is_empty()
158                || self
159                    .merge_info
160                    .as_ref()
161                    .map_or(false, |info| info.is_merged))
162    }
163
164    /// Get a user-friendly status description
165    pub fn status_description(&self) -> String {
166        if self.is_clean {
167            match &self.merge_info {
168                Some(info) if info.is_merged => format!("Clean ({})", info.detection_method),
169                _ => "Clean".to_string(),
170            }
171        } else {
172            let mut issues = Vec::new();
173
174            if !self.uncommitted_changes.is_empty() {
175                issues.push(format!("{} uncommitted", self.uncommitted_changes.len()));
176            }
177
178            if !self.untracked_files.is_empty() {
179                issues.push(format!("{} untracked", self.untracked_files.len()));
180            }
181
182            if !self.unpushed_commits.is_empty() {
183                issues.push(format!("{} unpushed", self.unpushed_commits.len()));
184            }
185
186            match &self.remote_status {
187                RemoteStatus::NoRemote => issues.push("no remote".to_string()),
188                RemoteStatus::Behind(count) => issues.push(format!("{} behind", count)),
189                RemoteStatus::Diverged { ahead, behind } => {
190                    issues.push(format!("{} ahead, {} behind", ahead, behind));
191                }
192                RemoteStatus::RemoteDeleted => issues.push("remote deleted".to_string()),
193                _ => {}
194            }
195
196            if issues.is_empty() {
197                "Unknown issue".to_string()
198            } else {
199                issues.join(", ")
200            }
201        }
202    }
203
204    /// Get the appropriate status icon
205    pub fn status_icon(&self) -> &'static str {
206        match self.severity {
207            StatusSeverity::Clean => "✅",
208            StatusSeverity::LightWarning => "⚠️",
209            StatusSeverity::Warning => "⚡",
210        }
211    }
212}
213
214impl Default for WorktreeStatus {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220impl StatusSeverity {
221    /// Get numeric priority for sorting (lower is more severe)
222    pub fn priority(&self) -> u8 {
223        match self {
224            StatusSeverity::Warning => 0,
225            StatusSeverity::LightWarning => 1,
226            StatusSeverity::Clean => 2,
227        }
228    }
229}
230
231impl WorktreeInfo {
232    /// Update status information for this worktree
233    pub async fn update_status(&mut self) -> Result<()> {
234        self.status = check_worktree_status(&self.path).await?;
235        self.update_age()?;
236        Ok(())
237    }
238
239    /// Update the age of this worktree
240    fn update_age(&mut self) -> Result<()> {
241        if let Ok(metadata) = std::fs::metadata(&self.path) {
242            if let Ok(created) = metadata.created() {
243                self.age = std::time::SystemTime::now().duration_since(created)?;
244            }
245        }
246        Ok(())
247    }
248}
249
250/// Repository-level summary statistics for worktree management
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct RepositoryWorktreeSummary {
253    /// Total number of worktrees (including main)
254    pub total_worktrees: usize,
255
256    /// Number of clean worktrees
257    pub clean_worktrees: usize,
258
259    /// Number of worktrees with uncommitted changes
260    pub dirty_worktrees: usize,
261
262    /// Number of worktrees with remote tracking
263    pub worktrees_with_remote: usize,
264
265    /// Number of worktrees that appear merged
266    pub merged_worktrees: usize,
267
268    /// Number of worktrees with unpushed commits
269    pub worktrees_with_unpushed: usize,
270
271    /// Overall health score (0.0 to 1.0)
272    pub health_score: f32,
273}
274
275impl RepositoryWorktreeSummary {
276    /// Create summary from a list of worktrees
277    pub fn from_worktrees(worktrees: &[WorktreeInfo]) -> Self {
278        let total = worktrees.len();
279        let clean = worktrees.iter().filter(|w| w.status.is_clean).count();
280        let dirty = worktrees.iter().filter(|w| !w.status.is_clean).count();
281        let with_remote = worktrees
282            .iter()
283            .filter(|w| !matches!(w.status.remote_status, RemoteStatus::NoRemote))
284            .count();
285        let merged = worktrees
286            .iter()
287            .filter(|w| {
288                w.status
289                    .merge_info
290                    .as_ref()
291                    .map_or(false, |info| info.is_merged)
292            })
293            .count();
294        let with_unpushed = worktrees
295            .iter()
296            .filter(|w| !w.status.unpushed_commits.is_empty())
297            .count();
298
299        // Calculate health score based on cleanliness and remote tracking
300        let health_score = if total == 0 {
301            1.0
302        } else {
303            let clean_ratio = clean as f32 / total as f32;
304            let remote_ratio = with_remote as f32 / total as f32;
305            // Weight cleanliness more heavily than remote tracking
306            clean_ratio * 0.7 + remote_ratio * 0.3
307        };
308
309        Self {
310            total_worktrees: total,
311            clean_worktrees: clean,
312            dirty_worktrees: dirty,
313            worktrees_with_remote: with_remote,
314            merged_worktrees: merged,
315            worktrees_with_unpushed: with_unpushed,
316            health_score,
317        }
318    }
319
320    /// Get a summary description string
321    pub fn summary_description(&self) -> String {
322        let mut parts = Vec::new();
323
324        parts.push(format!("{} worktrees", self.total_worktrees));
325
326        if self.dirty_worktrees > 0 {
327            parts.push(format!("{} dirty", self.dirty_worktrees));
328        }
329
330        if self.worktrees_with_unpushed > 0 {
331            parts.push(format!("{} unpushed", self.worktrees_with_unpushed));
332        }
333
334        if self.merged_worktrees > 0 {
335            parts.push(format!("{} merged", self.merged_worktrees));
336        }
337
338        let remote_missing = self.total_worktrees - self.worktrees_with_remote;
339        if remote_missing > 0 {
340            parts.push(format!("{} no remote", remote_missing));
341        }
342
343        parts.join(", ")
344    }
345
346    /// Get health status icon
347    pub fn health_icon(&self) -> &'static str {
348        if self.health_score >= 0.8 {
349            "🟢"
350        } else if self.health_score >= 0.5 {
351            "🟡"
352        } else {
353            "🔴"
354        }
355    }
356
357    /// Get health description
358    pub fn health_description(&self) -> String {
359        if self.health_score >= 0.8 {
360            "Healthy".to_string()
361        } else if self.health_score >= 0.5 {
362            "Needs attention".to_string()
363        } else {
364            "Unhealthy".to_string()
365        }
366    }
367}
368
369/// Check comprehensive status for a worktree
370pub async fn check_worktree_status(worktree_path: &Path) -> Result<WorktreeStatus> {
371    check_worktree_status_with_config(worktree_path, None).await
372}
373
374/// Check comprehensive status for a worktree with optional merge detection config
375pub async fn check_worktree_status_with_config(
376    worktree_path: &Path,
377    merge_config: Option<&WorktreeMergeDetectionConfig>,
378) -> Result<WorktreeStatus> {
379    let mut status = WorktreeStatus::new();
380
381    // Get basic git status (staged, unstaged, untracked files)
382    let git_status = get_git_porcelain_status(worktree_path).await?;
383    status.uncommitted_changes = git_status.changed_files;
384    status.untracked_files = git_status.untracked_files;
385
386    // Get remote status and commit information
387    let remote_info = get_remote_status(worktree_path).await?;
388    status.remote_status = remote_info.status;
389    status.ahead_count = remote_info.ahead;
390    status.behind_count = remote_info.behind;
391
392    // Get unpushed commits
393    if status.ahead_count > 0 {
394        status.unpushed_commits = get_unpushed_commits(worktree_path).await?;
395    }
396
397    // Add merge detection if config is provided
398    if let Some(config) = merge_config {
399        if let Ok(current_branch) = get_current_branch(worktree_path).await {
400            match detect_worktree_merge_status(worktree_path, &current_branch, config).await {
401                Ok(merge_info) => {
402                    status.merge_info = Some(merge_info);
403                }
404                Err(e) => {
405                    debug!(
406                        "Merge detection failed for branch '{}': {}",
407                        current_branch, e
408                    );
409                    // Continue without merge info rather than failing
410                }
411            }
412        }
413    }
414
415    // Determine overall cleanliness
416    status.is_clean = status.uncommitted_changes.is_empty()
417        && status.untracked_files.is_empty()
418        && (status.ahead_count == 0 || matches!(status.remote_status, RemoteStatus::NoRemote));
419
420    // Classify severity (now considering merge status)
421    status.severity = classify_status_severity(&status);
422
423    Ok(status)
424}
425
426/// Get git status in porcelain format for parsing
427async fn get_git_porcelain_status(worktree_path: &Path) -> Result<GitStatusInfo> {
428    let output = Command::new("git")
429        .args(&["status", "--porcelain=v1", "-z"])
430        .current_dir(worktree_path)
431        .output()
432        .await
433        .with_context(|| format!("Failed to get git status for: {}", worktree_path.display()))?;
434
435    if !output.status.success() {
436        let stderr = String::from_utf8_lossy(&output.stderr);
437        return Err(anyhow::anyhow!("Git status failed: {}", stderr));
438    }
439
440    parse_porcelain_status(&output.stdout)
441}
442
443/// Parse git status porcelain output
444fn parse_porcelain_status(output: &[u8]) -> Result<GitStatusInfo> {
445    let output_str = String::from_utf8_lossy(output);
446    let mut changed_files = Vec::new();
447    let mut untracked_files = Vec::new();
448
449    for line in output_str.split('\0') {
450        if line.is_empty() {
451            continue;
452        }
453
454        if line.len() < 3 {
455            continue;
456        }
457
458        let status_code = &line[0..2];
459        let file_path = &line[3..];
460
461        match status_code {
462            "??" => {
463                untracked_files.push(file_path.to_string());
464            }
465            _ => {
466                let status_desc = match status_code {
467                    "M " => "modified (unstaged)",
468                    " M" => "modified (staged)",
469                    "MM" => "modified (both staged and unstaged)",
470                    "A " => "added (staged)",
471                    " A" => "added (unstaged)",
472                    "D " => "deleted (staged)",
473                    " D" => "deleted (unstaged)",
474                    "R " => "renamed (staged)",
475                    " R" => "renamed (unstaged)",
476                    "C " => "copied (staged)",
477                    " C" => "copied (unstaged)",
478                    "U " | " U" | "UU" => "unmerged",
479                    _ => "unknown",
480                };
481
482                changed_files.push(format!("{}: {}", status_desc, file_path));
483            }
484        }
485    }
486
487    Ok(GitStatusInfo {
488        changed_files,
489        untracked_files,
490    })
491}
492
493/// Get remote branch status and ahead/behind counts
494async fn get_remote_status(worktree_path: &Path) -> Result<RemoteInfo> {
495    // First, check if there's a remote tracking branch
496    let upstream_result = Command::new("git")
497        .args(&["rev-parse", "--abbrev-ref", "@{u}"])
498        .current_dir(worktree_path)
499        .output()
500        .await;
501
502    let upstream_branch = match upstream_result {
503        Ok(output) if output.status.success() => {
504            Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
505        }
506        _ => None,
507    };
508
509    if upstream_branch.is_none() {
510        return Ok(RemoteInfo {
511            status: RemoteStatus::NoRemote,
512            ahead: 0,
513            behind: 0,
514        });
515    }
516
517    // Get ahead/behind counts
518    let count_output = Command::new("git")
519        .args(&["rev-list", "--count", "--left-right", "@{u}...HEAD"])
520        .current_dir(worktree_path)
521        .output()
522        .await?;
523
524    if !count_output.status.success() {
525        // Remote branch might be deleted
526        return Ok(RemoteInfo {
527            status: RemoteStatus::RemoteDeleted,
528            ahead: 0,
529            behind: 0,
530        });
531    }
532
533    let count_str = String::from_utf8_lossy(&count_output.stdout);
534    let counts: Vec<&str> = count_str.trim().split_whitespace().collect();
535
536    let (behind, ahead) = if counts.len() >= 2 {
537        let behind = counts[0].parse::<usize>().unwrap_or(0);
538        let ahead = counts[1].parse::<usize>().unwrap_or(0);
539        (behind, ahead)
540    } else {
541        (0, 0)
542    };
543
544    let status = match (ahead, behind) {
545        (0, 0) => RemoteStatus::UpToDate,
546        (a, 0) if a > 0 => RemoteStatus::Ahead(a),
547        (0, b) if b > 0 => RemoteStatus::Behind(b),
548        (a, b) if a > 0 && b > 0 => RemoteStatus::Diverged {
549            ahead: a,
550            behind: b,
551        },
552        _ => RemoteStatus::UpToDate,
553    };
554
555    Ok(RemoteInfo {
556        status,
557        ahead,
558        behind,
559    })
560}
561
562/// Get list of unpushed commits with details
563async fn get_unpushed_commits(worktree_path: &Path) -> Result<Vec<CommitInfo>> {
564    let output = Command::new("git")
565        .args(&["log", "--oneline", "--format=%H|%s|%an|%ct", "@{u}..HEAD"])
566        .current_dir(worktree_path)
567        .output()
568        .await?;
569
570    if !output.status.success() {
571        return Ok(Vec::new());
572    }
573
574    let output_str = String::from_utf8_lossy(&output.stdout);
575    let mut commits = Vec::new();
576
577    for line in output_str.lines() {
578        if line.is_empty() {
579            continue;
580        }
581
582        let parts: Vec<&str> = line.split('|').collect();
583        if parts.len() >= 4 {
584            let full_sha = parts[0];
585            let short_sha = if full_sha.len() >= 7 {
586                &full_sha[..7]
587            } else {
588                full_sha
589            };
590
591            let timestamp_secs = parts[3].parse::<u64>().unwrap_or(0);
592            let timestamp = std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp_secs);
593
594            commits.push(CommitInfo {
595                id: short_sha.to_string(),
596                message: parts[1].to_string(),
597                author: parts[2].to_string(),
598                timestamp,
599            });
600        }
601    }
602
603    Ok(commits)
604}
605
606/// Get the current branch name
607async fn get_current_branch(worktree_path: &Path) -> Result<String> {
608    let output = Command::new("git")
609        .args(&["rev-parse", "--abbrev-ref", "HEAD"])
610        .current_dir(worktree_path)
611        .output()
612        .await?;
613
614    if output.status.success() {
615        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
616    } else {
617        Err(anyhow::anyhow!("Failed to get current branch"))
618    }
619}
620
621/// Classify the overall severity of a worktree's status
622fn classify_status_severity(status: &WorktreeStatus) -> StatusSeverity {
623    // If branch is merged with high confidence, it's safer to clean
624    if let Some(merge_info) = &status.merge_info {
625        if merge_info.is_merged && merge_info.confidence > 0.8 {
626            // Even with uncommitted changes, merged branches are less concerning
627            if status.is_clean {
628                return StatusSeverity::Clean;
629            } else {
630                return StatusSeverity::LightWarning;
631            }
632        }
633    }
634
635    // Clean status: everything is up to date
636    if status.is_clean && status.ahead_count == 0 && status.behind_count == 0 {
637        return StatusSeverity::Clean;
638    }
639
640    // Warning (⚡) - serious issues with the branch itself
641    if matches!(status.remote_status, RemoteStatus::RemoteDeleted) {
642        return StatusSeverity::Warning;
643    }
644
645    if status.behind_count > 10 {
646        return StatusSeverity::Warning;
647    }
648
649    if let RemoteStatus::Diverged { ahead, behind } = status.remote_status {
650        if behind > 5 || ahead > 20 {
651            return StatusSeverity::Warning;
652        }
653    }
654
655    // Light warning (⚠️) - typical worktree development issues
656    if !status.uncommitted_changes.is_empty()
657        || !status.untracked_files.is_empty()
658        || status.ahead_count > 0
659        || status.behind_count > 0
660        || matches!(status.remote_status, RemoteStatus::NoRemote)
661    {
662        return StatusSeverity::LightWarning;
663    }
664
665    StatusSeverity::Clean
666}
667
668/// Check if a worktree has any activity in the last N days
669pub async fn check_worktree_activity(worktree_path: &Path, days: u64) -> Result<bool> {
670    let since = format!("--since={} days ago", days);
671
672    let output = Command::new("git")
673        .args(&["log", "--oneline", &since, "HEAD"])
674        .current_dir(worktree_path)
675        .output()
676        .await?;
677
678    Ok(output.status.success() && !output.stdout.is_empty())
679}
680
681/// Get detailed file-level diff for conflicts or changes
682pub async fn get_worktree_diff(worktree_path: &Path, compact: bool) -> Result<String> {
683    let mut args = vec!["diff"];
684
685    if compact {
686        args.extend(&["--name-status"]);
687    } else {
688        args.extend(&["--stat", "--color=never"]);
689    }
690
691    let output = Command::new("git")
692        .args(&args)
693        .current_dir(worktree_path)
694        .output()
695        .await?;
696
697    if output.status.success() {
698        Ok(String::from_utf8_lossy(&output.stdout).to_string())
699    } else {
700        Ok(String::new())
701    }
702}
703
704/// Get branch creation time and first commit
705pub async fn get_branch_info(worktree_path: &Path) -> Result<BranchInfo> {
706    // Get current branch name
707    let branch_output = Command::new("git")
708        .args(&["rev-parse", "--abbrev-ref", "HEAD"])
709        .current_dir(worktree_path)
710        .output()
711        .await?;
712
713    let branch_name = String::from_utf8_lossy(&branch_output.stdout)
714        .trim()
715        .to_string();
716
717    // Get first commit on this branch (if it's not the default branch)
718    // First, try to find the default branch
719    let default_branch_result = Command::new("git")
720        .args(&["symbolic-ref", "refs/remotes/origin/HEAD"])
721        .current_dir(worktree_path)
722        .output()
723        .await;
724
725    // If current branch is a common default branch, assume it's the main branch
726    if branch_name == "main" || branch_name == "master" {
727        return Ok(BranchInfo {
728            name: branch_name,
729            first_commit: None,
730            commit_count: 0,
731        });
732    }
733
734    let default_branch = match default_branch_result {
735        Ok(output) if output.status.success() => {
736            let full_ref = String::from_utf8_lossy(&output.stdout);
737            let trimmed = full_ref.trim();
738            trimmed
739                .strip_prefix("refs/remotes/origin/")
740                .unwrap_or("main")
741                .to_string()
742        }
743        _ => {
744            // Fallback: try common default branch names
745            let mut found_default = None;
746            for default in &["main", "master"] {
747                let check_output = Command::new("git")
748                    .args(&["show-ref", "--verify", &format!("refs/heads/{}", default)])
749                    .current_dir(worktree_path)
750                    .output()
751                    .await;
752
753                if let Ok(output) = check_output {
754                    if output.status.success() {
755                        found_default = Some(default.to_string());
756                        break;
757                    }
758                }
759            }
760
761            found_default.unwrap_or_else(|| "main".to_string())
762        }
763    };
764
765    // Don't compare branch to itself
766    if branch_name == default_branch {
767        return Ok(BranchInfo {
768            name: branch_name,
769            first_commit: None,
770            commit_count: 0,
771        });
772    }
773
774    let first_commit_output = Command::new("git")
775        .args(&[
776            "log",
777            "--reverse",
778            "--oneline",
779            &format!("{}..HEAD", default_branch),
780        ])
781        .current_dir(worktree_path)
782        .output()
783        .await?;
784
785    let first_commit =
786        if first_commit_output.status.success() && !first_commit_output.stdout.is_empty() {
787            String::from_utf8_lossy(&first_commit_output.stdout)
788                .lines()
789                .next()
790                .map(|line| line.to_string())
791        } else {
792            None
793        };
794
795    // Get total commits on this branch
796    let commit_count_output = Command::new("git")
797        .args(&["rev-list", "--count", &format!("{}..HEAD", default_branch)])
798        .current_dir(worktree_path)
799        .output()
800        .await?;
801
802    let commit_count = if commit_count_output.status.success() {
803        String::from_utf8_lossy(&commit_count_output.stdout)
804            .trim()
805            .parse::<usize>()
806            .unwrap_or(0)
807    } else {
808        0
809    };
810
811    Ok(BranchInfo {
812        name: branch_name,
813        first_commit,
814        commit_count,
815    })
816}
817
818/// Update an existing WorktreeInfo with fresh status
819pub async fn update_worktree_info(mut worktree: WorktreeInfo) -> Result<WorktreeInfo> {
820    worktree.update_status().await?;
821
822    // Refresh HEAD
823    let head_output = Command::new("git")
824        .args(&["rev-parse", "HEAD"])
825        .current_dir(&worktree.path)
826        .output()
827        .await?;
828
829    if head_output.status.success() {
830        worktree.head = String::from_utf8_lossy(&head_output.stdout)
831            .trim()
832            .to_string();
833    }
834
835    Ok(worktree)
836}
837
838/// Batch update multiple worktrees for efficiency
839pub async fn batch_update_worktree_status(
840    worktrees: Vec<WorktreeInfo>,
841) -> Result<Vec<WorktreeInfo>> {
842    let mut updated = Vec::new();
843
844    // Update in parallel for better performance
845    let futures = worktrees
846        .into_iter()
847        .map(|worktree| async move { update_worktree_info(worktree).await });
848
849    let results = futures_util::future::try_join_all(futures).await?;
850    updated.extend(results);
851
852    Ok(updated)
853}
854
855// Supporting types
856#[derive(Debug)]
857struct GitStatusInfo {
858    changed_files: Vec<String>,
859    untracked_files: Vec<String>,
860}
861
862#[derive(Debug)]
863struct RemoteInfo {
864    status: RemoteStatus,
865    ahead: usize,
866    behind: usize,
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize)]
870pub struct BranchInfo {
871    pub name: String,
872    pub first_commit: Option<String>,
873    pub commit_count: usize,
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use tempfile::TempDir;
880    use tokio;
881
882    async fn setup_test_worktree() -> Result<(TempDir, PathBuf)> {
883        let temp_dir = TempDir::new()?;
884        let path = temp_dir.path().to_path_buf();
885
886        // Initialize git repo
887        let init_output = Command::new("git")
888            .args(&["init"])
889            .current_dir(&path)
890            .output()
891            .await?;
892
893        if !init_output.status.success() {
894            anyhow::bail!("Failed to initialize git repo");
895        }
896
897        // Configure git user
898        Command::new("git")
899            .args(&["config", "user.name", "Test User"])
900            .current_dir(&path)
901            .output()
902            .await?;
903
904        Command::new("git")
905            .args(&["config", "user.email", "test@example.com"])
906            .current_dir(&path)
907            .output()
908            .await?;
909
910        // Create initial commit
911        std::fs::write(path.join("README.md"), "# Test Repository")?;
912
913        Command::new("git")
914            .args(&["add", "README.md"])
915            .current_dir(&path)
916            .output()
917            .await?;
918
919        Command::new("git")
920            .args(&["commit", "-m", "Initial commit"])
921            .current_dir(&path)
922            .output()
923            .await?;
924
925        Ok((temp_dir, path))
926    }
927
928    #[tokio::test]
929    async fn test_clean_worktree_status() -> Result<()> {
930        let (_temp, path) = setup_test_worktree().await?;
931
932        let status = check_worktree_status(&path).await?;
933
934        assert!(status.is_clean);
935        assert_eq!(status.severity, StatusSeverity::Clean);
936        assert!(status.uncommitted_changes.is_empty());
937        assert!(status.untracked_files.is_empty());
938        assert_eq!(status.ahead_count, 0);
939        assert_eq!(status.behind_count, 0);
940
941        Ok(())
942    }
943
944    #[tokio::test]
945    async fn test_uncommitted_changes_detection() -> Result<()> {
946        let (_temp, path) = setup_test_worktree().await?;
947
948        // Create a modified file
949        std::fs::write(path.join("README.md"), "# Modified Test Repository")?;
950
951        let status = check_worktree_status(&path).await?;
952
953        assert!(!status.is_clean);
954        assert_eq!(status.severity, StatusSeverity::LightWarning);
955        assert!(!status.uncommitted_changes.is_empty());
956
957        // Should contain information about the modified file
958        let change_desc = &status.uncommitted_changes[0];
959        assert!(change_desc.contains("README.md"));
960        assert!(change_desc.contains("modified"));
961
962        Ok(())
963    }
964
965    #[tokio::test]
966    async fn test_untracked_files_detection() -> Result<()> {
967        let (_temp, path) = setup_test_worktree().await?;
968
969        // Create an untracked file
970        std::fs::write(path.join("untracked.txt"), "Untracked content")?;
971
972        let status = check_worktree_status(&path).await?;
973
974        assert!(!status.is_clean);
975        assert_eq!(status.severity, StatusSeverity::LightWarning);
976        assert!(!status.untracked_files.is_empty());
977        assert!(status
978            .untracked_files
979            .contains(&"untracked.txt".to_string()));
980
981        Ok(())
982    }
983
984    #[tokio::test]
985    async fn test_severity_classification() -> Result<()> {
986        // Test clean status
987        let mut status = WorktreeStatus::new();
988        status.is_clean = true;
989        status.ahead_count = 0;
990        status.behind_count = 0;
991        status.uncommitted_changes.clear();
992        status.untracked_files.clear();
993        status.remote_status = RemoteStatus::UpToDate;
994
995        assert_eq!(classify_status_severity(&status), StatusSeverity::Clean);
996
997        // Test light warning - uncommitted changes
998        status.uncommitted_changes.push("file.txt".to_string());
999        status.is_clean = false;
1000        assert_eq!(
1001            classify_status_severity(&status),
1002            StatusSeverity::LightWarning
1003        );
1004
1005        // Test warning - many commits behind
1006        status.behind_count = 15;
1007        status.remote_status = RemoteStatus::Behind(15);
1008        assert_eq!(classify_status_severity(&status), StatusSeverity::Warning);
1009
1010        // Test warning - remote deleted
1011        status.behind_count = 0;
1012        status.remote_status = RemoteStatus::RemoteDeleted;
1013        assert_eq!(classify_status_severity(&status), StatusSeverity::Warning);
1014
1015        // Test warning - diverged significantly
1016        status.remote_status = RemoteStatus::Diverged {
1017            ahead: 25,
1018            behind: 8,
1019        };
1020        assert_eq!(classify_status_severity(&status), StatusSeverity::Warning);
1021
1022        Ok(())
1023    }
1024
1025    #[test]
1026    fn test_porcelain_status_parsing() {
1027        let sample_output = b"M  modified.txt\0?? untracked.txt\0A  added.txt\0D  deleted.txt\0";
1028        let status = parse_porcelain_status(sample_output).unwrap();
1029
1030        assert_eq!(status.changed_files.len(), 3); // M, A, D
1031        assert_eq!(status.untracked_files.len(), 1); // ??
1032        assert!(status
1033            .untracked_files
1034            .contains(&"untracked.txt".to_string()));
1035
1036        // Check that status descriptions are included
1037        assert!(status
1038            .changed_files
1039            .iter()
1040            .any(|f| f.contains("modified.txt") && f.contains("modified")));
1041        assert!(status
1042            .changed_files
1043            .iter()
1044            .any(|f| f.contains("added.txt") && f.contains("added")));
1045        assert!(status
1046            .changed_files
1047            .iter()
1048            .any(|f| f.contains("deleted.txt") && f.contains("deleted")));
1049    }
1050
1051    #[test]
1052    fn test_empty_porcelain_status() {
1053        let empty_output = b"";
1054        let status = parse_porcelain_status(empty_output).unwrap();
1055
1056        assert!(status.changed_files.is_empty());
1057        assert!(status.untracked_files.is_empty());
1058    }
1059
1060    #[test]
1061    fn test_status_severity_priority() {
1062        assert!(StatusSeverity::Warning.priority() < StatusSeverity::LightWarning.priority());
1063        assert!(StatusSeverity::LightWarning.priority() < StatusSeverity::Clean.priority());
1064    }
1065
1066    #[tokio::test]
1067    async fn test_worktree_info_update_status() -> Result<()> {
1068        let (_temp, path) = setup_test_worktree().await?;
1069
1070        let mut worktree_info = WorktreeInfo {
1071            path: path.clone(),
1072            branch: "main".to_string(),
1073            head: "".to_string(),
1074            task_id: None,
1075            status: WorktreeStatus::new(),
1076            age: Duration::from_secs(0),
1077            is_detached: false,
1078        };
1079
1080        // Update status should work without errors
1081        worktree_info.update_status().await?;
1082
1083        // Should have a clean status for the fresh repo
1084        assert!(worktree_info.status.is_clean);
1085        assert_eq!(worktree_info.status.severity, StatusSeverity::Clean);
1086
1087        Ok(())
1088    }
1089
1090    #[tokio::test]
1091    async fn test_batch_update_worktree_status() -> Result<()> {
1092        let (_temp1, path1) = setup_test_worktree().await?;
1093        let (_temp2, path2) = setup_test_worktree().await?;
1094
1095        let worktrees = vec![
1096            WorktreeInfo {
1097                path: path1,
1098                branch: "main".to_string(),
1099                head: "abc123".to_string(),
1100                task_id: None,
1101                status: WorktreeStatus::new(),
1102                age: Duration::from_secs(0),
1103                is_detached: false,
1104            },
1105            WorktreeInfo {
1106                path: path2,
1107                branch: "feature".to_string(),
1108                head: "def456".to_string(),
1109                task_id: Some("feature".to_string()),
1110                status: WorktreeStatus::new(),
1111                age: Duration::from_secs(0),
1112                is_detached: false,
1113            },
1114        ];
1115
1116        let updated_worktrees = batch_update_worktree_status(worktrees).await?;
1117
1118        assert_eq!(updated_worktrees.len(), 2);
1119
1120        // Both should be clean since they're fresh repos
1121        for worktree in &updated_worktrees {
1122            assert!(worktree.status.is_clean);
1123            assert_eq!(worktree.status.severity, StatusSeverity::Clean);
1124        }
1125
1126        Ok(())
1127    }
1128
1129    #[tokio::test]
1130    async fn test_check_worktree_activity() -> Result<()> {
1131        let (_temp, path) = setup_test_worktree().await?;
1132
1133        // Should have recent activity (the initial commit)
1134        let has_recent_activity = check_worktree_activity(&path, 1).await?;
1135        assert!(has_recent_activity);
1136
1137        // Check for activity in the distant past (should be false)
1138        // Note: This might be flaky depending on system clock
1139        let _has_old_activity = check_worktree_activity(&path, 0).await?;
1140        // We can't reliably test this because it depends on timing
1141
1142        Ok(())
1143    }
1144
1145    #[tokio::test]
1146    async fn test_get_worktree_diff_compact() -> Result<()> {
1147        let (_temp, path) = setup_test_worktree().await?;
1148
1149        // Clean repo should have empty diff
1150        let diff = get_worktree_diff(&path, true).await?;
1151        assert!(diff.is_empty() || diff.trim().is_empty());
1152
1153        // Modify a file and check diff
1154        std::fs::write(path.join("README.md"), "# Modified Test Repository")?;
1155
1156        let diff = get_worktree_diff(&path, true).await?;
1157        // Should contain information about the modified file
1158        assert!(diff.contains("README.md") || diff.trim().is_empty()); // Git might not show diff until staged
1159
1160        Ok(())
1161    }
1162
1163    #[tokio::test]
1164    async fn test_get_branch_info() -> Result<()> {
1165        let (_temp, path) = setup_test_worktree().await?;
1166
1167        let branch_info = get_branch_info(&path).await?;
1168
1169        // Default branch name can be either "main" or "master" depending on git configuration
1170        assert!(branch_info.name == "main" || branch_info.name == "master");
1171        // For a new repo, there shouldn't be commits ahead of main/master
1172        assert_eq!(branch_info.commit_count, 0);
1173        assert!(branch_info.first_commit.is_none());
1174
1175        Ok(())
1176    }
1177
1178    #[test]
1179    fn test_status_description() {
1180        let mut status = WorktreeStatus::new();
1181        status.is_clean = true;
1182
1183        // Clean status
1184        assert_eq!(status.status_description(), "Clean");
1185
1186        // Status with issues
1187        status.is_clean = false;
1188        status.uncommitted_changes.push("file1.rs".to_string());
1189        status.untracked_files.push("file2.rs".to_string());
1190        status.unpushed_commits.push(CommitInfo {
1191            id: "abc123".to_string(),
1192            message: "Test commit".to_string(),
1193            author: "Test Author".to_string(),
1194            timestamp: SystemTime::now(),
1195        });
1196
1197        let description = status.status_description();
1198        assert!(description.contains("1 uncommitted"));
1199        assert!(description.contains("1 untracked"));
1200        assert!(description.contains("1 unpushed"));
1201    }
1202
1203    #[test]
1204    fn test_status_icon() {
1205        let mut status = WorktreeStatus::new();
1206
1207        status.severity = StatusSeverity::Clean;
1208        assert_eq!(status.status_icon(), "✅");
1209
1210        status.severity = StatusSeverity::LightWarning;
1211        assert_eq!(status.status_icon(), "⚠️");
1212
1213        status.severity = StatusSeverity::Warning;
1214        assert_eq!(status.status_icon(), "⚡");
1215    }
1216
1217    #[test]
1218    fn test_cleanup_safety_detection() {
1219        let mut status = WorktreeStatus::new();
1220
1221        // Not safe initially
1222        assert!(!status.is_safe_to_cleanup());
1223
1224        // Make it clean
1225        status.is_clean = true;
1226        status.uncommitted_changes.clear();
1227        status.untracked_files.clear();
1228        status.unpushed_commits.clear();
1229        assert!(status.is_safe_to_cleanup());
1230
1231        // Test with unpushed commits but merged branch
1232        status.unpushed_commits.push(CommitInfo {
1233            id: "abc123".to_string(),
1234            message: "Test commit".to_string(),
1235            author: "Test Author".to_string(),
1236            timestamp: SystemTime::now(),
1237        });
1238        assert!(!status.is_safe_to_cleanup());
1239
1240        status.merge_info = Some(MergeInfo {
1241            is_merged: true,
1242            detection_method: "standard".to_string(),
1243            details: None,
1244            confidence: 0.9,
1245        });
1246        assert!(status.is_safe_to_cleanup());
1247    }
1248
1249    #[test]
1250    fn test_remote_status_display() {
1251        // Test the different remote status variants would be covered
1252        // in integration tests with the CLI display functions
1253        let status_no_remote = RemoteStatus::NoRemote;
1254        let status_up_to_date = RemoteStatus::UpToDate;
1255        let status_ahead = RemoteStatus::Ahead(3);
1256        let status_behind = RemoteStatus::Behind(2);
1257        let status_diverged = RemoteStatus::Diverged {
1258            ahead: 3,
1259            behind: 2,
1260        };
1261        let status_deleted = RemoteStatus::RemoteDeleted;
1262
1263        // These would be tested in the display functions
1264        // Here we just verify the enum variants exist and can be constructed
1265        assert!(matches!(status_no_remote, RemoteStatus::NoRemote));
1266        assert!(matches!(status_up_to_date, RemoteStatus::UpToDate));
1267        assert!(matches!(status_ahead, RemoteStatus::Ahead(3)));
1268        assert!(matches!(status_behind, RemoteStatus::Behind(2)));
1269        assert!(matches!(
1270            status_diverged,
1271            RemoteStatus::Diverged {
1272                ahead: 3,
1273                behind: 2
1274            }
1275        ));
1276        assert!(matches!(status_deleted, RemoteStatus::RemoteDeleted));
1277    }
1278}