1use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use f00_core::{Entry, GitStatus, Listing};
10
11#[derive(Debug, Default, Clone)]
13pub struct GitIndex {
14 statuses: HashMap<PathBuf, GitStatus>,
16 repo_root: Option<PathBuf>,
17}
18
19impl GitIndex {
20 pub fn empty() -> Self {
21 Self::default()
22 }
23
24 pub fn repo_root(&self) -> Option<&Path> {
25 self.repo_root.as_deref()
26 }
27
28 pub fn status_for(&self, path: &Path) -> GitStatus {
29 if let Some(st) = self.statuses.get(path) {
31 return *st;
32 }
33 if let Some(root) = &self.repo_root {
34 if let Ok(rel) = path.strip_prefix(root) {
35 if let Some(st) = self.statuses.get(rel) {
36 return *st;
37 }
38 }
39 }
40 if let Some(name) = path.file_name() {
42 for (k, v) in &self.statuses {
43 if k.file_name() == Some(name) && k.components().count() == 1 {
44 return *v;
45 }
46 }
47 }
48 GitStatus::Clean
49 }
50
51 pub fn discover(start: &Path) -> Self {
53 let root = match find_repo_root(start) {
54 Some(r) => r,
55 None => return Self::empty(),
56 };
57
58 let output = Command::new("git")
59 .args(["status", "--porcelain", "-uall"])
60 .current_dir(&root)
61 .output();
62
63 let output = match output {
64 Ok(o) if o.status.success() => o,
65 _ => {
66 return Self {
67 statuses: HashMap::new(),
68 repo_root: Some(root),
69 };
70 }
71 };
72
73 let stdout = String::from_utf8_lossy(&output.stdout);
74 let mut statuses = HashMap::new();
75
76 for line in stdout.lines() {
77 if line.len() < 3 {
78 continue;
79 }
80 let code = &line[..2];
81 let rest = line[3..].trim();
82 let path_str = if let Some((left, _right)) = rest.split_once(" -> ") {
84 left.trim()
85 } else {
86 rest
87 };
88 let path_str = path_str.trim_matches('"');
90 let rel = PathBuf::from(path_str);
91 let st = parse_porcelain_code(code);
92 statuses.insert(rel.clone(), st);
93 statuses.insert(root.join(&rel), st);
94 }
95
96 Self {
97 statuses,
98 repo_root: Some(root),
99 }
100 }
101}
102
103fn parse_porcelain_code(code: &str) -> GitStatus {
105 let chars: Vec<char> = code.chars().collect();
106 let x = chars.first().copied().unwrap_or(' ');
107 let y = chars.get(1).copied().unwrap_or(' ');
108
109 for c in [y, x] {
111 match c {
112 'M' => return GitStatus::Modified,
113 'A' => return GitStatus::Added,
114 'D' => return GitStatus::Deleted,
115 'R' | 'C' => return GitStatus::Renamed,
116 'U' => return GitStatus::Conflicted,
117 '?' => return GitStatus::Untracked,
118 '!' => return GitStatus::Ignored,
119 _ => {}
120 }
121 }
122 GitStatus::Unknown
123}
124
125pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
127 let start = if start.is_file() {
128 start.parent()?.to_path_buf()
129 } else {
130 start.to_path_buf()
131 };
132 let abs = std::fs::canonicalize(&start).unwrap_or(start);
133 let mut cur = abs.as_path();
134 loop {
135 let git = cur.join(".git");
136 if git.exists() {
137 return Some(cur.to_path_buf());
138 }
139 cur = cur.parent()?;
140 }
141}
142
143pub fn annotate_listing(listing: &mut Listing, index: &GitIndex) {
145 for entry in &mut listing.entries {
146 if entry.is_dir_header {
147 continue;
148 }
149 entry.git_status = index.status_for(&entry.path);
150 }
151}
152
153pub fn annotate_listings(listings: &mut [Listing]) {
155 use std::collections::HashMap;
156 use std::path::PathBuf;
157
158 let mut by_repo: HashMap<PathBuf, GitIndex> = HashMap::new();
160 for listing in listings {
161 let key = find_repo_root(&listing.root).unwrap_or_else(|| listing.root.clone());
162 let index = by_repo
163 .entry(key)
164 .or_insert_with(|| GitIndex::discover(&listing.root));
165 annotate_listing(listing, index);
166 }
167}
168
169pub fn annotate_entries(entries: &mut [Entry], start: &Path) {
171 let index = GitIndex::discover(start);
172 for entry in entries {
173 if !entry.is_dir_header {
174 entry.git_status = index.status_for(&entry.path);
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn parse_modified() {
185 assert_eq!(parse_porcelain_code(" M"), GitStatus::Modified);
186 assert_eq!(parse_porcelain_code("M "), GitStatus::Modified);
187 assert_eq!(parse_porcelain_code("MM"), GitStatus::Modified);
188 }
189
190 #[test]
191 fn parse_untracked() {
192 assert_eq!(parse_porcelain_code("??"), GitStatus::Untracked);
193 }
194
195 #[test]
196 fn parse_added() {
197 assert_eq!(parse_porcelain_code("A "), GitStatus::Added);
198 }
199
200 #[test]
201 fn find_repo_from_workspace() {
202 let base = std::env::temp_dir().join(format!(
205 "f00-git-find-{}-{}",
206 std::process::id(),
207 std::time::SystemTime::now()
208 .duration_since(std::time::UNIX_EPOCH)
209 .map(|d| d.as_nanos())
210 .unwrap_or(0)
211 ));
212 std::fs::create_dir_all(base.join("nested/deep")).unwrap();
213 std::fs::create_dir_all(base.join(".git")).unwrap();
214 let found = find_repo_root(&base.join("nested/deep")).expect("repo root");
215 let found_c = std::fs::canonicalize(&found).unwrap_or(found);
217 let base_c = std::fs::canonicalize(&base).unwrap_or(base.clone());
218 assert_eq!(found_c, base_c);
219 let _ = std::fs::remove_dir_all(&base);
220
221 let _ = find_repo_root(Path::new(env!("CARGO_MANIFEST_DIR")));
223 }
224}