pub mod bzr;
pub mod git;
pub mod mercurial;
use std::path::{Path, PathBuf};
pub fn generate_directories<P: AsRef<Path>>(path: P) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut path = path.as_ref().to_path_buf();
if path.is_dir() {
out.push(path.clone());
}
loop {
if is_mount(&path) {
break;
}
let old_path = path.clone();
path = match path.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
if path == old_path || path.as_os_str().is_empty() {
break;
}
out.push(path.clone());
}
out
}
#[cfg(unix)]
fn is_mount(path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let m1 = match path.metadata() {
Ok(m) => m,
Err(_) => return false,
};
let parent = match path.parent() {
Some(p) => p,
None => return true,
};
let m2 = match parent.metadata() {
Ok(m) => m,
Err(_) => return false,
};
m1.dev() != m2.dev()
}
#[cfg(not(unix))]
fn is_mount(_path: &Path) -> bool {
false
}
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
pub fn branch_name_cache() -> &'static Mutex<HashMap<String, String>> {
static M: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn branch_lock() -> &'static Mutex<()> {
static M: OnceLock<Mutex<()>> = OnceLock::new();
M.get_or_init(|| Mutex::new(()))
}
pub fn file_status_lock() -> &'static Mutex<()> {
static M: OnceLock<Mutex<()>> = OnceLock::new();
M.get_or_init(|| Mutex::new(()))
}
pub struct FileStatusCache {
pub statuses: HashMap<String, String>,
pub dirstate_map: HashMap<String, HashSet<String>>,
pub ignore_map: HashMap<String, HashSet<String>>,
pub keypath_ignore_map: HashMap<String, HashSet<String>>,
}
impl Default for FileStatusCache {
fn default() -> Self {
Self::new()
}
}
impl FileStatusCache {
pub fn new() -> Self {
Self {
statuses: HashMap::new(),
dirstate_map: HashMap::new(),
ignore_map: HashMap::new(),
keypath_ignore_map: HashMap::new(),
}
}
pub fn update_maps(
&mut self,
keypath: &str,
directory: &str,
dirstate_file: &str,
ignore_file_name: &str,
extra_ignore_files: &[String],
) {
let mut ignore_files: HashSet<String> = HashSet::new();
let mut parent = std::path::PathBuf::from(keypath);
loop {
if parent.to_string_lossy() == directory {
break;
}
let nparent = match parent.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
if nparent == parent {
break;
}
parent = nparent;
let mut ignore = parent.clone();
ignore.push(ignore_file_name);
ignore_files.insert(ignore.to_string_lossy().to_string());
}
for f in extra_ignore_files {
ignore_files.insert(f.clone());
}
self.keypath_ignore_map
.insert(keypath.to_string(), ignore_files.clone());
for ignf in &ignore_files {
self.ignore_map
.entry(ignf.clone())
.or_default()
.insert(keypath.to_string());
}
self.dirstate_map
.entry(dirstate_file.to_string())
.or_default()
.insert(keypath.to_string());
}
pub fn invalidate(&mut self, dirstate_file: Option<&str>, ignore_file: Option<&str>) {
if let Some(d) = dirstate_file {
if let Some(keypaths) = self.dirstate_map.get(d).cloned() {
for keypath in keypaths {
self.statuses.remove(&keypath);
}
}
}
if let Some(i) = ignore_file {
if let Some(keypaths) = self.ignore_map.get(i).cloned() {
for keypath in keypaths {
self.statuses.remove(&keypath);
}
}
}
}
pub fn ignore_files(&self, keypath: &str) -> HashSet<String> {
self.keypath_ignore_map
.get(keypath)
.cloned()
.unwrap_or_default()
}
}
pub fn file_status_cache() -> &'static Mutex<FileStatusCache> {
static M: OnceLock<Mutex<FileStatusCache>> = OnceLock::new();
M.get_or_init(|| Mutex::new(FileStatusCache::new()))
}
pub struct TreeStatusCache {
pub statuses: HashMap<String, Option<String>>,
}
impl Default for TreeStatusCache {
fn default() -> Self {
Self::new()
}
}
impl TreeStatusCache {
pub fn new() -> Self {
Self {
statuses: HashMap::new(),
}
}
pub fn cache_and_get<F>(&mut self, key: &str, status_fn: F) -> Option<String>
where
F: FnOnce() -> Option<String>,
{
if let Some(v) = self.statuses.get(key) {
return v.clone();
}
let ans = status_fn();
self.statuses.insert(key.to_string(), ans.clone());
ans
}
pub fn call<F, S>(
&mut self,
repo_directory: &str,
tw_changed_fn: F,
status_fn: S,
) -> Option<String>
where
F: FnOnce() -> Result<bool, std::io::Error>,
S: FnOnce() -> Option<String>,
{
match tw_changed_fn() {
Ok(true) => {
self.statuses.remove(repo_directory);
}
Ok(false) => {}
Err(_) => {}
}
self.cache_and_get(repo_directory, status_fn)
}
}
pub fn tree_status_cache() -> &'static Mutex<TreeStatusCache> {
static M: OnceLock<Mutex<TreeStatusCache>> = OnceLock::new();
M.get_or_init(|| Mutex::new(TreeStatusCache::new()))
}
pub fn get_branch_name<F>(config_file: &str, changed: bool, mut get_func: F) -> String
where
F: FnMut() -> String,
{
let _g = branch_lock().lock().unwrap_or_else(|e| e.into_inner());
let mut cache = branch_name_cache()
.lock()
.unwrap_or_else(|e| e.into_inner());
if changed {
let fresh = get_func();
cache.insert(config_file.to_string(), fresh);
} else if !cache.contains_key(config_file) {
let fresh = get_func();
cache.insert(config_file.to_string(), fresh);
}
cache[config_file].clone()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VcsCheck {
Exists,
IsDir,
}
impl VcsCheck {
pub fn matches<P: AsRef<Path>>(self, path: P) -> bool {
let p = path.as_ref();
match self {
VcsCheck::Exists => p.exists(),
VcsCheck::IsDir => p.is_dir(),
}
}
}
pub fn vcs_props() -> &'static [(&'static str, &'static str, VcsCheck)] {
&[
("git", ".git", VcsCheck::Exists),
("mercurial", ".hg", VcsCheck::IsDir),
("bzr", ".bzr", VcsCheck::IsDir),
]
}
pub fn guess_vcs_at_directory<P: AsRef<Path>>(directory: P) -> Option<(&'static str, PathBuf)> {
let directory = directory.as_ref();
for (vcs, vcs_dir, check) in vcs_props() {
let repo_dir = directory.join(vcs_dir);
if check.matches(&repo_dir) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if repo_dir.is_dir() {
if let Ok(meta) = repo_dir.metadata() {
if meta.permissions().mode() & 0o111 == 0 {
continue;
}
}
}
}
return Some((vcs, repo_dir));
}
}
None
}
pub fn guess<P: AsRef<Path>>(path: P) -> Option<(&'static str, PathBuf)> {
let dirs = generate_directories(path);
for directory in &dirs {
if let Some(found) = guess_vcs_at_directory(directory) {
return Some(found);
}
}
None
}
pub fn file_watcher_initialised() -> bool {
static W: OnceLock<()> = OnceLock::new();
W.get().is_some()
}
pub fn file_watcher<F>(slot: &mut Option<u64>, create_watcher: F) -> u64
where
F: FnOnce() -> u64,
{
if slot.is_none() {
*slot = Some(create_watcher());
}
slot.unwrap()
}
pub fn branch_watcher<F>(slot: &mut Option<u64>, create_watcher: F) -> u64
where
F: FnOnce() -> u64,
{
if slot.is_none() {
*slot = Some(create_watcher());
}
slot.unwrap()
}
pub fn get_file_status<G>(
directory: &std::path::Path,
_dirstate_file: Option<&std::path::Path>,
file_path: Option<&std::path::Path>,
_ignore_file_name: Option<&str>,
get_func: G,
_extra_ignore_files: &[std::path::PathBuf],
) -> Option<String>
where
G: FnOnce(&std::path::Path, Option<&std::path::Path>) -> Option<String>,
{
get_func(directory, file_path)
}
pub fn get_fallback_create_watcher() -> impl FnOnce() -> u64 {
|| 0_u64
}
pub fn debug(_path: &std::path::Path) -> Option<(String, String)> {
None
}
pub fn branch_watcher_initialised() -> bool {
static W: OnceLock<()> = OnceLock::new();
W.get().is_some()
}
pub fn tree_status<F, S>(repo_directory: &str, tw_changed_fn: F, status_fn: S) -> Option<String>
where
F: FnOnce() -> Result<bool, std::io::Error>,
S: FnOnce() -> Option<String>,
{
let mut cache = tree_status_cache()
.lock()
.unwrap_or_else(|e| e.into_inner());
cache.call(repo_directory, tw_changed_fn, status_fn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_directories_walks_up_from_cwd() {
let cwd = std::env::current_dir().unwrap();
let dirs = generate_directories(&cwd);
assert_eq!(dirs[0], cwd);
assert!(dirs.len() >= 2);
for w in dirs.windows(2) {
assert!(
w[0].starts_with(&w[1]) || w[1].as_os_str().is_empty(),
"expected {:?} to be a child of {:?}",
w[0],
w[1]
);
}
}
#[test]
fn generate_directories_terminates_at_root() {
let root = PathBuf::from("/");
let dirs = generate_directories(&root);
assert!(dirs.len() <= 2);
}
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
macro_rules! lock_globals {
() => {{
TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}};
}
fn reset_caches() {
branch_name_cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
let mut fsc = file_status_cache()
.lock()
.unwrap_or_else(|e| e.into_inner());
fsc.statuses.clear();
fsc.dirstate_map.clear();
fsc.ignore_map.clear();
fsc.keypath_ignore_map.clear();
tree_status_cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.statuses
.clear();
}
#[test]
fn file_status_cache_new_starts_empty() {
let c = FileStatusCache::new();
assert!(c.statuses.is_empty());
assert!(c.dirstate_map.is_empty());
assert!(c.ignore_map.is_empty());
assert!(c.keypath_ignore_map.is_empty());
}
#[test]
fn file_status_cache_update_maps_registers_ignore_files() {
let mut c = FileStatusCache::new();
c.update_maps(
"/repo/src/main.rs",
"/repo",
"/repo/.git/index",
".gitignore",
&[],
);
let ignores = c.ignore_files("/repo/src/main.rs");
assert!(!ignores.is_empty());
assert!(c.dirstate_map.contains_key("/repo/.git/index"));
assert!(c.dirstate_map["/repo/.git/index"].contains("/repo/src/main.rs"));
}
#[test]
fn file_status_cache_invalidate_clears_dirstate_dependents() {
let mut c = FileStatusCache::new();
c.update_maps(
"/repo/main.rs",
"/repo",
"/repo/.git/index",
".gitignore",
&[],
);
c.statuses
.insert("/repo/main.rs".to_string(), "M".to_string());
c.invalidate(Some("/repo/.git/index"), None);
assert!(!c.statuses.contains_key("/repo/main.rs"));
}
#[test]
fn file_status_cache_invalidate_clears_ignore_dependents() {
let mut c = FileStatusCache::new();
c.update_maps(
"/repo/main.rs",
"/repo",
"/repo/.git/index",
".gitignore",
&[],
);
c.statuses
.insert("/repo/main.rs".to_string(), "M".to_string());
c.invalidate(None, Some("/repo/.gitignore"));
assert!(!c.statuses.contains_key("/repo/main.rs"));
}
#[test]
fn file_status_cache_extra_ignore_files_are_included() {
let mut c = FileStatusCache::new();
c.update_maps(
"/repo/x.txt",
"/repo",
"/repo/.git/index",
".gitignore",
&["/repo/.git/info/exclude".to_string()],
);
let ignores = c.ignore_files("/repo/x.txt");
assert!(ignores.contains("/repo/.git/info/exclude"));
}
#[test]
fn tree_status_cache_caches_status_on_first_call() {
let mut c = TreeStatusCache::new();
let mut call_count = 0;
let r = c.cache_and_get("/repo", || {
call_count += 1;
Some("D ".to_string())
});
assert_eq!(r, Some("D ".to_string()));
let r2 = c.cache_and_get("/repo", || {
call_count += 1;
Some("DU".to_string())
});
assert_eq!(r2, Some("D ".to_string()));
assert_eq!(call_count, 1);
}
#[test]
fn tree_status_cache_call_invalidates_on_watcher_change() {
let mut c = TreeStatusCache::new();
c.statuses
.insert("/repo".to_string(), Some("D ".to_string()));
let r = c.call("/repo", || Ok(true), || Some(" U".to_string()));
assert_eq!(r, Some(" U".to_string()));
}
#[test]
fn tree_status_cache_call_uses_cache_when_unchanged() {
let mut c = TreeStatusCache::new();
c.statuses
.insert("/repo".to_string(), Some("D ".to_string()));
let r = c.call("/repo", || Ok(false), || Some(" U".to_string()));
assert_eq!(r, Some("D ".to_string()));
}
#[test]
fn tree_status_cache_call_swallows_os_error_and_uses_cache() {
let mut c = TreeStatusCache::new();
c.statuses
.insert("/repo".to_string(), Some("D ".to_string()));
let r = c.call(
"/repo",
|| Err(std::io::Error::other("x")),
|| Some(" U".to_string()),
);
assert_eq!(r, Some("D ".to_string()));
}
#[test]
fn get_branch_name_caches_within_process() {
let _g = lock_globals!();
reset_caches();
let mut call_count = 0;
let r1 = get_branch_name("/repo/.git/HEAD", false, || {
call_count += 1;
"main".to_string()
});
assert_eq!(r1, "main");
let r2 = get_branch_name("/repo/.git/HEAD", false, || {
call_count += 1;
"other".to_string()
});
assert_eq!(r2, "main");
assert_eq!(call_count, 1);
}
#[test]
fn get_branch_name_changed_flag_refreshes_cache() {
let _g = lock_globals!();
reset_caches();
let _r1 = get_branch_name("/repo/.git/HEAD", false, || "main".to_string());
let r2 = get_branch_name("/repo/.git/HEAD", true, || "develop".to_string());
assert_eq!(r2, "develop");
}
#[test]
fn tree_status_delegates_to_cache() {
let _g = lock_globals!();
reset_caches();
let r = tree_status("/repo", || Ok(false), || Some("D ".to_string()));
assert_eq!(r, Some("D ".to_string()));
let r2 = tree_status("/repo", || Ok(false), || Some(" U".to_string()));
assert_eq!(r2, Some("D ".to_string()));
}
#[test]
fn vcs_props_contains_three_entries() {
let props = vcs_props();
assert_eq!(props.len(), 3);
let names: Vec<&str> = props.iter().map(|(name, _, _)| *name).collect();
assert!(names.contains(&"git"));
assert!(names.contains(&"mercurial"));
assert!(names.contains(&"bzr"));
}
#[test]
fn vcs_props_git_uses_exists_check() {
let props = vcs_props();
let git = props.iter().find(|(name, _, _)| *name == "git").unwrap();
assert_eq!(git.1, ".git");
assert_eq!(git.2, VcsCheck::Exists);
}
#[test]
fn vcs_props_mercurial_uses_isdir_check() {
let props = vcs_props();
let hg = props
.iter()
.find(|(name, _, _)| *name == "mercurial")
.unwrap();
assert_eq!(hg.1, ".hg");
assert_eq!(hg.2, VcsCheck::IsDir);
}
#[test]
fn vcs_check_exists_matches_existing_path() {
let cwd = std::env::current_dir().unwrap();
assert!(VcsCheck::Exists.matches(&cwd));
}
#[test]
fn vcs_check_isdir_only_matches_directories() {
let cwd = std::env::current_dir().unwrap();
assert!(VcsCheck::IsDir.matches(&cwd));
let path = std::env::temp_dir().join(format!(
"powerliners-vcs-isdir-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, "x").unwrap();
assert!(!VcsCheck::IsDir.matches(&path));
std::fs::remove_file(&path).ok();
}
#[test]
fn vcs_check_exists_no_match_for_nonexistent_path() {
assert!(!VcsCheck::Exists.matches("/never/exists/abc"));
}
#[test]
fn guess_vcs_at_directory_detects_git_repo() {
let dir = std::env::temp_dir().join(format!(
"powerliners-vcs-guess-git-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join(".git")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.join(".git"), std::fs::Permissions::from_mode(0o755))
.unwrap();
}
let r = guess_vcs_at_directory(&dir);
assert_eq!(r.as_ref().map(|(name, _)| *name), Some("git"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn guess_vcs_at_directory_detects_mercurial_repo() {
let dir = std::env::temp_dir().join(format!(
"powerliners-vcs-guess-hg-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join(".hg")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.join(".hg"), std::fs::Permissions::from_mode(0o755))
.unwrap();
}
let r = guess_vcs_at_directory(&dir);
assert_eq!(r.as_ref().map(|(name, _)| *name), Some("mercurial"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn guess_vcs_at_directory_returns_none_for_non_repo() {
let dir = std::env::temp_dir().join(format!(
"powerliners-vcs-guess-none-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
assert!(guess_vcs_at_directory(&dir).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn guess_walks_parent_directories_to_find_repo() {
let root = std::env::temp_dir().join(format!(
"powerliners-vcs-guess-walk-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let nested = root.join("sub").join("deeper");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(root.join(".git"), std::fs::Permissions::from_mode(0o755))
.unwrap();
}
let r = guess(&nested);
assert_eq!(r.as_ref().map(|(name, _)| *name), Some("git"));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn guess_returns_none_when_no_repo_in_chain() {
let dir = std::env::temp_dir().join(format!(
"powerliners-vcs-guess-no-repo-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let r = guess_vcs_at_directory(&dir);
assert!(r.is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn file_watcher_initialised_returns_bool() {
let _ = file_watcher_initialised();
}
#[test]
fn branch_watcher_initialised_returns_bool() {
let _ = branch_watcher_initialised();
}
}