1use std::{
8 collections::HashMap,
9 path::{Path, PathBuf},
10 process::Command,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum GitMark {
16 Untracked,
18 Added,
20 Renamed,
22 Deleted,
24 Modified,
26 Conflict,
28}
29
30impl GitMark {
31 #[must_use]
33 pub fn symbol(self) -> char {
34 match self {
35 Self::Untracked => '?',
36 Self::Added => 'A',
37 Self::Renamed => 'R',
38 Self::Deleted => 'D',
39 Self::Modified => 'M',
40 Self::Conflict => 'U',
41 }
42 }
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct GitStatus {
49 marks: HashMap<PathBuf, GitMark>,
50}
51
52impl GitStatus {
53 #[must_use]
55 pub fn mark(&self, path: &Path) -> Option<GitMark> {
56 self.marks.get(path).copied()
57 }
58
59 #[must_use]
61 pub fn is_clean(&self) -> bool {
62 self.marks.is_empty()
63 }
64
65 #[must_use]
67 pub fn len(&self) -> usize {
68 self.marks.len()
69 }
70
71 #[must_use]
73 pub fn is_empty(&self) -> bool {
74 self.marks.is_empty()
75 }
76
77 fn insert(&mut self, root: &Path, rel: &str, mark: GitMark) {
78 let mut path = root.join(rel.trim_end_matches('/'));
79 self.marks
81 .entry(path.clone())
82 .and_modify(|m| *m = (*m).max(mark))
83 .or_insert(mark);
84 while path.pop() && path != root && path.starts_with(root) {
85 self.marks
86 .entry(path.clone())
87 .and_modify(|m| *m = (*m).max(mark))
88 .or_insert(mark);
89 }
90 }
91}
92
93fn classify(x: u8, y: u8) -> Option<GitMark> {
95 match (x, y) {
96 (b'?', b'?') => Some(GitMark::Untracked),
97 (b'!', b'!') => None,
98 (b'U', _) | (_, b'U') | (b'A', b'A') | (b'D', b'D') => Some(GitMark::Conflict),
99 (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => Some(GitMark::Modified),
100 (b'D', _) | (_, b'D') => Some(GitMark::Deleted),
101 (b'R', _) | (_, b'R') | (b'C', _) | (_, b'C') => Some(GitMark::Renamed),
102 (b'A', _) | (_, b'A') => Some(GitMark::Added),
103 _ => None,
104 }
105}
106
107#[must_use]
109pub fn parse_porcelain_z(root: &Path, output: &[u8]) -> GitStatus {
110 let mut status = GitStatus::default();
111 let mut records = output.split(|&b| b == 0).filter(|r| !r.is_empty());
112 while let Some(rec) = records.next() {
113 if rec.len() < 4 {
114 continue;
115 }
116 let (x, y) = (rec[0], rec[1]);
117 let path = String::from_utf8_lossy(&rec[3..]).into_owned();
118 if x == b'R' || x == b'C' || y == b'R' || y == b'C' {
120 let _ = records.next();
121 }
122 if let Some(mark) = classify(x, y) {
123 status.insert(root, &path, mark);
124 }
125 }
126 status
127}
128
129#[must_use]
132pub fn status_for(dir: &Path) -> Option<GitStatus> {
133 let top = Command::new("git")
134 .arg("-C")
135 .arg(dir)
136 .args(["rev-parse", "--show-toplevel"])
137 .output()
138 .ok()
139 .filter(|o| o.status.success())?;
140 let root = PathBuf::from(String::from_utf8_lossy(&top.stdout).trim_end());
141 let canon_root = root.canonicalize().unwrap_or_else(|_| root.clone());
144 let root = match dir.canonicalize() {
145 Ok(canon_dir) if canon_dir.starts_with(&canon_root) => {
146 let rel_depth = canon_dir
147 .strip_prefix(&canon_root)
148 .map_or(0, |p| p.components().count());
149 let mut r = dir.to_path_buf();
150 for _ in 0..rel_depth {
151 r.pop();
152 }
153 r
154 }
155 _ => root,
156 };
157 let out = Command::new("git")
158 .arg("-C")
159 .arg(&root)
160 .args(["status", "--porcelain=v1", "-z", "--untracked-files=normal"])
161 .output()
162 .ok()
163 .filter(|o| o.status.success())?;
164 Some(parse_porcelain_z(&root, &out.stdout))
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn root() -> PathBuf {
172 PathBuf::from("/repo")
173 }
174
175 #[test]
176 fn parses_basic_statuses() {
177 let out = b" M src/lib.rs\0A new.txt\0?? scratch.txt\0 D gone.txt\0";
178 let s = parse_porcelain_z(&root(), out);
179 assert_eq!(
180 s.mark(Path::new("/repo/src/lib.rs")),
181 Some(GitMark::Modified)
182 );
183 assert_eq!(s.mark(Path::new("/repo/new.txt")), Some(GitMark::Added));
184 assert_eq!(
185 s.mark(Path::new("/repo/scratch.txt")),
186 Some(GitMark::Untracked)
187 );
188 assert_eq!(s.mark(Path::new("/repo/gone.txt")), Some(GitMark::Deleted));
189 }
190
191 #[test]
192 fn directories_aggregate_highest_priority() {
193 let out = b"?? a/b/new.txt\0 M a/c.rs\0";
194 let s = parse_porcelain_z(&root(), out);
195 assert_eq!(s.mark(Path::new("/repo/a")), Some(GitMark::Modified));
196 assert_eq!(s.mark(Path::new("/repo/a/b")), Some(GitMark::Untracked));
197 assert_eq!(
198 s.mark(Path::new("/repo")),
199 None,
200 "root itself is not marked"
201 );
202 }
203
204 #[test]
205 fn rename_consumes_the_from_path() {
206 let out = b"R new_name.rs\0old_name.rs\0 M other.rs\0";
207 let s = parse_porcelain_z(&root(), out);
208 assert_eq!(
209 s.mark(Path::new("/repo/new_name.rs")),
210 Some(GitMark::Renamed)
211 );
212 assert_eq!(s.mark(Path::new("/repo/old_name.rs")), None);
213 assert_eq!(s.mark(Path::new("/repo/other.rs")), Some(GitMark::Modified));
214 }
215
216 #[test]
217 fn conflicts_win_over_modified() {
218 let out = b"UU merge.rs\0";
219 let s = parse_porcelain_z(&root(), out);
220 assert_eq!(s.mark(Path::new("/repo/merge.rs")), Some(GitMark::Conflict));
221 assert!(GitMark::Conflict > GitMark::Modified);
222 }
223
224 #[test]
225 fn ignored_and_empty_input_yield_no_marks() {
226 assert!(parse_porcelain_z(&root(), b"").is_clean());
227 assert!(parse_porcelain_z(&root(), b"!! target/\0").is_clean());
228 }
229
230 #[test]
231 fn status_for_a_missing_directory_is_none() {
232 assert!(status_for(Path::new("/definitely/not/a/real/dir")).is_none());
233 }
234
235 #[test]
236 fn status_for_real_repo_marks_untracked() {
237 let tmp = tempfile::tempdir().unwrap();
238 let ok = Command::new("git")
239 .arg("-C")
240 .arg(tmp.path())
241 .args(["init", "-q"])
242 .status()
243 .map(|s| s.success())
244 .unwrap_or(false);
245 if !ok {
246 return; }
248 std::fs::write(tmp.path().join("hello.txt"), "x").unwrap();
249 let status = status_for(tmp.path()).expect("status");
250 assert_eq!(
251 status.mark(&tmp.path().join("hello.txt")),
252 Some(GitMark::Untracked)
253 );
254 }
255}