use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Change {
Ignored,
Untracked,
Added,
Modified,
Deleted,
Conflicted,
}
impl Change {
pub fn letter(self) -> char {
match self {
Change::Ignored => '!',
Change::Untracked => '?',
Change::Added => 'A',
Change::Modified => 'M',
Change::Deleted => 'D',
Change::Conflicted => 'U',
}
}
pub fn glyph(self) -> &'static str {
match self {
Change::Ignored => "\u{f2d1}",
Change::Untracked => "\u{f128}",
Change::Added => "\u{f457}",
Change::Modified => "\u{f040}",
Change::Deleted => "\u{f458}",
Change::Conflicted => "\u{f071}",
}
}
pub fn style(self) -> &'static str {
match self {
Change::Ignored => "bright_black",
Change::Untracked => "magenta",
Change::Added => "green",
Change::Modified => "yellow",
Change::Deleted => "red",
Change::Conflicted => "yellow",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FileStatus {
pub index: char,
pub worktree: char,
}
impl FileStatus {
pub fn change(self) -> Change {
let (x, y) = (self.index, self.worktree);
match (x, y) {
('!', '!') => Change::Ignored,
('?', '?') => Change::Untracked,
('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D') => Change::Conflicted,
('D', _) | (_, 'D') => Change::Deleted,
(_, 'M') | (_, 'T') | ('M', _) | ('T', _) => Change::Modified,
_ => Change::Added,
}
}
pub fn staged(self) -> bool {
!matches!(self.index, ' ' | '?' | '!')
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GitStatus {
pub root: PathBuf,
pub files: HashMap<PathBuf, FileStatus>,
dirs: HashMap<PathBuf, Change>,
}
impl GitStatus {
pub fn new(root: PathBuf, files: HashMap<PathBuf, FileStatus>) -> GitStatus {
let mut dirs: HashMap<PathBuf, Change> = HashMap::new();
for (path, status) in &files {
let change = status.change();
if change == Change::Ignored {
continue;
}
for dir in path.ancestors().skip(1) {
if !dir.starts_with(&root) {
break;
}
let slot = dirs.entry(dir.to_path_buf()).or_insert(change);
*slot = (*slot).max(change);
}
}
GitStatus { root, files, dirs }
}
pub fn of(&self, path: &Path) -> Option<Change> {
if let Some(status) = self.files.get(path) {
return Some(status.change());
}
if let Some(change) = self.dirs.get(path) {
return Some(*change);
}
path.ancestors()
.skip(1)
.take_while(|dir| dir.starts_with(&self.root))
.find_map(|dir| {
self.files
.get(dir)
.map(|s| s.change())
.filter(|c| matches!(c, Change::Untracked | Change::Ignored))
})
}
}
pub fn status(dir: &Path) -> io::Result<Option<GitStatus>> {
let Some(root) = toplevel(dir)? else {
return Ok(None);
};
let output = git(dir)
.args([
"status",
"--porcelain=v1",
"-z",
"--ignored=matching",
"--untracked-files=normal",
])
.output()?;
if !output.status.success() {
return Err(io::Error::other(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
let files = parse_porcelain(&root, &output.stdout);
Ok(Some(GitStatus::new(root, files)))
}
pub fn toplevel(dir: &Path) -> io::Result<Option<PathBuf>> {
let output = match git(dir).args(["rev-parse", "--show-toplevel"]).output() {
Ok(output) => output,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
if !output.status.success() {
return Ok(None);
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok((!text.is_empty()).then(|| PathBuf::from(text)))
}
fn git(dir: &Path) -> Command {
let mut command = Command::new("git");
command
.arg("-C")
.arg(dir)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null());
command
}
pub fn parse_porcelain(root: &Path, output: &[u8]) -> HashMap<PathBuf, FileStatus> {
let mut files = HashMap::new();
let mut records = output.split(|b| *b == 0).filter(|r| !r.is_empty());
while let Some(record) = records.next() {
if record.len() < 4 || record[2] != b' ' {
continue;
}
let status = FileStatus {
index: record[0] as char,
worktree: record[1] as char,
};
if matches!(status.index, 'R' | 'C') {
records.next();
}
let path = String::from_utf8_lossy(&record[3..]);
let path = path.trim_end_matches('/');
files.insert(root.join(path), status);
}
files
}
#[cfg(test)]
mod tests {
use super::*;
fn st(x: char, y: char) -> FileStatus {
FileStatus {
index: x,
worktree: y,
}
}
#[test]
fn letters_come_to_changes() {
assert_eq!(st(' ', 'M').change(), Change::Modified);
assert_eq!(st('M', ' ').change(), Change::Modified);
assert_eq!(st('A', 'M').change(), Change::Modified);
assert_eq!(st('A', ' ').change(), Change::Added);
assert_eq!(st('R', ' ').change(), Change::Added);
assert_eq!(st(' ', 'D').change(), Change::Deleted);
assert_eq!(st('U', 'U').change(), Change::Conflicted);
assert_eq!(st('A', 'A').change(), Change::Conflicted);
assert_eq!(st('?', '?').change(), Change::Untracked);
assert_eq!(st('!', '!').change(), Change::Ignored);
assert!(st('M', ' ').staged());
assert!(!st('?', '?').staged());
}
#[test]
fn directories_take_the_greatest_change_inside() {
let root = Path::new("/r");
let files = parse_porcelain(
root,
b" M a/x.rs\0A a/b/y.rs\0 D a/b/z.rs\0!! target/\0?? tmp/\0",
);
let status = GitStatus::new(root.to_path_buf(), files);
assert_eq!(status.of(Path::new("/r/a/x.rs")), Some(Change::Modified));
assert_eq!(status.of(Path::new("/r/a/b")), Some(Change::Deleted));
assert_eq!(status.of(Path::new("/r/a")), Some(Change::Deleted));
assert_eq!(status.of(Path::new("/r")), Some(Change::Deleted));
assert_eq!(
status.of(Path::new("/r/target/debug/x")),
Some(Change::Ignored)
);
assert_eq!(
status.of(Path::new("/r/tmp/notes.txt")),
Some(Change::Untracked)
);
assert_eq!(status.of(Path::new("/r/clean.rs")), None);
assert_eq!(status.of(Path::new("/elsewhere")), None);
}
#[test]
fn a_real_repository() {
let dir = std::env::temp_dir().join(format!("rich-ext-git-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
};
if !run(&["init", "-q"]).is_ok_and(|o| o.status.success()) {
return;
}
std::fs::write(dir.join("kept.txt"), "a").unwrap();
std::fs::write(dir.join(".gitignore"), "*.log\n").unwrap();
run(&["add", "."]).unwrap();
run(&["commit", "-qm", "first"]).unwrap();
std::fs::write(dir.join("kept.txt"), "b").unwrap();
std::fs::write(dir.join("new.txt"), "c").unwrap();
std::fs::write(dir.join("debug.log"), "d").unwrap();
let status = status(&dir).unwrap().expect("a repository");
let root = &status.root;
assert_eq!(status.of(&root.join("kept.txt")), Some(Change::Modified));
assert_eq!(status.of(&root.join("new.txt")), Some(Change::Untracked));
assert_eq!(status.of(&root.join("debug.log")), Some(Change::Ignored));
assert_eq!(status.of(&root.join(".gitignore")), None);
let outside = std::env::temp_dir();
if toplevel(&outside).unwrap().is_none() {
assert!(super::status(&outside).unwrap().is_none());
}
std::fs::remove_dir_all(&dir).ok();
}
}