1use std::path::Path;
8
9use crate::error::Result;
10use crate::git::cli::GitCli;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub(crate) struct StatusEntry {
15 pub(crate) marker: char,
17 pub(crate) path: String,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub(crate) struct StatusSummary {
24 pub(crate) dirty: bool,
26 pub(crate) has_untracked: bool,
28 pub(crate) entries: Vec<StatusEntry>,
30}
31
32pub(crate) fn parse_status_porcelain(z: &str) -> StatusSummary {
36 let mut summary = StatusSummary::default();
37 let mut fields = z.split('\0');
38 while let Some(field) = fields.next() {
39 if field.len() < 3 {
40 continue;
41 }
42 let xy = &field[..2];
43 let path = &field[3..];
44 if xy == "??" {
45 summary.has_untracked = true;
46 summary.entries.push(StatusEntry {
47 marker: '?',
48 path: path.to_string(),
49 });
50 } else if xy == "!!" {
51 } else {
53 summary.dirty = true;
54 summary.entries.push(StatusEntry {
55 marker: 'M',
56 path: path.to_string(),
57 });
58 if xy.contains('R') || xy.contains('C') {
60 fields.next();
61 }
62 }
63 }
64 summary
65}
66
67pub(crate) fn status_of(git: &dyn GitCli, worktree_dir: &Path) -> Result<StatusSummary> {
69 let output = git.run(worktree_dir, &["status", "--porcelain=v1", "-z"])?;
70 Ok(parse_status_porcelain(&output))
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use crate::git::cli::RealGit;
77 use crate::testutil::TestRepo;
78
79 #[test]
80 fn clean_worktree_is_not_dirty() {
81 let s = parse_status_porcelain("");
82 assert!(!s.dirty);
83 assert!(!s.has_untracked);
84 assert!(s.entries.is_empty());
85 }
86
87 #[test]
88 fn parses_modified_staged_and_untracked() {
89 let z = " M src/a\0M src/b\0?? scratch\0";
91 let s = parse_status_porcelain(z);
92 assert!(s.dirty);
93 assert!(s.has_untracked);
94 assert_eq!(s.entries.len(), 3);
95 assert_eq!(
96 s.entries[0],
97 StatusEntry {
98 marker: 'M',
99 path: "src/a".into()
100 }
101 );
102 assert_eq!(
103 s.entries[2],
104 StatusEntry {
105 marker: '?',
106 path: "scratch".into()
107 }
108 );
109 }
110
111 #[test]
112 fn untracked_only_is_not_dirty() {
113 let s = parse_status_porcelain("?? new.txt\0");
114 assert!(!s.dirty);
115 assert!(s.has_untracked);
116 }
117
118 #[test]
119 fn ignored_entries_are_skipped() {
120 let s = parse_status_porcelain("!! target/\0");
121 assert!(!s.dirty);
122 assert!(!s.has_untracked);
123 assert!(s.entries.is_empty());
124 }
125
126 #[test]
127 fn rename_consumes_source_field() {
128 let z = "R new\0old\0?? u\0";
130 let s = parse_status_porcelain(z);
131 assert!(s.dirty);
132 assert!(s.has_untracked);
133 assert_eq!(s.entries.len(), 2);
135 assert_eq!(s.entries[0].path, "new");
136 assert_eq!(s.entries[1].path, "u");
137 }
138
139 #[test]
140 fn status_of_real_repo() {
141 let repo = TestRepo::init();
142 let s = status_of(&RealGit, repo.root()).unwrap();
144 assert!(!s.dirty && !s.has_untracked);
145 repo.write("README.md", "changed\n");
147 let s = status_of(&RealGit, repo.root()).unwrap();
148 assert!(s.dirty);
149 assert!(!s.has_untracked);
150 repo.write("scratch.txt", "x\n");
152 let s = status_of(&RealGit, repo.root()).unwrap();
153 assert!(s.dirty);
154 assert!(s.has_untracked);
155 }
156}