rustic_git/commands/
status.rs

1use crate::utils::git;
2use crate::{Repository, Result};
3use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub enum IndexStatus {
8    Clean,
9    Modified,
10    Added,
11    Deleted,
12    Renamed,
13    Copied,
14}
15
16impl IndexStatus {
17    /// Convert a git porcelain index character to IndexStatus
18    pub const fn from_char(c: char) -> Self {
19        match c {
20            'M' => Self::Modified,
21            'A' => Self::Added,
22            'D' => Self::Deleted,
23            'R' => Self::Renamed,
24            'C' => Self::Copied,
25            _ => Self::Clean,
26        }
27    }
28
29    /// Convert IndexStatus to its git porcelain character representation
30    pub const fn to_char(&self) -> char {
31        match self {
32            Self::Clean => ' ',
33            Self::Modified => 'M',
34            Self::Added => 'A',
35            Self::Deleted => 'D',
36            Self::Renamed => 'R',
37            Self::Copied => 'C',
38        }
39    }
40}
41
42impl fmt::Display for IndexStatus {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(f, "{}", self.to_char())
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub enum WorktreeStatus {
50    Clean,
51    Modified,
52    Deleted,
53    Untracked,
54    Ignored,
55}
56
57impl WorktreeStatus {
58    /// Convert a git porcelain worktree character to WorktreeStatus
59    pub const fn from_char(c: char) -> Self {
60        match c {
61            'M' => Self::Modified,
62            'D' => Self::Deleted,
63            '?' => Self::Untracked,
64            '!' => Self::Ignored,
65            _ => Self::Clean,
66        }
67    }
68
69    /// Convert WorktreeStatus to its git porcelain character representation
70    pub const fn to_char(&self) -> char {
71        match self {
72            Self::Clean => ' ',
73            Self::Modified => 'M',
74            Self::Deleted => 'D',
75            Self::Untracked => '?',
76            Self::Ignored => '!',
77        }
78    }
79}
80
81impl fmt::Display for WorktreeStatus {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(f, "{}", self.to_char())
84    }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88pub struct FileEntry {
89    pub path: PathBuf,
90    pub index_status: IndexStatus,
91    pub worktree_status: WorktreeStatus,
92}
93
94#[derive(Debug, Clone, PartialEq)]
95pub struct GitStatus {
96    pub entries: Box<[FileEntry]>,
97}
98
99impl GitStatus {
100    pub fn is_clean(&self) -> bool {
101        self.entries.is_empty()
102    }
103
104    pub fn has_changes(&self) -> bool {
105        !self.is_clean()
106    }
107
108    // New API methods for staged/unstaged files
109    /// Get all files that have changes in the index (staged)
110    pub fn staged_files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
111        self.entries
112            .iter()
113            .filter(|entry| !matches!(entry.index_status, IndexStatus::Clean))
114    }
115
116    /// Get all files that have changes in the working tree (unstaged)
117    pub fn unstaged_files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
118        self.entries
119            .iter()
120            .filter(|entry| !matches!(entry.worktree_status, WorktreeStatus::Clean))
121    }
122
123    /// Get all untracked files (new API)
124    pub fn untracked_entries(&self) -> impl Iterator<Item = &FileEntry> + '_ {
125        self.entries
126            .iter()
127            .filter(|entry| matches!(entry.worktree_status, WorktreeStatus::Untracked))
128    }
129
130    /// Get all ignored files
131    pub fn ignored_files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
132        self.entries
133            .iter()
134            .filter(|entry| matches!(entry.worktree_status, WorktreeStatus::Ignored))
135    }
136
137    /// Get files with specific index status
138    pub fn files_with_index_status(
139        &self,
140        status: IndexStatus,
141    ) -> impl Iterator<Item = &FileEntry> + '_ {
142        self.entries
143            .iter()
144            .filter(move |entry| entry.index_status == status)
145    }
146
147    /// Get files with specific worktree status  
148    pub fn files_with_worktree_status(
149        &self,
150        status: WorktreeStatus,
151    ) -> impl Iterator<Item = &FileEntry> + '_ {
152        self.entries
153            .iter()
154            .filter(move |entry| entry.worktree_status == status)
155    }
156
157    /// Get all file entries
158    pub fn entries(&self) -> &[FileEntry] {
159        &self.entries
160    }
161
162    fn parse_porcelain_output(output: &str) -> Self {
163        let mut entries = Vec::new();
164
165        for line in output.lines() {
166            if line.len() < 3 {
167                continue;
168            }
169
170            let index_char = line.chars().nth(0).unwrap_or(' ');
171            let worktree_char = line.chars().nth(1).unwrap_or(' ');
172            let filename = line[3..].to_string();
173            let path = PathBuf::from(&filename);
174
175            let index_status = IndexStatus::from_char(index_char);
176            let worktree_status = WorktreeStatus::from_char(worktree_char);
177
178            // Skip entries that are completely clean
179            if matches!(index_status, IndexStatus::Clean)
180                && matches!(worktree_status, WorktreeStatus::Clean)
181            {
182                continue;
183            }
184
185            let entry = FileEntry {
186                path,
187                index_status,
188                worktree_status,
189            };
190
191            entries.push(entry);
192        }
193
194        Self {
195            entries: entries.into_boxed_slice(),
196        }
197    }
198}
199
200impl Repository {
201    /// Get the status of the repository.
202    ///
203    /// # Returns
204    ///
205    /// A `Result` containing the `GitStatus` or a `GitError`.
206    pub fn status(&self) -> Result<GitStatus> {
207        Self::ensure_git()?;
208
209        let stdout = git(&["status", "--porcelain"], Some(self.repo_path()))?;
210        Ok(GitStatus::parse_porcelain_output(&stdout))
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::env;
218    use std::fs;
219
220    #[test]
221    fn test_parse_porcelain_output() {
222        let output = "M  modified.txt\nA  added.txt\nD  deleted.txt\n?? untracked.txt\n";
223        let status = GitStatus::parse_porcelain_output(output);
224
225        assert_eq!(status.entries.len(), 4);
226
227        // Find entries by path for testing
228        let modified_entry = status
229            .entries
230            .iter()
231            .find(|e| e.path.to_str() == Some("modified.txt"))
232            .unwrap();
233        assert_eq!(modified_entry.index_status, IndexStatus::Modified);
234        assert_eq!(modified_entry.worktree_status, WorktreeStatus::Clean);
235
236        let added_entry = status
237            .entries
238            .iter()
239            .find(|e| e.path.to_str() == Some("added.txt"))
240            .unwrap();
241        assert_eq!(added_entry.index_status, IndexStatus::Added);
242        assert_eq!(added_entry.worktree_status, WorktreeStatus::Clean);
243
244        let deleted_entry = status
245            .entries
246            .iter()
247            .find(|e| e.path.to_str() == Some("deleted.txt"))
248            .unwrap();
249        assert_eq!(deleted_entry.index_status, IndexStatus::Deleted);
250        assert_eq!(deleted_entry.worktree_status, WorktreeStatus::Clean);
251
252        let untracked_entry = status
253            .entries
254            .iter()
255            .find(|e| e.path.to_str() == Some("untracked.txt"))
256            .unwrap();
257        assert_eq!(untracked_entry.index_status, IndexStatus::Clean);
258        assert_eq!(untracked_entry.worktree_status, WorktreeStatus::Untracked);
259
260        // Test new API methods
261        let staged_files: Vec<_> = status.staged_files().collect();
262        assert_eq!(staged_files.len(), 3); // modified, added, deleted are staged
263
264        let untracked_files: Vec<_> = status.untracked_entries().collect();
265        assert_eq!(untracked_files.len(), 1);
266        assert_eq!(untracked_files[0].path.to_str(), Some("untracked.txt"));
267
268        assert!(!status.is_clean());
269        assert!(status.has_changes());
270    }
271
272    #[test]
273    fn test_clean_repository_status() {
274        let output = "";
275        let status = GitStatus::parse_porcelain_output(output);
276
277        assert!(status.is_clean());
278        assert!(!status.has_changes());
279        assert_eq!(status.entries.len(), 0);
280        assert_eq!(status.staged_files().count(), 0);
281        assert_eq!(status.untracked_entries().count(), 0);
282    }
283
284    #[test]
285    fn test_repository_status() {
286        let test_path = env::temp_dir().join("test_status_repo");
287
288        // Clean up if exists
289        if test_path.exists() {
290            fs::remove_dir_all(&test_path).unwrap();
291        }
292
293        // Create a repository
294        let repo = Repository::init(&test_path, false).unwrap();
295
296        // Get status of empty repository
297        let status = repo.status().unwrap();
298        assert!(status.is_clean());
299
300        // Clean up
301        fs::remove_dir_all(&test_path).unwrap();
302    }
303
304    #[test]
305    fn test_parse_porcelain_output_edge_cases() {
306        // Test empty lines and malformed lines
307        let output = "\n\nM  valid.txt\nXX\n  \nA  another.txt\n";
308        let status = GitStatus::parse_porcelain_output(output);
309
310        assert_eq!(status.entries.len(), 2);
311
312        let valid_entry = status
313            .entries
314            .iter()
315            .find(|e| e.path.to_str() == Some("valid.txt"))
316            .unwrap();
317        assert_eq!(valid_entry.index_status, IndexStatus::Modified);
318
319        let another_entry = status
320            .entries
321            .iter()
322            .find(|e| e.path.to_str() == Some("another.txt"))
323            .unwrap();
324        assert_eq!(another_entry.index_status, IndexStatus::Added);
325    }
326
327    #[test]
328    fn test_parse_porcelain_all_status_types() {
329        let output = "M  modified.txt\nA  added.txt\nD  deleted.txt\nR  renamed.txt\nC  copied.txt\n?? untracked.txt\n!! ignored.txt\n";
330        let status = GitStatus::parse_porcelain_output(output);
331
332        assert_eq!(status.entries.len(), 7);
333
334        let modified = status
335            .entries
336            .iter()
337            .find(|e| e.path.to_str() == Some("modified.txt"))
338            .unwrap();
339        assert_eq!(modified.index_status, IndexStatus::Modified);
340
341        let added = status
342            .entries
343            .iter()
344            .find(|e| e.path.to_str() == Some("added.txt"))
345            .unwrap();
346        assert_eq!(added.index_status, IndexStatus::Added);
347
348        let deleted = status
349            .entries
350            .iter()
351            .find(|e| e.path.to_str() == Some("deleted.txt"))
352            .unwrap();
353        assert_eq!(deleted.index_status, IndexStatus::Deleted);
354
355        let renamed = status
356            .entries
357            .iter()
358            .find(|e| e.path.to_str() == Some("renamed.txt"))
359            .unwrap();
360        assert_eq!(renamed.index_status, IndexStatus::Renamed);
361
362        let copied = status
363            .entries
364            .iter()
365            .find(|e| e.path.to_str() == Some("copied.txt"))
366            .unwrap();
367        assert_eq!(copied.index_status, IndexStatus::Copied);
368
369        let untracked = status
370            .entries
371            .iter()
372            .find(|e| e.path.to_str() == Some("untracked.txt"))
373            .unwrap();
374        assert_eq!(untracked.worktree_status, WorktreeStatus::Untracked);
375
376        let ignored = status
377            .entries
378            .iter()
379            .find(|e| e.path.to_str() == Some("ignored.txt"))
380            .unwrap();
381        assert_eq!(ignored.worktree_status, WorktreeStatus::Ignored);
382    }
383
384    #[test]
385    fn test_parse_porcelain_worktree_modifications() {
386        let output = " M worktree_modified.txt\n";
387        let status = GitStatus::parse_porcelain_output(output);
388
389        assert_eq!(status.entries.len(), 1);
390        let entry = &status.entries[0];
391        assert_eq!(entry.path.to_str(), Some("worktree_modified.txt"));
392        assert_eq!(entry.index_status, IndexStatus::Clean);
393        assert_eq!(entry.worktree_status, WorktreeStatus::Modified);
394    }
395
396    #[test]
397    fn test_parse_porcelain_unknown_status() {
398        let output = "XY unknown.txt\nZ  another_unknown.txt\n";
399        let status = GitStatus::parse_porcelain_output(output);
400
401        // Unknown statuses should be treated as clean/clean and ignored
402        assert_eq!(status.entries.len(), 0);
403    }
404
405    #[test]
406    fn test_index_status_equality() {
407        assert_eq!(IndexStatus::Modified, IndexStatus::Modified);
408        assert_ne!(IndexStatus::Modified, IndexStatus::Added);
409        assert_eq!(IndexStatus::Clean, IndexStatus::Clean);
410    }
411
412    #[test]
413    fn test_worktree_status_equality() {
414        assert_eq!(WorktreeStatus::Modified, WorktreeStatus::Modified);
415        assert_ne!(WorktreeStatus::Modified, WorktreeStatus::Untracked);
416        assert_eq!(WorktreeStatus::Clean, WorktreeStatus::Clean);
417    }
418
419    #[test]
420    fn test_index_status_char_conversion() {
421        // Test from_char
422        assert_eq!(IndexStatus::from_char('M'), IndexStatus::Modified);
423        assert_eq!(IndexStatus::from_char('A'), IndexStatus::Added);
424        assert_eq!(IndexStatus::from_char('D'), IndexStatus::Deleted);
425        assert_eq!(IndexStatus::from_char('R'), IndexStatus::Renamed);
426        assert_eq!(IndexStatus::from_char('C'), IndexStatus::Copied);
427        assert_eq!(IndexStatus::from_char(' '), IndexStatus::Clean);
428        assert_eq!(IndexStatus::from_char('X'), IndexStatus::Clean); // unknown char
429
430        // Test to_char
431        assert_eq!(IndexStatus::Modified.to_char(), 'M');
432        assert_eq!(IndexStatus::Added.to_char(), 'A');
433        assert_eq!(IndexStatus::Deleted.to_char(), 'D');
434        assert_eq!(IndexStatus::Renamed.to_char(), 'R');
435        assert_eq!(IndexStatus::Copied.to_char(), 'C');
436        assert_eq!(IndexStatus::Clean.to_char(), ' ');
437    }
438
439    #[test]
440    fn test_worktree_status_char_conversion() {
441        // Test from_char
442        assert_eq!(WorktreeStatus::from_char('M'), WorktreeStatus::Modified);
443        assert_eq!(WorktreeStatus::from_char('D'), WorktreeStatus::Deleted);
444        assert_eq!(WorktreeStatus::from_char('?'), WorktreeStatus::Untracked);
445        assert_eq!(WorktreeStatus::from_char('!'), WorktreeStatus::Ignored);
446        assert_eq!(WorktreeStatus::from_char(' '), WorktreeStatus::Clean);
447        assert_eq!(WorktreeStatus::from_char('X'), WorktreeStatus::Clean); // unknown char
448
449        // Test to_char
450        assert_eq!(WorktreeStatus::Modified.to_char(), 'M');
451        assert_eq!(WorktreeStatus::Deleted.to_char(), 'D');
452        assert_eq!(WorktreeStatus::Untracked.to_char(), '?');
453        assert_eq!(WorktreeStatus::Ignored.to_char(), '!');
454        assert_eq!(WorktreeStatus::Clean.to_char(), ' ');
455    }
456
457    #[test]
458    fn test_bidirectional_char_conversion() {
459        // Test that from_char(to_char(x)) == x for IndexStatus
460        for status in [
461            IndexStatus::Clean,
462            IndexStatus::Modified,
463            IndexStatus::Added,
464            IndexStatus::Deleted,
465            IndexStatus::Renamed,
466            IndexStatus::Copied,
467        ] {
468            assert_eq!(IndexStatus::from_char(status.to_char()), status);
469        }
470
471        // Test that from_char(to_char(x)) == x for WorktreeStatus
472        for status in [
473            WorktreeStatus::Clean,
474            WorktreeStatus::Modified,
475            WorktreeStatus::Deleted,
476            WorktreeStatus::Untracked,
477            WorktreeStatus::Ignored,
478        ] {
479            assert_eq!(WorktreeStatus::from_char(status.to_char()), status);
480        }
481    }
482
483    #[test]
484    fn test_status_display() {
485        // Test IndexStatus Display
486        assert_eq!(format!("{}", IndexStatus::Modified), "M");
487        assert_eq!(format!("{}", IndexStatus::Added), "A");
488        assert_eq!(format!("{}", IndexStatus::Clean), " ");
489
490        // Test WorktreeStatus Display
491        assert_eq!(format!("{}", WorktreeStatus::Modified), "M");
492        assert_eq!(format!("{}", WorktreeStatus::Untracked), "?");
493        assert_eq!(format!("{}", WorktreeStatus::Clean), " ");
494    }
495
496    #[test]
497    fn test_file_entry_equality() {
498        let entry1 = FileEntry {
499            path: PathBuf::from("test.txt"),
500            index_status: IndexStatus::Modified,
501            worktree_status: WorktreeStatus::Clean,
502        };
503        let entry2 = FileEntry {
504            path: PathBuf::from("test.txt"),
505            index_status: IndexStatus::Modified,
506            worktree_status: WorktreeStatus::Clean,
507        };
508        let entry3 = FileEntry {
509            path: PathBuf::from("other.txt"),
510            index_status: IndexStatus::Modified,
511            worktree_status: WorktreeStatus::Clean,
512        };
513
514        assert_eq!(entry1, entry2);
515        assert_ne!(entry1, entry3);
516    }
517
518    #[test]
519    fn test_git_status_equality() {
520        let entries1 = vec![
521            FileEntry {
522                path: PathBuf::from("file1.txt"),
523                index_status: IndexStatus::Modified,
524                worktree_status: WorktreeStatus::Clean,
525            },
526            FileEntry {
527                path: PathBuf::from("file2.txt"),
528                index_status: IndexStatus::Added,
529                worktree_status: WorktreeStatus::Clean,
530            },
531        ];
532        let entries2 = entries1.clone();
533        let entries3 = vec![FileEntry {
534            path: PathBuf::from("different.txt"),
535            index_status: IndexStatus::Modified,
536            worktree_status: WorktreeStatus::Clean,
537        }];
538
539        let status1 = GitStatus {
540            entries: entries1.into_boxed_slice(),
541        };
542        let status2 = GitStatus {
543            entries: entries2.into_boxed_slice(),
544        };
545        let status3 = GitStatus {
546            entries: entries3.into_boxed_slice(),
547        };
548
549        assert_eq!(status1, status2);
550        assert_ne!(status1, status3);
551    }
552
553    #[test]
554    fn test_git_status_clone() {
555        let entries = vec![FileEntry {
556            path: PathBuf::from("file1.txt"),
557            index_status: IndexStatus::Modified,
558            worktree_status: WorktreeStatus::Clean,
559        }];
560        let status1 = GitStatus {
561            entries: entries.into_boxed_slice(),
562        };
563        let status2 = status1.clone();
564
565        assert_eq!(status1, status2);
566    }
567
568    #[test]
569    fn test_git_status_debug() {
570        let entries = vec![FileEntry {
571            path: PathBuf::from("file1.txt"),
572            index_status: IndexStatus::Modified,
573            worktree_status: WorktreeStatus::Clean,
574        }];
575        let status = GitStatus {
576            entries: entries.into_boxed_slice(),
577        };
578        let debug_str = format!("{:?}", status);
579
580        assert!(debug_str.contains("GitStatus"));
581        assert!(debug_str.contains("Modified"));
582        assert!(debug_str.contains("file1.txt"));
583    }
584
585    #[test]
586    fn test_new_api_methods() {
587        let output = "M  file1.txt\nMM file2.txt\nA  file3.txt\n D file4.txt\n?? file5.txt\n";
588        let status = GitStatus::parse_porcelain_output(output);
589
590        // Test staged files (index changes)
591        let staged: Vec<_> = status.staged_files().collect();
592        assert_eq!(staged.len(), 3); // M, MM, A (not D since it has clean index status)
593
594        // Test unstaged files (worktree changes)
595        let unstaged: Vec<_> = status.unstaged_files().collect();
596        assert_eq!(unstaged.len(), 3); // MM, D, ??
597
598        // Test untracked files
599        let untracked: Vec<_> = status.untracked_entries().collect();
600        assert_eq!(untracked.len(), 1);
601        assert_eq!(untracked[0].path.to_str(), Some("file5.txt"));
602
603        // Test index status filtering
604        let modified_in_index: Vec<_> = status
605            .files_with_index_status(IndexStatus::Modified)
606            .collect();
607        assert_eq!(modified_in_index.len(), 2); // file1.txt, file2.txt
608
609        // Test worktree status filtering
610        let modified_in_worktree: Vec<_> = status
611            .files_with_worktree_status(WorktreeStatus::Modified)
612            .collect();
613        assert_eq!(modified_in_worktree.len(), 1); // file2.txt
614    }
615
616    #[test]
617    fn test_parse_porcelain_filenames_with_spaces() {
618        let output = "M  file with spaces.txt\nA  another file.txt\n";
619        let status = GitStatus::parse_porcelain_output(output);
620
621        assert_eq!(status.entries.len(), 2);
622
623        let spaced_entry = status
624            .entries
625            .iter()
626            .find(|e| e.path.to_str() == Some("file with spaces.txt"))
627            .unwrap();
628        assert_eq!(spaced_entry.index_status, IndexStatus::Modified);
629
630        let another_entry = status
631            .entries
632            .iter()
633            .find(|e| e.path.to_str() == Some("another file.txt"))
634            .unwrap();
635        assert_eq!(another_entry.index_status, IndexStatus::Added);
636    }
637
638    #[test]
639    fn test_parse_porcelain_unicode_filenames() {
640        let output = "M  测试文件.txt\nA  🚀rocket.txt\n";
641        let status = GitStatus::parse_porcelain_output(output);
642
643        assert_eq!(status.entries.len(), 2);
644
645        let chinese_entry = status
646            .entries
647            .iter()
648            .find(|e| e.path.to_str() == Some("测试文件.txt"))
649            .unwrap();
650        assert_eq!(chinese_entry.index_status, IndexStatus::Modified);
651
652        let rocket_entry = status
653            .entries
654            .iter()
655            .find(|e| e.path.to_str() == Some("🚀rocket.txt"))
656            .unwrap();
657        assert_eq!(rocket_entry.index_status, IndexStatus::Added);
658    }
659}