1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::sync::OnceLock;
7
8static GIT_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
10
11fn find_git_executable() -> Option<&'static PathBuf> {
13 GIT_PATH
14 .get_or_init(|| {
15 let candidates = [
17 "/usr/bin/git",
18 "/usr/local/bin/git",
19 "/opt/homebrew/bin/git",
20 ];
21
22 for path in candidates {
23 let p = PathBuf::from(path);
24 if p.exists() {
25 return Some(p);
26 }
27 }
28
29 std::process::Command::new("which")
31 .arg("git")
32 .output()
33 .ok()
34 .filter(|o| o.status.success())
35 .and_then(|o| String::from_utf8(o.stdout).ok())
36 .map(|s| PathBuf::from(s.trim()))
37 .filter(|p| p.exists())
38 })
39 .as_ref()
40}
41
42fn git_command() -> Option<Command> {
44 find_git_executable().map(Command::new)
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum FileStatus {
50 Modified,
52 Added,
54 Untracked,
56 Deleted,
58 Renamed,
60 Ignored,
62 Conflict,
64 #[default]
66 Clean,
67}
68
69#[derive(Debug)]
71pub struct GitStatus {
72 repo_root: PathBuf,
74 statuses: HashMap<PathBuf, FileStatus>,
76 dir_statuses: HashMap<PathBuf, FileStatus>,
78 branch: Option<String>,
80 staged_files: std::collections::HashSet<PathBuf>,
82}
83
84impl GitStatus {
85 pub fn detect(path: &Path) -> Option<Self> {
87 let repo_root = find_git_root(path)?;
88 let branch = get_current_branch(&repo_root);
89 let (statuses, dir_statuses, staged_files) = load_git_status(&repo_root);
90
91 Some(Self {
92 repo_root,
93 statuses,
94 dir_statuses,
95 branch,
96 staged_files,
97 })
98 }
99
100 pub fn get_status(&self, path: &Path) -> FileStatus {
102 if let Some(status) = self.statuses.get(path) {
104 return *status;
105 }
106
107 if let Some(status) = self.dir_statuses.get(path) {
109 return *status;
110 }
111
112 if let Ok(relative) = path.strip_prefix(&self.repo_root) {
114 if let Some(status) = self.statuses.get(relative) {
115 return *status;
116 }
117 if let Some(status) = self.dir_statuses.get(relative) {
118 return *status;
119 }
120 }
121
122 FileStatus::Clean
123 }
124
125 pub fn branch(&self) -> Option<&str> {
127 self.branch.as_deref()
128 }
129
130 pub fn repo_root(&self) -> &Path {
132 &self.repo_root
133 }
134
135 pub fn refresh(&mut self) {
137 self.branch = get_current_branch(&self.repo_root);
138 let (statuses, dir_statuses, staged_files) = load_git_status(&self.repo_root);
139 self.statuses = statuses;
140 self.dir_statuses = dir_statuses;
141 self.staged_files = staged_files;
142 }
143
144 pub fn is_staged(&self, path: &Path) -> bool {
146 if self.staged_files.contains(path) {
148 return true;
149 }
150
151 if let Ok(relative) = path.strip_prefix(&self.repo_root) {
153 if self.staged_files.contains(relative) {
154 return true;
155 }
156 }
157
158 false
159 }
160
161 #[cfg(test)]
163 pub fn default_with_root(repo_root: PathBuf) -> Self {
164 Self {
165 repo_root,
166 statuses: std::collections::HashMap::new(),
167 dir_statuses: std::collections::HashMap::new(),
168 branch: None,
169 staged_files: std::collections::HashSet::new(),
170 }
171 }
172}
173
174fn find_git_root(path: &Path) -> Option<PathBuf> {
176 let output = git_command()?
177 .args(["rev-parse", "--show-toplevel"])
178 .current_dir(path)
179 .output()
180 .ok()?;
181
182 if output.status.success() {
183 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
184 Some(PathBuf::from(root))
185 } else {
186 None
187 }
188}
189
190fn get_current_branch(repo_root: &Path) -> Option<String> {
192 let output = git_command()?
193 .args(["rev-parse", "--abbrev-ref", "HEAD"])
194 .current_dir(repo_root)
195 .output()
196 .ok()?;
197
198 if output.status.success() {
199 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
200 if branch == "HEAD" {
201 let hash_output = git_command()?
203 .args(["rev-parse", "--short", "HEAD"])
204 .current_dir(repo_root)
205 .output()
206 .ok()?;
207 if hash_output.status.success() {
208 return Some(format!(
209 "detached@{}",
210 String::from_utf8_lossy(&hash_output.stdout).trim()
211 ));
212 }
213 }
214 Some(branch)
215 } else {
216 None
217 }
218}
219
220fn load_git_status(
222 repo_root: &Path,
223) -> (
224 HashMap<PathBuf, FileStatus>,
225 HashMap<PathBuf, FileStatus>,
226 std::collections::HashSet<PathBuf>,
227) {
228 use std::collections::HashSet;
229
230 let mut statuses = HashMap::new();
231 let mut dir_statuses: HashMap<PathBuf, FileStatus> = HashMap::new();
232 let mut staged_files: HashSet<PathBuf> = HashSet::new();
233
234 let Some(mut cmd) = git_command() else {
237 return (statuses, dir_statuses, staged_files);
238 };
239 let output = cmd
240 .args(["status", "--porcelain=v1", "-uall", "--ignored"])
241 .current_dir(repo_root)
242 .output();
243
244 let output = match output {
245 Ok(o) if o.status.success() => o,
246 _ => return (statuses, dir_statuses, staged_files),
247 };
248
249 let stdout = String::from_utf8_lossy(&output.stdout);
250
251 for line in stdout.lines() {
252 if line.len() < 4 {
253 continue;
254 }
255
256 let index_status = line.chars().next().unwrap_or(' ');
257 let worktree_status = line.chars().nth(1).unwrap_or(' ');
258 let path_str = &line[3..];
259
260 let file_path = if path_str.contains(" -> ") {
262 path_str.split(" -> ").last().unwrap_or(path_str)
263 } else {
264 path_str
265 };
266
267 let path = PathBuf::from(file_path);
268 let status = parse_status(index_status, worktree_status);
269
270 if matches!(index_status, 'M' | 'A' | 'D' | 'R' | 'C') {
272 staged_files.insert(path.clone());
273 }
274
275 if status != FileStatus::Clean {
276 statuses.insert(path.clone(), status);
277
278 let mut parent = path.parent();
280 while let Some(dir) = parent {
281 if dir.as_os_str().is_empty() {
282 break;
283 }
284 let current = dir_statuses
285 .entry(dir.to_path_buf())
286 .or_insert(FileStatus::Clean);
287 *current = merge_status(*current, status);
288 parent = dir.parent();
289 }
290 }
291 }
292
293 (statuses, dir_statuses, staged_files)
294}
295
296fn parse_status(index: char, worktree: char) -> FileStatus {
298 if index == 'U'
300 || worktree == 'U'
301 || (index == 'A' && worktree == 'A')
302 || (index == 'D' && worktree == 'D')
303 {
304 return FileStatus::Conflict;
305 }
306
307 if index == '!' {
309 return FileStatus::Ignored;
310 }
311
312 if index == '?' {
314 return FileStatus::Untracked;
315 }
316
317 if index == 'R' || worktree == 'R' {
319 return FileStatus::Renamed;
320 }
321
322 if index == 'A' {
324 return FileStatus::Added;
325 }
326
327 if index == 'D' || worktree == 'D' {
329 return FileStatus::Deleted;
330 }
331
332 if index == 'M' || worktree == 'M' {
334 return FileStatus::Modified;
335 }
336
337 FileStatus::Clean
338}
339
340fn merge_status(a: FileStatus, b: FileStatus) -> FileStatus {
342 use FileStatus::*;
343
344 match (a, b) {
345 (Conflict, _) | (_, Conflict) => Conflict,
347 (Deleted, _) | (_, Deleted) => Deleted,
349 (Modified, _) | (_, Modified) => Modified,
351 (Renamed, _) | (_, Renamed) => Renamed,
353 (Added, _) | (_, Added) => Added,
355 (Untracked, _) | (_, Untracked) => Untracked,
357 (Ignored, other) | (other, Ignored) => other,
359 (Clean, Clean) => Clean,
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_parse_status_modified() {
370 assert_eq!(parse_status('M', ' '), FileStatus::Modified);
371 assert_eq!(parse_status(' ', 'M'), FileStatus::Modified);
372 assert_eq!(parse_status('M', 'M'), FileStatus::Modified);
373 }
374
375 #[test]
376 fn test_parse_status_added() {
377 assert_eq!(parse_status('A', ' '), FileStatus::Added);
378 }
379
380 #[test]
381 fn test_parse_status_deleted() {
382 assert_eq!(parse_status('D', ' '), FileStatus::Deleted);
383 assert_eq!(parse_status(' ', 'D'), FileStatus::Deleted);
384 }
385
386 #[test]
387 fn test_parse_status_untracked() {
388 assert_eq!(parse_status('?', '?'), FileStatus::Untracked);
389 }
390
391 #[test]
392 fn test_parse_status_ignored() {
393 assert_eq!(parse_status('!', '!'), FileStatus::Ignored);
394 }
395
396 #[test]
397 fn test_parse_status_conflict() {
398 assert_eq!(parse_status('U', 'U'), FileStatus::Conflict);
399 assert_eq!(parse_status('A', 'A'), FileStatus::Conflict);
400 }
401
402 #[test]
403 fn test_parse_status_renamed() {
404 assert_eq!(parse_status('R', ' '), FileStatus::Renamed);
405 }
406
407 #[test]
408 fn test_merge_status() {
409 assert_eq!(
410 merge_status(FileStatus::Clean, FileStatus::Modified),
411 FileStatus::Modified
412 );
413 assert_eq!(
414 merge_status(FileStatus::Modified, FileStatus::Conflict),
415 FileStatus::Conflict
416 );
417 assert_eq!(
418 merge_status(FileStatus::Untracked, FileStatus::Added),
419 FileStatus::Added
420 );
421 }
422}