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 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 pub fn modified_files(&self) -> Vec<&String> {
40 self.files_with_status(FileStatus::Modified)
41 }
42
43 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 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 if Path::new(test_path).exists() {
138 fs::remove_dir_all(test_path).unwrap();
139 }
140
141 let repo = Repository::init(test_path, false).unwrap();
143
144 let status = repo.status().unwrap();
146 assert!(status.is_clean());
147
148 fs::remove_dir_all(test_path).unwrap();
150 }
151
152 #[test]
153 fn test_parse_porcelain_output_edge_cases() {
154 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 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}