use crate::theme;
use ratatui::style::Color;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum GitStatus {
Untracked,
Added,
Modified,
Deleted,
Renamed,
Conflict,
}
impl GitStatus {
pub fn glyph(&self) -> &'static str {
match self {
GitStatus::Untracked => "?",
GitStatus::Added => "+",
GitStatus::Modified => "●",
GitStatus::Deleted => "✗",
GitStatus::Renamed => "»",
GitStatus::Conflict => "!",
}
}
pub fn color(&self) -> Color {
let p = theme::palette();
match self {
GitStatus::Untracked => p.dim, GitStatus::Added => p.sheet, GitStatus::Modified => p.doc, GitStatus::Deleted => p.pdf, GitStatus::Renamed => p.image, GitStatus::Conflict => p.video, }
}
fn precedence(self) -> u8 {
match self {
GitStatus::Conflict => 5,
GitStatus::Modified => 4,
GitStatus::Added => 3,
GitStatus::Deleted => 2,
GitStatus::Renamed => 1,
GitStatus::Untracked => 0,
}
}
}
pub fn status_map(dir: &Path) -> Option<HashMap<String, GitStatus>> {
let rev = Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel", "--show-prefix"])
.output()
.ok()?;
if !rev.status.success() {
return None;
}
let rev_out = String::from_utf8_lossy(&rev.stdout);
let mut lines = rev_out.lines();
let _toplevel = lines.next()?; let prefix = lines.next().unwrap_or("").trim();
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args([
"status",
"--porcelain=v1",
"-z",
"--untracked-files=normal",
"--ignored=no",
])
.output()
.ok()?;
if !status.status.success() {
return None;
}
let records = parse_porcelain_z(&status.stdout);
Some(resolve(&records, prefix))
}
fn parse_porcelain_z(bytes: &[u8]) -> Vec<(String, String)> {
let text = String::from_utf8_lossy(bytes);
let mut fields = text.split('\0');
let mut records = Vec::new();
while let Some(field) = fields.next() {
if field.len() < 3 {
continue;
}
let xy = &field[..2];
let path = &field[3..];
if matches!(xy.as_bytes()[0], b'R' | b'C') {
let _ = fields.next();
}
records.push((xy.to_string(), path.to_string()));
}
records
}
pub fn resolve(records: &[(String, String)], prefix: &str) -> HashMap<String, GitStatus> {
let mut map: HashMap<String, GitStatus> = HashMap::new();
for (xy, path) in records {
let Some(rest) = path.strip_prefix(prefix) else {
continue; };
if rest.is_empty() {
continue;
}
let (name, status) = match rest.find('/') {
None => (rest.to_string(), status_from_xy(xy)),
Some(slash) => (rest[..slash].to_string(), dir_signal(status_from_xy(xy))),
};
merge(&mut map, name, status);
}
map
}
fn merge(map: &mut HashMap<String, GitStatus>, name: String, status: GitStatus) {
map.entry(name)
.and_modify(|cur| {
if status.precedence() > cur.precedence() {
*cur = status;
}
})
.or_insert(status);
}
fn dir_signal(child: GitStatus) -> GitStatus {
match child {
GitStatus::Untracked => GitStatus::Untracked,
GitStatus::Conflict => GitStatus::Conflict,
_ => GitStatus::Modified,
}
}
fn status_from_xy(xy: &str) -> GitStatus {
let bytes = xy.as_bytes();
let x = bytes.first().copied().unwrap_or(b' ');
let y = bytes.get(1).copied().unwrap_or(b' ');
if xy == "??" {
return GitStatus::Untracked;
}
if is_conflict(x, y) {
return GitStatus::Conflict;
}
let letter = if y != b' ' { y } else { x };
match letter {
b'A' | b'C' => GitStatus::Added,
b'D' => GitStatus::Deleted,
b'R' => GitStatus::Renamed,
_ => GitStatus::Modified, }
}
fn is_conflict(x: u8, y: u8) -> bool {
x == b'U' || y == b'U' || (x == b'A' && y == b'A') || (x == b'D' && y == b'D')
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(xy: &str, path: &str) -> (String, String) {
(xy.to_string(), path.to_string())
}
#[test]
fn xy_maps_the_status_table() {
assert_eq!(status_from_xy("??"), GitStatus::Untracked);
assert_eq!(status_from_xy(" M"), GitStatus::Modified);
assert_eq!(status_from_xy("M "), GitStatus::Modified);
assert_eq!(status_from_xy("MM"), GitStatus::Modified);
assert_eq!(status_from_xy(" T"), GitStatus::Modified); assert_eq!(status_from_xy("A "), GitStatus::Added);
assert_eq!(status_from_xy("C "), GitStatus::Added); assert_eq!(status_from_xy(" D"), GitStatus::Deleted);
assert_eq!(status_from_xy("D "), GitStatus::Deleted);
assert_eq!(status_from_xy("R "), GitStatus::Renamed);
assert_eq!(status_from_xy("AM"), GitStatus::Modified);
}
#[test]
fn xy_detects_every_conflict_code() {
for code in ["DD", "AU", "UD", "UA", "DU", "AA", "UU"] {
assert_eq!(status_from_xy(code), GitStatus::Conflict, "code {code}");
}
}
#[test]
fn resolve_file_directly_in_dir_at_repo_root() {
let recs = [rec(" M", "README.md")];
let map = resolve(&recs, "");
assert_eq!(map.get("README.md"), Some(&GitStatus::Modified));
assert_eq!(map.len(), 1);
}
#[test]
fn resolve_untracked_file() {
let recs = [rec("??", "notes.txt")];
let map = resolve(&recs, "");
assert_eq!(map.get("notes.txt"), Some(&GitStatus::Untracked));
}
#[test]
fn resolve_change_in_subdir_aggregates_to_modified() {
let recs = [rec(" M", "src/dir/mod.rs")];
let map = resolve(&recs, "");
assert_eq!(map.get("src"), Some(&GitStatus::Modified));
assert!(!map.contains_key("mod.rs"));
}
#[test]
fn resolve_wholly_untracked_subdir_is_untracked() {
let recs = [rec("??", "sub/")];
let map = resolve(&recs, "");
assert_eq!(map.get("sub"), Some(&GitStatus::Untracked));
}
#[test]
fn resolve_precedence_modified_beats_untracked_in_a_dir() {
let recs = [rec("??", "pkg/new.rs"), rec(" M", "pkg/old.rs")];
let map = resolve(&recs, "");
assert_eq!(map.get("pkg"), Some(&GitStatus::Modified));
let recs_rev = [rec(" M", "pkg/old.rs"), rec("??", "pkg/new.rs")];
assert_eq!(
resolve(&recs_rev, "").get("pkg"),
Some(&GitStatus::Modified)
);
}
#[test]
fn resolve_conflict_in_subdir_bubbles_up() {
let recs = [rec(" M", "pkg/a.rs"), rec("UU", "pkg/b.rs")];
assert_eq!(resolve(&recs, "").get("pkg"), Some(&GitStatus::Conflict));
}
#[test]
fn resolve_honours_a_nested_prefix() {
let recs = [
rec("M ", "src/lib.rs"),
rec("??", "src/ui/new.rs"),
rec(" M", "docs/guide.md"), ];
let map = resolve(&recs, "src/");
assert_eq!(map.get("lib.rs"), Some(&GitStatus::Modified));
assert_eq!(map.get("ui"), Some(&GitStatus::Untracked));
assert!(
!map.contains_key("guide.md"),
"outside-prefix record leaked in"
);
assert_eq!(map.len(), 2);
}
#[test]
fn resolve_ignores_records_outside_the_prefix() {
let recs = [rec(" M", "other/file.rs")];
assert!(resolve(&recs, "src/").is_empty());
}
#[test]
fn parse_porcelain_z_handles_rename_two_field_record() {
let bytes = b"R new.rs\x00old.rs\x00 M other.rs\x00";
let recs = parse_porcelain_z(bytes);
assert_eq!(
recs,
vec![
("R ".to_string(), "new.rs".to_string()),
(" M".to_string(), "other.rs".to_string()),
]
);
}
#[test]
fn parse_porcelain_z_reads_simple_records() {
let bytes = b" M a.rs\x00?? b.rs\x00";
let recs = parse_porcelain_z(bytes);
assert_eq!(
recs,
vec![
(" M".to_string(), "a.rs".to_string()),
("??".to_string(), "b.rs".to_string()),
]
);
}
}