1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::Mutex;
10use std::time::SystemTime;
11
12use f00_core::{Entry, GitStatus, Listing};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum UntrackedMode {
17 #[default]
19 All,
20 No,
22}
23
24#[derive(Debug, Default, Clone)]
26pub struct GitIndex {
27 statuses: HashMap<PathBuf, GitStatus>,
29 repo_root: Option<PathBuf>,
30 #[allow(dead_code)]
32 index_mtime: Option<SystemTime>,
33 #[allow(dead_code)]
34 untracked: UntrackedMode,
35}
36
37#[derive(Clone)]
38struct CacheEntry {
39 index: GitIndex,
40 index_mtime: Option<SystemTime>,
41 untracked: UntrackedMode,
42}
43
44fn global_cache() -> &'static Mutex<HashMap<PathBuf, CacheEntry>> {
45 use std::sync::OnceLock;
46 static CACHE: OnceLock<Mutex<HashMap<PathBuf, CacheEntry>>> = OnceLock::new();
47 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
48}
49
50fn index_mtime(repo: &Path) -> Option<SystemTime> {
51 let index = repo.join(".git/index");
52 std::fs::metadata(index).and_then(|m| m.modified()).ok()
53}
54
55impl GitIndex {
56 pub fn empty() -> Self {
57 Self::default()
58 }
59
60 pub fn repo_root(&self) -> Option<&Path> {
61 self.repo_root.as_deref()
62 }
63
64 pub fn status_for(&self, path: &Path) -> GitStatus {
65 if let Some(st) = self.statuses.get(path) {
66 return *st;
67 }
68 if let Some(root) = &self.repo_root {
69 if let Ok(rel) = path.strip_prefix(root) {
70 if let Some(st) = self.statuses.get(rel) {
71 return *st;
72 }
73 }
74 }
75 if let Some(name) = path.file_name() {
77 for (k, v) in &self.statuses {
78 if k.file_name() == Some(name) && k.components().count() == 1 {
79 return *v;
80 }
81 }
82 }
83 GitStatus::Clean
84 }
85
86 pub fn discover(start: &Path) -> Self {
88 Self::discover_with(start, UntrackedMode::All)
89 }
90
91 pub fn discover_with(start: &Path, untracked: UntrackedMode) -> Self {
93 let root = match find_repo_root(start) {
94 Some(r) => r,
95 None => return Self::empty(),
96 };
97
98 let mtime = index_mtime(&root);
99 if let Ok(cache) = global_cache().lock() {
100 if let Some(hit) = cache.get(&root) {
101 if hit.untracked == untracked && hit.index_mtime == mtime {
102 return hit.index.clone();
103 }
104 }
105 }
106
107 let index = Self::load_fresh(&root, mtime, untracked);
108 if let Ok(mut cache) = global_cache().lock() {
109 cache.insert(
110 root.clone(),
111 CacheEntry {
112 index: index.clone(),
113 index_mtime: mtime,
114 untracked,
115 },
116 );
117 if cache.len() > 32 {
119 if let Some(k) = cache.keys().next().cloned() {
121 cache.remove(&k);
122 }
123 }
124 }
125 index
126 }
127
128 fn load_fresh(root: &Path, mtime: Option<SystemTime>, untracked: UntrackedMode) -> Self {
129 let uflag = match untracked {
130 UntrackedMode::All => "-uall",
131 UntrackedMode::No => "-uno",
132 };
133 let output = Command::new("git")
134 .args(["status", "--porcelain", "--ignored=no", uflag])
135 .current_dir(root)
136 .output();
137
138 let output = match output {
139 Ok(o) if o.status.success() => o,
140 _ => {
141 return Self {
142 statuses: HashMap::new(),
143 repo_root: Some(root.to_path_buf()),
144 index_mtime: mtime,
145 untracked,
146 };
147 }
148 };
149
150 let stdout = String::from_utf8_lossy(&output.stdout);
151 let mut statuses = HashMap::new();
152
153 for line in stdout.lines() {
154 if line.len() < 3 {
155 continue;
156 }
157 let code = &line[..2];
158 let rest = line[3..].trim();
159 let path_str = if let Some((left, _right)) = rest.split_once(" -> ") {
160 left.trim()
161 } else {
162 rest
163 };
164 let path_str = path_str.trim_matches('"');
165 let rel = PathBuf::from(path_str);
166 let st = parse_porcelain_code(code);
167 statuses.insert(rel.clone(), st);
168 statuses.insert(root.join(&rel), st);
169 }
170
171 Self {
172 statuses,
173 repo_root: Some(root.to_path_buf()),
174 index_mtime: mtime,
175 untracked,
176 }
177 }
178}
179
180fn parse_porcelain_code(code: &str) -> GitStatus {
182 let chars: Vec<char> = code.chars().collect();
183 let x = chars.first().copied().unwrap_or(' ');
184 let y = chars.get(1).copied().unwrap_or(' ');
185
186 for c in [y, x] {
187 match c {
188 'M' => return GitStatus::Modified,
189 'A' => return GitStatus::Added,
190 'D' => return GitStatus::Deleted,
191 'R' | 'C' => return GitStatus::Renamed,
192 'U' => return GitStatus::Conflicted,
193 '?' => return GitStatus::Untracked,
194 '!' => return GitStatus::Ignored,
195 _ => {}
196 }
197 }
198 GitStatus::Unknown
199}
200
201pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
203 let start = if start.is_file() {
204 start.parent()?.to_path_buf()
205 } else {
206 start.to_path_buf()
207 };
208 let abs = std::fs::canonicalize(&start).unwrap_or(start);
209 let mut cur = abs.as_path();
210 loop {
211 let git = cur.join(".git");
212 if git.exists() {
213 return Some(cur.to_path_buf());
214 }
215 cur = cur.parent()?;
216 }
217}
218
219pub fn annotate_listing(listing: &mut Listing, index: &GitIndex) {
221 for entry in &mut listing.entries {
222 if entry.is_dir_header {
223 continue;
224 }
225 entry.git_status = index.status_for(&entry.path);
226 }
227}
228
229pub fn annotate_listings(listings: &mut [Listing]) {
234 use std::collections::HashMap;
235
236 let mut by_repo: HashMap<PathBuf, GitIndex> = HashMap::new();
237 for listing in listings.iter_mut() {
238 let recursive = listing
239 .entries
240 .iter()
241 .any(|e| e.depth > 0 || e.is_dir_header);
242 let mode = if recursive {
243 UntrackedMode::No
244 } else {
245 UntrackedMode::All
246 };
247 let key = find_repo_root(&listing.root).unwrap_or_else(|| listing.root.clone());
248 let index = by_repo
249 .entry(key)
250 .or_insert_with(|| GitIndex::discover_with(&listing.root, mode));
251 annotate_listing(listing, index);
252 }
253}
254
255pub fn annotate_entries(entries: &mut [Entry], start: &Path) {
257 let recursive = entries.iter().any(|e| e.depth > 0);
258 let mode = if recursive {
259 UntrackedMode::No
260 } else {
261 UntrackedMode::All
262 };
263 let index = GitIndex::discover_with(start, mode);
264 for entry in entries {
265 if !entry.is_dir_header {
266 entry.git_status = index.status_for(&entry.path);
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn parse_modified() {
277 assert_eq!(parse_porcelain_code(" M"), GitStatus::Modified);
278 assert_eq!(parse_porcelain_code("M "), GitStatus::Modified);
279 assert_eq!(parse_porcelain_code("MM"), GitStatus::Modified);
280 }
281
282 #[test]
283 fn parse_untracked() {
284 assert_eq!(parse_porcelain_code("??"), GitStatus::Untracked);
285 }
286
287 #[test]
288 fn parse_added() {
289 assert_eq!(parse_porcelain_code("A "), GitStatus::Added);
290 }
291
292 #[test]
293 fn find_repo_from_workspace() {
294 let base = std::env::temp_dir().join(format!(
295 "f00-git-find-{}-{}",
296 std::process::id(),
297 std::time::SystemTime::now()
298 .duration_since(std::time::UNIX_EPOCH)
299 .map(|d| d.as_nanos())
300 .unwrap_or(0)
301 ));
302 std::fs::create_dir_all(base.join("nested/deep")).unwrap();
303 std::fs::create_dir_all(base.join(".git")).unwrap();
304 let found = find_repo_root(&base.join("nested/deep")).expect("repo root");
305 let found_c = std::fs::canonicalize(&found).unwrap_or(found);
306 let base_c = std::fs::canonicalize(&base).unwrap_or(base.clone());
307 assert_eq!(found_c, base_c);
308 let _ = std::fs::remove_dir_all(&base);
309 let _ = find_repo_root(Path::new(env!("CARGO_MANIFEST_DIR")));
310 }
311
312 #[test]
313 fn cache_returns_same_for_second_discover() {
314 let base = std::env::temp_dir().join(format!(
315 "f00-git-cache-{}-{}",
316 std::process::id(),
317 std::time::SystemTime::now()
318 .duration_since(std::time::UNIX_EPOCH)
319 .map(|d| d.as_nanos())
320 .unwrap_or(0)
321 ));
322 std::fs::create_dir_all(base.join(".git")).unwrap();
323 let a = GitIndex::discover_with(&base, UntrackedMode::No);
325 let b = GitIndex::discover_with(&base, UntrackedMode::No);
326 assert_eq!(a.repo_root(), b.repo_root());
327 let _ = std::fs::remove_dir_all(&base);
328 }
329}