use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Mark {
Ignored,
Untracked,
Staged,
Modified,
Conflict,
}
#[derive(Debug, Default)]
pub struct Status {
named: HashMap<OsString, Mark>,
inside: HashMap<OsString, Mark>,
whole: Option<Mark>,
}
impl Status {
pub fn mark(&self, name: &OsStr, is_dir: bool) -> Option<Mark> {
if name == ".." {
return None;
}
let name = composed(name);
let name: &OsStr = &name;
if let Some(&mark) = self.named.get(name) {
return Some(mark);
}
if is_dir && let Some(&mark) = self.inside.get(name) {
return Some(mark);
}
Path::new(name).ancestors().skip(1).filter(|dir| !dir.as_os_str().is_empty()).find_map(|dir| self.named.get(dir.as_os_str()).copied()).or(self.whole)
}
fn add(&mut self, path: PathBuf, mark: Mark) {
let path = PathBuf::from(composed(path.as_os_str()).into_owned());
if mark != Mark::Ignored {
for dir in path.ancestors().skip(1).filter(|dir| !dir.as_os_str().is_empty()) {
let held = self.inside.entry(dir.as_os_str().to_os_string()).or_insert(mark);
*held = (*held).max(mark);
}
}
let held = self.named.entry(path.into_os_string()).or_insert(mark);
*held = (*held).max(mark);
}
}
pub fn status(dir: &Path) -> Result<Status, String> {
let prefix = git_bytes(dir, &["rev-parse", "--show-prefix"])?;
let prefix = prefix.strip_suffix(b"\n").unwrap_or(&prefix);
let prefix = prefix.strip_suffix(b"\r").unwrap_or(prefix);
let output = git_bytes(dir, &["status", "--porcelain=v1", "-z", "--ignored", "--untracked-files=normal", "--", "."])?;
let mut status = Status::default();
let mut records = output.split(|&byte| byte == 0);
while let Some(record) = records.next() {
if record.len() < 4 {
continue;
}
let (x, y) = (record[0], record[1]);
if matches!(x, b'R' | b'C') {
records.next();
}
let mark = match (x, y) {
(b'?', b'?') => Mark::Untracked,
(b'!', b'!') => Mark::Ignored,
(b'U', _) | (_, b'U') | (b'A', b'A') | (b'D', b'D') => Mark::Conflict,
(_, b' ') => Mark::Staged,
_ => Mark::Modified,
};
if record[3..].ends_with(b"/") && prefix.starts_with(&record[3..]) {
status.whole = Some(mark);
continue;
}
let Some(path) = record[3..].strip_prefix(prefix) else {
continue;
};
let path = path.strip_suffix(b"/").unwrap_or(path);
if path.is_empty() {
continue;
}
status.add(from_git(path), mark);
}
Ok(status)
}
fn composed(name: &OsStr) -> Cow<'_, OsStr> {
use unicode_normalization::{UnicodeNormalization, is_nfc};
match name.to_str() {
Some(text) if !is_nfc(text) => Cow::Owned(OsString::from(text.nfc().collect::<String>())),
_ => Cow::Borrowed(name),
}
}
fn from_git(path: &[u8]) -> PathBuf {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
PathBuf::from(OsStr::from_bytes(path))
}
#[cfg(not(unix))]
{
String::from_utf8_lossy(path).split('/').collect()
}
}
pub fn branches(dir: &Path) -> Result<(Vec<String>, Option<usize>), String> {
let output = git(dir, &["for-each-ref", "--format=%(HEAD)%00%(refname:short)", "refs/heads/"])?;
let mut names = Vec::new();
let mut current = None;
for line in output.lines() {
let Some((head, name)) = line.split_once('\0') else {
continue;
};
if head == "*" {
current = Some(names.len());
}
names.push(name.to_string());
}
Ok((names, current))
}
pub fn switch(dir: &Path, branch: &str) -> Result<(), String> {
git(dir, &["switch", "--no-guess", branch]).map(drop)
}
fn git(dir: &Path, arguments: &[&str]) -> Result<String, String> {
git_bytes(dir, arguments).map(|output| String::from_utf8_lossy(&output).into_owned())
}
fn git_bytes(dir: &Path, arguments: &[&str]) -> Result<Vec<u8>, String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(arguments)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.stdin(Stdio::null())
.output()
.map_err(|e| t!("Cannot run git: {e}", e))?;
if output.status.success() {
return Ok(output.stdout);
}
let said = String::from_utf8_lossy(&output.stderr).split_whitespace().collect::<Vec<_>>().join(" ");
let said = said.strip_prefix("error: ").or_else(|| said.strip_prefix("fatal: ")).unwrap_or(&said);
let message: String = said.chars().map(|c| if c.is_control() { '.' } else { c }).collect();
Err(if message.is_empty() { format!("git {} failed", arguments[0]) } else { message })
}
#[cfg(test)]
mod tests {
use super::*;
fn repository(name: &str) -> Option<std::path::PathBuf> {
let dir = std::env::temp_dir().join(format!("fm84-git-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |arguments: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&dir)
.args(["-c", "user.name=fm84", "-c", "user.email=fm84@localhost", "-c", "commit.gpgsign=false"])
.args(arguments)
.output()
.is_ok_and(|output| output.status.success())
};
if !run(&["init", "--quiet", "--initial-branch=main"]) {
let _ = std::fs::remove_dir_all(&dir);
return None;
}
std::fs::write(dir.join("file.txt"), "main\n").unwrap();
assert!(run(&["add", "file.txt"]));
assert!(run(&["commit", "--quiet", "-m", "first"]));
assert!(run(&["branch", "feature/tabs"]));
Some(dir)
}
#[test]
fn the_branches_and_which_is_checked_out_and_changing_it() {
let Some(dir) = repository("switch") else {
return;
};
assert_eq!(branches(&dir).unwrap(), (vec!["feature/tabs".to_string(), "main".to_string()], Some(1)));
std::fs::create_dir_all(dir.join("sub")).unwrap();
assert_eq!(branches(&dir.join("sub")).unwrap().1, Some(1));
switch(&dir, "feature/tabs").unwrap();
assert_eq!(branches(&dir).unwrap().1, Some(0));
assert_eq!(crate::fs_ops::git_branch(&dir).as_deref(), Some("feature/tabs"));
let refused = switch(&dir, "nowhere").unwrap_err();
assert!(refused.contains("nowhere"), "{refused}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn what_has_changed_file_by_file() {
let Some(dir) = repository("status") else {
return;
};
let run = |arguments: &[&str]| assert!(Command::new("git").arg("-C").arg(&dir).args(arguments).output().unwrap().status.success());
std::fs::create_dir_all(dir.join("src/deep")).unwrap();
std::fs::create_dir_all(dir.join("build")).unwrap();
std::fs::create_dir_all(dir.join("fresh")).unwrap();
std::fs::write(dir.join("src/deep/lib.rs"), "one").unwrap();
std::fs::write(dir.join("src/main.rs"), "one").unwrap();
run(&["add", "src"]);
run(&["-c", "user.name=fm84", "-c", "user.email=fm84@localhost", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "src"]);
std::fs::write(dir.join("file.txt"), "changed\n").unwrap();
std::fs::write(dir.join("src/deep/lib.rs"), "two").unwrap();
std::fs::write(dir.join("src/main.rs"), "two").unwrap();
run(&["add", "src/main.rs"]);
std::fs::write(dir.join("new.txt"), "").unwrap();
std::fs::write(dir.join("fresh/a.txt"), "").unwrap();
std::fs::write(dir.join(".gitignore"), "build/\n").unwrap();
std::fs::write(dir.join("build/out"), "").unwrap();
let status = status(&dir).unwrap();
let mark = |name: &str, is_dir: bool| status.mark(OsStr::new(name), is_dir);
assert_eq!(mark("file.txt", false), Some(Mark::Modified));
assert_eq!(mark("new.txt", false), Some(Mark::Untracked));
assert_eq!(mark("build", true), Some(Mark::Ignored));
assert_eq!(mark("fresh", true), Some(Mark::Untracked));
assert_eq!(mark("src", true), Some(Mark::Modified));
assert_eq!(mark(".git", true), None);
let sep = std::path::MAIN_SEPARATOR;
assert_eq!(mark(&format!("src{sep}main.rs"), false), Some(Mark::Staged));
assert_eq!(mark(&format!("src{sep}deep{sep}lib.rs"), false), Some(Mark::Modified));
assert_eq!(mark(&format!("fresh{sep}a.txt"), false), Some(Mark::Untracked));
assert_eq!(mark(&format!("src{sep}deep{sep}other.rs"), false), None);
let below = super::status(&dir.join("src")).unwrap();
assert_eq!(below.mark(OsStr::new("main.rs"), false), Some(Mark::Staged));
assert_eq!(below.mark(OsStr::new("deep"), true), Some(Mark::Modified));
assert_eq!(below.mark(OsStr::new("file.txt"), false), None);
std::fs::create_dir_all(dir.join("fresh/deeper")).unwrap();
std::fs::write(dir.join("fresh/deeper/b.txt"), "").unwrap();
std::fs::write(dir.join("fresh/x.log"), "").unwrap();
std::fs::write(dir.join(".gitignore"), "build/\n*.log\n").unwrap();
for (inside, mark) in [("fresh", Mark::Untracked), ("fresh/deeper", Mark::Untracked), ("build", Mark::Ignored)] {
let status = super::status(&dir.join(inside)).unwrap();
assert_eq!(status.mark(OsStr::new("a.txt"), false), Some(mark), "{inside}");
assert_eq!(status.mark(OsStr::new(".."), true), None);
}
let fresh = super::status(&dir.join("fresh")).unwrap();
assert_eq!(fresh.mark(OsStr::new("x.log"), false), Some(Mark::Ignored));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_accent_matches_composed_or_not() {
let (composed, decomposed) = ("caf\u{e9}.txt", "cafe\u{301}.txt");
let sep = std::path::MAIN_SEPARATOR;
for (from_git, on_disk) in [(composed, decomposed), (decomposed, composed)] {
let mut status = Status::default();
status.add(PathBuf::from(format!("r\u{e9}sum\u{e9}{sep}{from_git}")), Mark::Modified);
assert_eq!(status.mark(OsStr::new(&format!("re\u{301}sume\u{301}{sep}{on_disk}")), false), Some(Mark::Modified));
assert_eq!(status.mark(OsStr::new("re\u{301}sume\u{301}"), true), Some(Mark::Modified));
}
}
#[test]
fn outside_a_repository_is_an_error() {
let dir = std::env::temp_dir().join(format!("fm84-git-none-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
if crate::fs_ops::git_branch(&dir).is_none() {
assert!(branches(&dir).is_err());
}
std::fs::remove_dir_all(&dir).unwrap();
}
}