use regex::bytes::Regex as ByteRegex;
use std::sync::OnceLock;
#[allow(non_snake_case)]
pub fn _ref_pat() -> &'static ByteRegex {
static R: OnceLock<ByteRegex> = OnceLock::new();
R.get_or_init(|| ByteRegex::new(r"^ref:\s*refs/heads/(.+)$").unwrap())
}
pub fn branch_name_from_config_file(
directory: &std::path::Path,
config_file: &std::path::Path,
) -> String {
let raw = match std::fs::read(config_file) {
Ok(b) => b,
Err(_) => {
return directory
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
}
};
if let Some(c) = _ref_pat().captures(raw.split(|&b| b == b'\n').next().unwrap_or(&[])) {
if let Some(m) = c.get(1) {
return String::from_utf8_lossy(m.as_bytes()).trim().to_string();
}
}
let head: Vec<u8> = raw.iter().take(7).copied().collect();
String::from_utf8_lossy(&head).to_string()
}
pub fn git_directory(directory: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
let path = directory.join(".git");
if path.is_file() {
let raw = std::fs::read(&path)?;
if !raw.starts_with(b"gitdir: ") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"invalid gitfile format",
));
}
let raw = &raw[8..];
let raw = if raw.last() == Some(&b'\n') {
&raw[..raw.len() - 1]
} else {
raw
};
let s = String::from_utf8_lossy(raw).to_string();
if s.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"no path in gitfile",
));
}
let joined = directory.join(&s);
std::fs::canonicalize(&joined).or(Ok(joined))
} else {
Ok(path)
}
}
#[derive(Debug)]
pub struct GitRepository {
pub directory: std::path::PathBuf,
pub create_watcher: (),
}
impl GitRepository {
pub fn new(directory: impl AsRef<std::path::Path>, create_watcher: ()) -> Self {
let abs = std::fs::canonicalize(directory.as_ref())
.unwrap_or_else(|_| directory.as_ref().to_path_buf());
Self {
directory: abs,
create_watcher,
}
}
pub fn status(&self, _path: Option<&str>) -> Option<String> {
None
}
pub fn branch(&self) -> String {
let dir = git_directory(&self.directory).unwrap_or_else(|_| self.directory.join(".git"));
let head = dir.join("HEAD");
branch_name_from_config_file(&dir, &head)
}
}
#[derive(Debug)]
pub struct Repository {
pub base: GitRepository,
}
impl Repository {
pub fn new(
directory: impl AsRef<std::path::Path>,
create_watcher: (),
) -> std::io::Result<Self> {
if which_exists("git").is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"git executable is not available",
));
}
Ok(Self {
base: GitRepository::new(directory, create_watcher),
})
}
pub fn ignore_event(path: &str, name: &str) -> bool {
path.ends_with(".git") && name == "index.lock"
}
pub fn aggregate_porcelain_status(lines: &[&str]) -> Option<String> {
let mut wt_column: char = ' ';
let mut index_column: char = ' ';
let mut untracked_column: char = ' ';
for line in lines {
let bytes = line.as_bytes();
if !bytes.is_empty() && bytes[0] == b'?' {
untracked_column = 'U';
continue;
}
if !bytes.is_empty() && bytes[0] == b'!' {
continue;
}
if !bytes.is_empty() && bytes[0] != b' ' {
index_column = 'I';
}
if bytes.len() > 1 && bytes[1] != b' ' {
wt_column = 'D';
}
}
let r: String = format!("{}{}{}", wt_column, index_column, untracked_column);
if r == " " {
None
} else {
Some(r)
}
}
pub fn do_status(&self, _directory: &std::path::Path, _path: Option<&str>) -> Option<String> {
None
}
pub fn stash(&self) -> usize {
0
}
pub fn _gitcmd(&self, _directory: &std::path::Path, _args: &[&str]) -> Vec<String> {
Vec::new()
}
}
fn which_exists(name: &str) -> Option<std::path::PathBuf> {
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let full = dir.join(name);
if full.is_file() {
return Some(full);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::Mutex;
use std::sync::OnceLock;
static PATH_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
macro_rules! lock_path {
() => {{
PATH_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}};
}
fn tmp_dir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut p = std::env::temp_dir();
p.push(format!(
"powerliners-git-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn ref_pat_matches_symbolic_head() {
let m = _ref_pat().captures(b"ref: refs/heads/main").unwrap();
assert_eq!(&m[1], b"main");
}
#[test]
fn ref_pat_matches_with_extra_whitespace() {
let m = _ref_pat().captures(b"ref: refs/heads/feature/x").unwrap();
assert_eq!(&m[1], b"feature/x");
}
#[test]
fn ref_pat_does_not_match_sha() {
assert!(_ref_pat().captures(b"abc1234567890abcdef").is_none());
}
#[test]
fn branch_name_from_symbolic_head() {
let d = tmp_dir();
let f = d.join("HEAD");
let mut h = std::fs::File::create(&f).unwrap();
h.write_all(b"ref: refs/heads/develop\n").unwrap();
let name = branch_name_from_config_file(&d, &f);
assert_eq!(name, "develop");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn branch_name_from_detached_head_returns_short_sha() {
let d = tmp_dir();
let f = d.join("HEAD");
let mut h = std::fs::File::create(&f).unwrap();
h.write_all(b"abcdef1234567890\n").unwrap();
let name = branch_name_from_config_file(&d, &f);
assert_eq!(name, "abcdef1");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn branch_name_missing_file_returns_basename() {
let d = tmp_dir();
let basename = d.file_name().unwrap().to_string_lossy().to_string();
let f = d.join("does-not-exist");
let name = branch_name_from_config_file(&d, &f);
assert_eq!(name, basename);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn git_directory_returns_dot_git_when_it_is_a_directory() {
let d = tmp_dir();
let gitd = d.join(".git");
std::fs::create_dir_all(&gitd).unwrap();
let result = git_directory(&d).unwrap();
assert_eq!(result, gitd);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn git_directory_follows_gitfile_pointer() {
let d = tmp_dir();
let target = d.join("realgit");
std::fs::create_dir_all(&target).unwrap();
let gitfile = d.join(".git");
let mut h = std::fs::File::create(&gitfile).unwrap();
h.write_all(b"gitdir: realgit\n").unwrap();
let resolved = git_directory(&d).unwrap();
assert!(resolved.is_absolute());
assert!(resolved.file_name().unwrap() == "realgit");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn git_directory_errors_on_invalid_gitfile() {
let d = tmp_dir();
let gitfile = d.join(".git");
let mut h = std::fs::File::create(&gitfile).unwrap();
h.write_all(b"not a gitdir pointer\n").unwrap();
let r = git_directory(&d);
assert!(r.is_err());
let e = r.unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn git_directory_errors_on_empty_gitfile_pointer() {
let d = tmp_dir();
let gitfile = d.join(".git");
let mut h = std::fs::File::create(&gitfile).unwrap();
h.write_all(b"gitdir: \n").unwrap();
let r = git_directory(&d);
assert!(r.is_err());
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn git_repository_new_canonicalizes() {
let d = tmp_dir();
let repo = GitRepository::new(&d, ());
assert!(repo.directory.is_absolute());
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn git_repository_branch_reads_head() {
let d = tmp_dir();
let gitd = d.join(".git");
std::fs::create_dir_all(&gitd).unwrap();
let head = gitd.join("HEAD");
let mut h = std::fs::File::create(&head).unwrap();
h.write_all(b"ref: refs/heads/trunk\n").unwrap();
let repo = GitRepository::new(&d, ());
assert_eq!(repo.branch(), "trunk");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn repository_new_errors_when_git_not_on_path() {
let _g = lock_path!();
let saved = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", "/nonexistent-empty-dir-for-test");
}
let d = tmp_dir();
let result = Repository::new(&d, ());
if let Some(p) = saved {
unsafe {
std::env::set_var("PATH", p);
}
} else {
unsafe {
std::env::remove_var("PATH");
}
}
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn ignore_event_index_lock_is_ignored() {
assert!(Repository::ignore_event("/repo/.git", "index.lock"));
}
#[test]
fn ignore_event_other_files_not_ignored() {
assert!(!Repository::ignore_event("/repo/.git", "HEAD"));
assert!(!Repository::ignore_event("/repo/src", "index.lock"));
}
#[test]
fn aggregate_porcelain_status_empty_returns_none() {
let r = Repository::aggregate_porcelain_status(&[]);
assert_eq!(r, None);
}
#[test]
fn aggregate_porcelain_status_modified_workingtree_returns_d() {
let r = Repository::aggregate_porcelain_status(&[" M file.txt"]);
assert_eq!(r, Some("D ".to_string()));
}
#[test]
fn aggregate_porcelain_status_modified_index_returns_i() {
let r = Repository::aggregate_porcelain_status(&["M file.txt"]);
assert_eq!(r, Some(" I ".to_string()));
}
#[test]
fn aggregate_porcelain_status_untracked_returns_u() {
let r = Repository::aggregate_porcelain_status(&["?? newfile.txt"]);
assert_eq!(r, Some(" U".to_string()));
}
#[test]
fn aggregate_porcelain_status_ignored_line_does_not_change_state() {
let r = Repository::aggregate_porcelain_status(&["!! ignored.txt"]);
assert_eq!(r, None);
}
#[test]
fn aggregate_porcelain_status_combined_index_wt_untracked() {
let lines = ["MM both-dirty.txt", "?? untracked.txt"];
let r = Repository::aggregate_porcelain_status(&lines);
assert_eq!(r, Some("DIU".to_string()));
}
#[test]
fn aggregate_porcelain_status_all_spaces_returns_none() {
let r = Repository::aggregate_porcelain_status(&[]);
assert_eq!(r, None);
}
#[test]
fn do_status_stub_returns_none() {
let _g = lock_path!();
if which_exists("git").is_none() {
return;
}
let d = tmp_dir();
let repo = Repository::new(&d, ()).unwrap();
assert_eq!(repo.do_status(&d, None), None);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn stash_stub_returns_zero() {
let _g = lock_path!();
if which_exists("git").is_none() {
return;
}
let d = tmp_dir();
let repo = Repository::new(&d, ()).unwrap();
assert_eq!(repo.stash(), 0);
std::fs::remove_dir_all(&d).ok();
}
}