use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use std::time::SystemTime;
use f00_core::{Entry, GitStatus, Listing};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UntrackedMode {
#[default]
All,
No,
}
#[derive(Debug, Default, Clone)]
pub struct GitIndex {
statuses: HashMap<PathBuf, GitStatus>,
repo_root: Option<PathBuf>,
#[allow(dead_code)]
index_mtime: Option<SystemTime>,
#[allow(dead_code)]
untracked: UntrackedMode,
}
#[derive(Clone)]
struct CacheEntry {
index: GitIndex,
index_mtime: Option<SystemTime>,
untracked: UntrackedMode,
}
fn global_cache() -> &'static Mutex<HashMap<PathBuf, CacheEntry>> {
use std::sync::OnceLock;
static CACHE: OnceLock<Mutex<HashMap<PathBuf, CacheEntry>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn index_mtime(repo: &Path) -> Option<SystemTime> {
let index = repo.join(".git/index");
std::fs::metadata(index).and_then(|m| m.modified()).ok()
}
impl GitIndex {
pub fn empty() -> Self {
Self::default()
}
pub fn repo_root(&self) -> Option<&Path> {
self.repo_root.as_deref()
}
pub fn status_for(&self, path: &Path) -> GitStatus {
if let Some(st) = self.statuses.get(path) {
return *st;
}
if let Some(root) = &self.repo_root {
if let Ok(rel) = path.strip_prefix(root) {
if let Some(st) = self.statuses.get(rel) {
return *st;
}
}
}
if let Some(name) = path.file_name() {
for (k, v) in &self.statuses {
if k.file_name() == Some(name) && k.components().count() == 1 {
return *v;
}
}
}
GitStatus::Clean
}
pub fn discover(start: &Path) -> Self {
Self::discover_with(start, UntrackedMode::All)
}
pub fn discover_with(start: &Path, untracked: UntrackedMode) -> Self {
let root = match find_repo_root(start) {
Some(r) => r,
None => return Self::empty(),
};
let mtime = index_mtime(&root);
if let Ok(cache) = global_cache().lock() {
if let Some(hit) = cache.get(&root) {
if hit.untracked == untracked && hit.index_mtime == mtime {
return hit.index.clone();
}
}
}
let index = Self::load_fresh(&root, mtime, untracked);
if let Ok(mut cache) = global_cache().lock() {
cache.insert(
root.clone(),
CacheEntry {
index: index.clone(),
index_mtime: mtime,
untracked,
},
);
if cache.len() > 32 {
if let Some(k) = cache.keys().next().cloned() {
cache.remove(&k);
}
}
}
index
}
fn load_fresh(root: &Path, mtime: Option<SystemTime>, untracked: UntrackedMode) -> Self {
let uflag = match untracked {
UntrackedMode::All => "-uall",
UntrackedMode::No => "-uno",
};
let output = Command::new("git")
.args(["status", "--porcelain", "--ignored=no", uflag])
.current_dir(root)
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => {
return Self {
statuses: HashMap::new(),
repo_root: Some(root.to_path_buf()),
index_mtime: mtime,
untracked,
};
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut statuses = HashMap::new();
for line in stdout.lines() {
if line.len() < 3 {
continue;
}
let code = &line[..2];
let rest = line[3..].trim();
let path_str = if let Some((left, _right)) = rest.split_once(" -> ") {
left.trim()
} else {
rest
};
let path_str = path_str.trim_matches('"');
let rel = PathBuf::from(path_str);
let st = parse_porcelain_code(code);
statuses.insert(rel.clone(), st);
statuses.insert(root.join(&rel), st);
}
Self {
statuses,
repo_root: Some(root.to_path_buf()),
index_mtime: mtime,
untracked,
}
}
}
fn parse_porcelain_code(code: &str) -> GitStatus {
let chars: Vec<char> = code.chars().collect();
let x = chars.first().copied().unwrap_or(' ');
let y = chars.get(1).copied().unwrap_or(' ');
for c in [y, x] {
match c {
'M' => return GitStatus::Modified,
'A' => return GitStatus::Added,
'D' => return GitStatus::Deleted,
'R' | 'C' => return GitStatus::Renamed,
'U' => return GitStatus::Conflicted,
'?' => return GitStatus::Untracked,
'!' => return GitStatus::Ignored,
_ => {}
}
}
GitStatus::Unknown
}
pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
let start = if start.is_file() {
start.parent()?.to_path_buf()
} else {
start.to_path_buf()
};
let abs = std::fs::canonicalize(&start).unwrap_or(start);
let mut cur = abs.as_path();
loop {
let git = cur.join(".git");
if git.exists() {
return Some(cur.to_path_buf());
}
cur = cur.parent()?;
}
}
pub fn annotate_listing(listing: &mut Listing, index: &GitIndex) {
for entry in &mut listing.entries {
if entry.is_dir_header {
continue;
}
entry.git_status = index.status_for(&entry.path);
}
}
pub fn annotate_listings(listings: &mut [Listing]) {
use std::collections::HashMap;
let mut by_repo: HashMap<PathBuf, GitIndex> = HashMap::new();
for listing in listings.iter_mut() {
let recursive = listing
.entries
.iter()
.any(|e| e.depth > 0 || e.is_dir_header);
let mode = if recursive {
UntrackedMode::No
} else {
UntrackedMode::All
};
let key = find_repo_root(&listing.root).unwrap_or_else(|| listing.root.clone());
let index = by_repo
.entry(key)
.or_insert_with(|| GitIndex::discover_with(&listing.root, mode));
annotate_listing(listing, index);
}
}
pub fn annotate_entries(entries: &mut [Entry], start: &Path) {
let recursive = entries.iter().any(|e| e.depth > 0);
let mode = if recursive {
UntrackedMode::No
} else {
UntrackedMode::All
};
let index = GitIndex::discover_with(start, mode);
for entry in entries {
if !entry.is_dir_header {
entry.git_status = index.status_for(&entry.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_modified() {
assert_eq!(parse_porcelain_code(" M"), GitStatus::Modified);
assert_eq!(parse_porcelain_code("M "), GitStatus::Modified);
assert_eq!(parse_porcelain_code("MM"), GitStatus::Modified);
}
#[test]
fn parse_untracked() {
assert_eq!(parse_porcelain_code("??"), GitStatus::Untracked);
}
#[test]
fn parse_added() {
assert_eq!(parse_porcelain_code("A "), GitStatus::Added);
}
#[test]
fn find_repo_from_workspace() {
let base = std::env::temp_dir().join(format!(
"f00-git-find-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(base.join("nested/deep")).unwrap();
std::fs::create_dir_all(base.join(".git")).unwrap();
let found = find_repo_root(&base.join("nested/deep")).expect("repo root");
let found_c = std::fs::canonicalize(&found).unwrap_or(found);
let base_c = std::fs::canonicalize(&base).unwrap_or(base.clone());
assert_eq!(found_c, base_c);
let _ = std::fs::remove_dir_all(&base);
let _ = find_repo_root(Path::new(env!("CARGO_MANIFEST_DIR")));
}
#[test]
fn cache_returns_same_for_second_discover() {
let base = std::env::temp_dir().join(format!(
"f00-git-cache-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(base.join(".git")).unwrap();
let a = GitIndex::discover_with(&base, UntrackedMode::No);
let b = GitIndex::discover_with(&base, UntrackedMode::No);
assert_eq!(a.repo_root(), b.repo_root());
let _ = std::fs::remove_dir_all(&base);
}
}