git_status.rs (7100B)
1 use std::path::{Path, PathBuf}; 2 use std::sync::mpsc; 3 use std::time::Instant; 4 5 /// A single file entry from git status --short 6 #[derive(Debug, Clone)] 7 pub struct GitFileEntry { 8 /// Two-character status code (e.g., "M ", " M", "??", "A ") 9 pub status: String, 10 /// File path relative to repo root 11 pub path: String, 12 } 13 14 /// Parsed result of git status --short --branch 15 #[derive(Debug, Clone)] 16 pub struct GitStatusData { 17 /// Current branch name (None if detached HEAD) 18 pub branch: Option<String>, 19 /// List of file entries 20 pub files: Vec<GitFileEntry>, 21 /// When this data was fetched 22 pub fetched_at: Instant, 23 } 24 25 impl GitStatusData { 26 pub fn modified_count(&self) -> usize { 27 self.files 28 .iter() 29 .filter(|f| { 30 let b = f.status.as_bytes(); 31 (b[0] == b'M' || b[1] == b'M') && b[0] != b'?' && b[0] != b'A' && b[0] != b'D' 32 }) 33 .count() 34 } 35 36 pub fn added_count(&self) -> usize { 37 self.files 38 .iter() 39 .filter(|f| f.status.starts_with('A')) 40 .count() 41 } 42 43 pub fn deleted_count(&self) -> usize { 44 self.files 45 .iter() 46 .filter(|f| { 47 let b = f.status.as_bytes(); 48 b[0] == b'D' || b[1] == b'D' 49 }) 50 .count() 51 } 52 53 pub fn untracked_count(&self) -> usize { 54 self.files 55 .iter() 56 .filter(|f| f.status.starts_with('?')) 57 .count() 58 } 59 60 pub fn is_clean(&self) -> bool { 61 self.files.is_empty() 62 } 63 } 64 65 #[derive(Debug, Clone)] 66 pub enum GitStatusError { 67 NotARepo, 68 CommandFailed(String), 69 } 70 71 pub type GitStatusResult = Result<GitStatusData, GitStatusError>; 72 73 /// Manages periodic git status checks for a session 74 pub struct GitStatusCache { 75 cwd: PathBuf, 76 current: Option<GitStatusResult>, 77 receiver: Option<mpsc::Receiver<GitStatusResult>>, 78 last_fetch: Option<Instant>, 79 /// Whether a fetch is currently in-flight 80 fetching: bool, 81 /// Whether the expanded file list is shown 82 pub expanded: bool, 83 } 84 85 const REFRESH_INTERVAL_SECS: f64 = 5.0; 86 87 impl GitStatusCache { 88 pub fn new(cwd: PathBuf) -> Self { 89 Self { 90 cwd, 91 current: None, 92 receiver: None, 93 last_fetch: None, 94 fetching: false, 95 expanded: false, 96 } 97 } 98 99 /// Request a fresh git status (non-blocking, spawns thread) 100 pub fn request_refresh(&mut self) { 101 if self.fetching { 102 return; 103 } 104 let (tx, rx) = mpsc::channel(); 105 let cwd = self.cwd.clone(); 106 std::thread::spawn(move || { 107 let result = run_git_status(&cwd); 108 let _ = tx.send(result); 109 }); 110 self.receiver = Some(rx); 111 self.fetching = true; 112 self.last_fetch = Some(Instant::now()); 113 } 114 115 /// Poll for results (call each frame) 116 pub fn poll(&mut self) { 117 if let Some(rx) = &self.receiver { 118 match rx.try_recv() { 119 Ok(result) => { 120 self.current = Some(result); 121 self.fetching = false; 122 self.receiver = None; 123 } 124 Err(mpsc::TryRecvError::Disconnected) => { 125 self.fetching = false; 126 self.receiver = None; 127 } 128 Err(mpsc::TryRecvError::Empty) => {} 129 } 130 } 131 } 132 133 /// Check if auto-refresh is due and trigger if so 134 pub fn maybe_auto_refresh(&mut self) { 135 let should_refresh = match self.last_fetch { 136 None => true, 137 Some(t) => t.elapsed().as_secs_f64() >= REFRESH_INTERVAL_SECS, 138 }; 139 if should_refresh { 140 self.request_refresh(); 141 } 142 } 143 144 pub fn current(&self) -> Option<&GitStatusResult> { 145 self.current.as_ref() 146 } 147 148 /// Mark cache as stale so next poll triggers a refresh 149 pub fn invalidate(&mut self) { 150 self.last_fetch = None; 151 } 152 } 153 154 fn parse_git_status(output: &str) -> GitStatusData { 155 let mut branch = None; 156 let mut files = Vec::new(); 157 158 for line in output.lines() { 159 if let Some(rest) = line.strip_prefix("## ") { 160 // Branch line: "## main...origin/main" or "## HEAD (no branch)" 161 let branch_name = rest 162 .split("...") 163 .next() 164 .unwrap_or(rest) 165 .split(' ') 166 .next() 167 .unwrap_or(rest); 168 if branch_name != "HEAD" { 169 branch = Some(branch_name.to_string()); 170 } 171 } else if line.len() >= 3 { 172 // File entry: "XY path" where XY is 2-char status 173 let status = line[..2].to_string(); 174 let path = line[3..].to_string(); 175 files.push(GitFileEntry { status, path }); 176 } 177 } 178 179 GitStatusData { 180 branch, 181 files, 182 fetched_at: Instant::now(), 183 } 184 } 185 186 fn run_git_status(cwd: &Path) -> GitStatusResult { 187 let mut cmd = std::process::Command::new("git"); 188 cmd.args(["status", "--short", "--branch"]).current_dir(cwd); 189 190 #[cfg(target_os = "windows")] 191 { 192 use std::os::windows::process::CommandExt; 193 const CREATE_NO_WINDOW: u32 = 0x08000000; 194 cmd.creation_flags(CREATE_NO_WINDOW); 195 } 196 197 let output = cmd 198 .output() 199 .map_err(|e| GitStatusError::CommandFailed(e.to_string()))?; 200 201 if !output.status.success() { 202 let stderr = String::from_utf8_lossy(&output.stderr); 203 if stderr.contains("not a git repository") { 204 return Err(GitStatusError::NotARepo); 205 } 206 return Err(GitStatusError::CommandFailed(stderr.into_owned())); 207 } 208 209 let stdout = String::from_utf8_lossy(&output.stdout); 210 Ok(parse_git_status(&stdout)) 211 } 212 213 #[cfg(test)] 214 mod tests { 215 use super::*; 216 217 #[test] 218 fn test_parse_clean_repo() { 219 let output = "## main...origin/main\n"; 220 let data = parse_git_status(output); 221 assert_eq!(data.branch.as_deref(), Some("main")); 222 assert!(data.is_clean()); 223 } 224 225 #[test] 226 fn test_parse_dirty_repo() { 227 let output = "## dave...origin/dave\n M src/ui/dave.rs\n M src/session.rs\nA src/git_status.rs\n?? src/ui/git_status_ui.rs\n"; 228 let data = parse_git_status(output); 229 assert_eq!(data.branch.as_deref(), Some("dave")); 230 assert_eq!(data.files.len(), 4); 231 assert_eq!(data.modified_count(), 2); 232 assert_eq!(data.added_count(), 1); 233 assert_eq!(data.untracked_count(), 1); 234 assert_eq!(data.deleted_count(), 0); 235 } 236 237 #[test] 238 fn test_parse_detached_head() { 239 let output = "## HEAD (no branch)\n M file.rs\n"; 240 let data = parse_git_status(output); 241 assert!(data.branch.is_none()); 242 assert_eq!(data.files.len(), 1); 243 } 244 245 #[test] 246 fn test_parse_deleted_file() { 247 let output = "## main\n D deleted.rs\n"; 248 let data = parse_git_status(output); 249 assert_eq!(data.deleted_count(), 1); 250 } 251 }