use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GitStatus {
pub staged: bool,
pub modified: bool,
pub untracked: bool,
}
impl GitStatus {
#[allow(dead_code)] pub fn is_clean(self) -> bool {
!self.staged && !self.modified && !self.untracked
}
}
pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
let mut cur = start.to_path_buf();
loop {
if cur.join(".git").exists() {
return Some(cur);
}
if !cur.pop() {
return None;
}
}
}
pub fn status(repo_root: &Path) -> HashMap<String, GitStatus> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["status", "--porcelain=v1", "-z"])
.output();
let Ok(out) = output else {
return HashMap::new();
};
if !out.status.success() {
return HashMap::new();
}
parse_porcelain(&out.stdout)
}
fn parse_porcelain(bytes: &[u8]) -> HashMap<String, GitStatus> {
let mut out: HashMap<String, GitStatus> = HashMap::new();
let mut i = 0;
while i < bytes.len() {
if i + 3 > bytes.len() {
break;
}
let x = bytes[i] as char;
let y = bytes[i + 1] as char;
i += 3;
let start = i;
while i < bytes.len() && bytes[i] != 0 {
i += 1;
}
let path = match std::str::from_utf8(&bytes[start..i]) {
Ok(s) => s.to_string(),
Err(_) => {
i += 1;
continue;
}
};
i += 1; if x == 'R' || x == 'C' {
while i < bytes.len() && bytes[i] != 0 {
i += 1;
}
if i < bytes.len() {
i += 1;
}
}
let status = GitStatus {
staged: !matches!(x, ' ' | '?' | '!'),
modified: !matches!(y, ' ' | '?' | '!'),
untracked: x == '?' || y == '?',
};
out.insert(path, status);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_porcelain_modified() {
let bytes = b" M Red Hand of Doom/rp-posts/Posterity.md\0";
let m = parse_porcelain(bytes);
assert_eq!(m.len(), 1);
let s = m.get("Red Hand of Doom/rp-posts/Posterity.md").unwrap();
assert!(s.modified);
assert!(!s.staged);
assert!(!s.untracked);
}
#[test]
fn parse_porcelain_staged_and_modified() {
let bytes = b"MM file.md\0";
let m = parse_porcelain(bytes);
let s = m.get("file.md").unwrap();
assert!(s.staged);
assert!(s.modified);
assert!(!s.untracked);
}
#[test]
fn parse_porcelain_untracked() {
let bytes = b"?? new.md\0";
let m = parse_porcelain(bytes);
let s = m.get("new.md").unwrap();
assert!(s.untracked);
assert!(!s.staged);
assert!(!s.modified);
}
#[test]
fn parse_porcelain_multiple_records() {
let bytes = b" M a.md\0?? b.md\0M c.md\0";
let m = parse_porcelain(bytes);
assert_eq!(m.len(), 3);
assert!(m["a.md"].modified);
assert!(m["b.md"].untracked);
assert!(m["c.md"].staged);
}
#[test]
fn parse_porcelain_rename_skips_old_path() {
let bytes = b"R newpath.md\0oldpath.md\0?? other.md\0";
let m = parse_porcelain(bytes);
assert!(m.contains_key("newpath.md"));
assert!(m.contains_key("other.md"));
assert!(!m.contains_key("oldpath.md"));
}
}