rustic_git/commands/
status.rs

1use crate::{Repository, Result};
2use crate::utils::git;
3
4#[derive(Debug, Clone, PartialEq)]
5pub enum FileStatus {
6    Modified,
7    Added,
8    Deleted,
9    Renamed,
10    Copied,
11    Untracked,
12    Ignored,
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct GitStatus {
17    pub files: Box<[(FileStatus, String)]>,
18}
19
20impl GitStatus {
21
22    pub fn is_clean(&self) -> bool {
23        self.files.is_empty()
24    }
25
26    pub fn has_changes(&self) -> bool {
27        !self.is_clean()
28    }
29
30    /// Get all files with a specific status
31    pub fn files_with_status(&self, status: FileStatus) -> Vec<&String> {
32        self.files
33            .iter()
34            .filter_map(|(s, f)| if *s == status { Some(f) } else { None })
35            .collect()
36    }
37
38    /// Get all modified files
39    pub fn modified_files(&self) -> Vec<&String> {
40        self.files_with_status(FileStatus::Modified)
41    }
42
43    /// Get all untracked files
44    pub fn untracked_files(&self) -> Vec<&String> {
45        self.files_with_status(FileStatus::Untracked)
46    }
47
48    fn parse_porcelain_output(output: &str) -> Self {
49        let mut files = Vec::new();
50
51        for line in output.lines() {
52            if line.len() < 3 {
53                continue;
54            }
55
56            let index_status = line.chars().nth(0).unwrap_or(' ');
57            let worktree_status = line.chars().nth(1).unwrap_or(' ');
58            let filename = line[3..].to_string();
59
60            let file_status = match (index_status, worktree_status) {
61                ('M', _) | (_, 'M') => Some(FileStatus::Modified),
62                ('A', _) => Some(FileStatus::Added),
63                ('D', _) => Some(FileStatus::Deleted),
64                ('R', _) => Some(FileStatus::Renamed),
65                ('C', _) => Some(FileStatus::Copied),
66                ('?', '?') => Some(FileStatus::Untracked),
67                ('!', '!') => Some(FileStatus::Ignored),
68                _ => None,
69            };
70
71            if let Some(fs) = file_status {
72                files.push((fs, filename));
73            }
74        }
75
76        Self {
77            files: files.into_boxed_slice(),
78        }
79    }
80}
81
82impl Repository {
83    /// Get the status of the repository.
84    ///
85    /// # Returns
86    ///
87    /// A `Result` containing the `GitStatus` or a `GitError`.
88    pub fn status(&self) -> Result<GitStatus> {
89        Self::ensure_git()?;
90
91        let stdout = git(&["status", "--porcelain"], Some(self.repo_path()))?;
92        Ok(GitStatus::parse_porcelain_output(&stdout))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use std::fs;
100    use std::path::Path;
101
102    #[test]
103    fn test_parse_porcelain_output() {
104        let output = "M  modified.txt\nA  added.txt\nD  deleted.txt\n?? untracked.txt\n";
105        let status = GitStatus::parse_porcelain_output(output);
106
107        assert_eq!(status.files.len(), 4);
108        assert!(status.files.contains(&(FileStatus::Modified, "modified.txt".to_string())));
109        assert!(status.files.contains(&(FileStatus::Added, "added.txt".to_string())));
110        assert!(status.files.contains(&(FileStatus::Deleted, "deleted.txt".to_string())));
111        assert!(status.files.contains(&(FileStatus::Untracked, "untracked.txt".to_string())));
112        
113        assert_eq!(status.modified_files(), vec![&"modified.txt".to_string()]);
114        assert_eq!(status.untracked_files(), vec![&"untracked.txt".to_string()]);
115        
116        assert!(!status.is_clean());
117        assert!(status.has_changes());
118    }
119
120    #[test]
121    fn test_clean_repository_status() {
122        let output = "";
123        let status = GitStatus::parse_porcelain_output(output);
124
125        assert!(status.is_clean());
126        assert!(!status.has_changes());
127        assert_eq!(status.files.len(), 0);
128        assert!(status.modified_files().is_empty());
129        assert!(status.untracked_files().is_empty());
130    }
131
132    #[test]
133    fn test_repository_status() {
134        let test_path = "/tmp/test_status_repo";
135
136        // Clean up if exists
137        if Path::new(test_path).exists() {
138            fs::remove_dir_all(test_path).unwrap();
139        }
140
141        // Create a repository
142        let repo = Repository::init(test_path, false).unwrap();
143
144        // Get status of empty repository
145        let status = repo.status().unwrap();
146        assert!(status.is_clean());
147
148        // Clean up
149        fs::remove_dir_all(test_path).unwrap();
150    }
151
152    #[test]
153    fn test_parse_porcelain_output_edge_cases() {
154        // Test empty lines and malformed lines
155        let output = "\n\nM  valid.txt\nXX\n  \nA  another.txt\n";
156        let status = GitStatus::parse_porcelain_output(output);
157        
158        assert_eq!(status.files.len(), 2);
159        assert!(status.files.contains(&(FileStatus::Modified, "valid.txt".to_string())));
160        assert!(status.files.contains(&(FileStatus::Added, "another.txt".to_string())));
161    }
162
163    #[test]
164    fn test_parse_porcelain_all_status_types() {
165        let output = "M  modified.txt\nA  added.txt\nD  deleted.txt\nR  renamed.txt\nC  copied.txt\n?? untracked.txt\n!! ignored.txt\n";
166        let status = GitStatus::parse_porcelain_output(output);
167
168        assert_eq!(status.files.len(), 7);
169        assert!(status.files.contains(&(FileStatus::Modified, "modified.txt".to_string())));
170        assert!(status.files.contains(&(FileStatus::Added, "added.txt".to_string())));
171        assert!(status.files.contains(&(FileStatus::Deleted, "deleted.txt".to_string())));
172        assert!(status.files.contains(&(FileStatus::Renamed, "renamed.txt".to_string())));
173        assert!(status.files.contains(&(FileStatus::Copied, "copied.txt".to_string())));
174        assert!(status.files.contains(&(FileStatus::Untracked, "untracked.txt".to_string())));
175        assert!(status.files.contains(&(FileStatus::Ignored, "ignored.txt".to_string())));
176    }
177
178    #[test]
179    fn test_parse_porcelain_worktree_modifications() {
180        let output = " M worktree_modified.txt\n";
181        let status = GitStatus::parse_porcelain_output(output);
182
183        assert_eq!(status.files.len(), 1);
184        assert!(status.files.contains(&(FileStatus::Modified, "worktree_modified.txt".to_string())));
185    }
186
187    #[test]
188    fn test_parse_porcelain_unknown_status() {
189        let output = "XY unknown.txt\nZ  another_unknown.txt\n";
190        let status = GitStatus::parse_porcelain_output(output);
191
192        // Unknown statuses should be ignored
193        assert_eq!(status.files.len(), 0);
194    }
195
196    #[test]
197    fn test_file_status_equality() {
198        assert_eq!(FileStatus::Modified, FileStatus::Modified);
199        assert_ne!(FileStatus::Modified, FileStatus::Added);
200        assert_eq!(FileStatus::Untracked, FileStatus::Untracked);
201    }
202
203    #[test]
204    fn test_file_status_clone() {
205        let status = FileStatus::Modified;
206        let cloned = status.clone();
207        assert_eq!(status, cloned);
208    }
209
210    #[test]
211    fn test_file_status_debug() {
212        let status = FileStatus::Modified;
213        let debug_str = format!("{:?}", status);
214        assert_eq!(debug_str, "Modified");
215    }
216
217    #[test]
218    fn test_git_status_equality() {
219        let files1 = vec![
220            (FileStatus::Modified, "file1.txt".to_string()),
221            (FileStatus::Added, "file2.txt".to_string()),
222        ];
223        let files2 = vec![
224            (FileStatus::Modified, "file1.txt".to_string()),
225            (FileStatus::Added, "file2.txt".to_string()),
226        ];
227        let files3 = vec![
228            (FileStatus::Modified, "different.txt".to_string()),
229        ];
230
231        let status1 = GitStatus { files: files1.into_boxed_slice() };
232        let status2 = GitStatus { files: files2.into_boxed_slice() };
233        let status3 = GitStatus { files: files3.into_boxed_slice() };
234
235        assert_eq!(status1, status2);
236        assert_ne!(status1, status3);
237    }
238
239    #[test]
240    fn test_git_status_clone() {
241        let files = vec![
242            (FileStatus::Modified, "file1.txt".to_string()),
243        ];
244        let status1 = GitStatus { files: files.into_boxed_slice() };
245        let status2 = status1.clone();
246        
247        assert_eq!(status1, status2);
248    }
249
250    #[test]
251    fn test_git_status_debug() {
252        let files = vec![
253            (FileStatus::Modified, "file1.txt".to_string()),
254        ];
255        let status = GitStatus { files: files.into_boxed_slice() };
256        let debug_str = format!("{:?}", status);
257        
258        assert!(debug_str.contains("GitStatus"));
259        assert!(debug_str.contains("Modified"));
260        assert!(debug_str.contains("file1.txt"));
261    }
262
263    #[test]
264    fn test_files_with_status_multiple_same_status() {
265        let output = "M  file1.txt\nM  file2.txt\nA  file3.txt\n";
266        let status = GitStatus::parse_porcelain_output(output);
267        
268        let modified = status.files_with_status(FileStatus::Modified);
269        assert_eq!(modified.len(), 2);
270        assert!(modified.contains(&&"file1.txt".to_string()));
271        assert!(modified.contains(&&"file2.txt".to_string()));
272        
273        let added = status.files_with_status(FileStatus::Added);
274        assert_eq!(added.len(), 1);
275        assert!(added.contains(&&"file3.txt".to_string()));
276    }
277
278    #[test]
279    fn test_files_with_status_no_matches() {
280        let output = "M  file1.txt\nA  file2.txt\n";
281        let status = GitStatus::parse_porcelain_output(output);
282        
283        let deleted = status.files_with_status(FileStatus::Deleted);
284        assert!(deleted.is_empty());
285    }
286
287    #[test]
288    fn test_parse_porcelain_filenames_with_spaces() {
289        let output = "M  file with spaces.txt\nA  another file.txt\n";
290        let status = GitStatus::parse_porcelain_output(output);
291        
292        assert_eq!(status.files.len(), 2);
293        assert!(status.files.contains(&(FileStatus::Modified, "file with spaces.txt".to_string())));
294        assert!(status.files.contains(&(FileStatus::Added, "another file.txt".to_string())));
295    }
296
297    #[test]
298    fn test_parse_porcelain_unicode_filenames() {
299        let output = "M  测试文件.txt\nA  🚀rocket.txt\n";
300        let status = GitStatus::parse_porcelain_output(output);
301        
302        assert_eq!(status.files.len(), 2);
303        assert!(status.files.contains(&(FileStatus::Modified, "测试文件.txt".to_string())));
304        assert!(status.files.contains(&(FileStatus::Added, "🚀rocket.txt".to_string())));
305    }
306}