notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }