1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorktreeInfo {
16 pub path: PathBuf,
18
19 pub branch: String,
21
22 pub head: String,
24
25 pub task_id: Option<String>,
27
28 pub status: WorktreeStatus,
30
31 pub age: Duration,
33
34 pub is_detached: bool,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WorktreeStatus {
41 pub is_clean: bool,
43
44 pub severity: StatusSeverity,
46
47 pub uncommitted_changes: Vec<String>,
49
50 pub untracked_files: Vec<String>,
52
53 pub unpushed_commits: Vec<CommitInfo>,
55
56 pub remote_status: RemoteStatus,
58
59 pub merge_info: Option<MergeInfo>,
61
62 pub ahead_count: usize,
64
65 pub behind_count: usize,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub enum StatusSeverity {
72 Clean,
74
75 LightWarning,
77
78 Warning,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CommitInfo {
85 pub id: String,
87
88 pub message: String,
90
91 pub author: String,
93
94 pub timestamp: SystemTime,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub enum RemoteStatus {
101 NoRemote,
103
104 UpToDate,
106
107 Ahead(usize),
109
110 Behind(usize),
112
113 Diverged { ahead: usize, behind: usize },
115
116 RemoteDeleted,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct MergeInfo {
123 pub is_merged: bool,
125
126 pub detection_method: String,
128
129 pub details: Option<String>,
131
132 pub confidence: f32,
134}
135
136impl WorktreeStatus {
137 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct RepositoryWorktreeSummary {
253 pub total_worktrees: usize,
255
256 pub clean_worktrees: usize,
258
259 pub dirty_worktrees: usize,
261
262 pub worktrees_with_remote: usize,
264
265 pub merged_worktrees: usize,
267
268 pub worktrees_with_unpushed: usize,
270
271 pub health_score: f32,
273}
274
275impl RepositoryWorktreeSummary {
276 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 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 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 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 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 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
369pub async fn check_worktree_status(worktree_path: &Path) -> Result<WorktreeStatus> {
371 check_worktree_status_with_config(worktree_path, None).await
372}
373
374pub 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 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 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 if status.ahead_count > 0 {
394 status.unpushed_commits = get_unpushed_commits(worktree_path).await?;
395 }
396
397 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, ¤t_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 }
411 }
412 }
413 }
414
415 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 status.severity = classify_status_severity(&status);
422
423 Ok(status)
424}
425
426async 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
443fn 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
493async fn get_remote_status(worktree_path: &Path) -> Result<RemoteInfo> {
495 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 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 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
562async 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
606async 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
621fn classify_status_severity(status: &WorktreeStatus) -> StatusSeverity {
623 if let Some(merge_info) = &status.merge_info {
625 if merge_info.is_merged && merge_info.confidence > 0.8 {
626 if status.is_clean {
628 return StatusSeverity::Clean;
629 } else {
630 return StatusSeverity::LightWarning;
631 }
632 }
633 }
634
635 if status.is_clean && status.ahead_count == 0 && status.behind_count == 0 {
637 return StatusSeverity::Clean;
638 }
639
640 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 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
668pub 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
681pub 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
704pub async fn get_branch_info(worktree_path: &Path) -> Result<BranchInfo> {
706 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 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 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 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 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 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
818pub async fn update_worktree_info(mut worktree: WorktreeInfo) -> Result<WorktreeInfo> {
820 worktree.update_status().await?;
821
822 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
838pub async fn batch_update_worktree_status(
840 worktrees: Vec<WorktreeInfo>,
841) -> Result<Vec<WorktreeInfo>> {
842 let mut updated = Vec::new();
843
844 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#[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 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 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 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 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 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 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 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 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 status.behind_count = 15;
1007 status.remote_status = RemoteStatus::Behind(15);
1008 assert_eq!(classify_status_severity(&status), StatusSeverity::Warning);
1009
1010 status.behind_count = 0;
1012 status.remote_status = RemoteStatus::RemoteDeleted;
1013 assert_eq!(classify_status_severity(&status), StatusSeverity::Warning);
1014
1015 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); assert_eq!(status.untracked_files.len(), 1); assert!(status
1033 .untracked_files
1034 .contains(&"untracked.txt".to_string()));
1035
1036 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 worktree_info.update_status().await?;
1082
1083 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 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 let has_recent_activity = check_worktree_activity(&path, 1).await?;
1135 assert!(has_recent_activity);
1136
1137 let _has_old_activity = check_worktree_activity(&path, 0).await?;
1140 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 let diff = get_worktree_diff(&path, true).await?;
1151 assert!(diff.is_empty() || diff.trim().is_empty());
1152
1153 std::fs::write(path.join("README.md"), "# Modified Test Repository")?;
1155
1156 let diff = get_worktree_diff(&path, true).await?;
1157 assert!(diff.contains("README.md") || diff.trim().is_empty()); 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 assert!(branch_info.name == "main" || branch_info.name == "master");
1171 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 assert_eq!(status.status_description(), "Clean");
1185
1186 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 assert!(!status.is_safe_to_cleanup());
1223
1224 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 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 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 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}