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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let staged_files: Vec<_> = status.staged_files().collect();
262 assert_eq!(staged_files.len(), 3); 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 if test_path.exists() {
290 fs::remove_dir_all(&test_path).unwrap();
291 }
292
293 let repo = Repository::init(&test_path, false).unwrap();
295
296 let status = repo.status().unwrap();
298 assert!(status.is_clean());
299
300 fs::remove_dir_all(&test_path).unwrap();
302 }
303
304 #[test]
305 fn test_parse_porcelain_output_edge_cases() {
306 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 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 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); 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 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); 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 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 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 assert_eq!(format!("{}", IndexStatus::Modified), "M");
487 assert_eq!(format!("{}", IndexStatus::Added), "A");
488 assert_eq!(format!("{}", IndexStatus::Clean), " ");
489
490 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 let staged: Vec<_> = status.staged_files().collect();
592 assert_eq!(staged.len(), 3); let unstaged: Vec<_> = status.unstaged_files().collect();
596 assert_eq!(unstaged.len(), 3); 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 let modified_in_index: Vec<_> = status
605 .files_with_index_status(IndexStatus::Modified)
606 .collect();
607 assert_eq!(modified_in_index.len(), 2); let modified_in_worktree: Vec<_> = status
611 .files_with_worktree_status(WorktreeStatus::Modified)
612 .collect();
613 assert_eq!(modified_in_worktree.len(), 1); }
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}