use std::cell::RefCell;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
const CACHE_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchInfo {
Branch(String),
DetachedHead(Option<String>),
NotGitRepo,
}
impl BranchInfo {
#[must_use]
pub fn branch_name(&self) -> Option<&str> {
match self {
Self::Branch(name) => Some(name),
Self::DetachedHead(_) | Self::NotGitRepo => None,
}
}
#[must_use]
pub fn is_in_git_repo(&self) -> bool {
!matches!(self, Self::NotGitRepo)
}
#[must_use]
pub fn is_on_branch(&self) -> bool {
matches!(self, Self::Branch(_))
}
#[must_use]
pub fn is_detached(&self) -> bool {
matches!(self, Self::DetachedHead(_))
}
}
#[derive(Debug)]
struct CachedBranch {
working_dir: PathBuf,
info: BranchInfo,
cached_at: Instant,
}
impl CachedBranch {
fn is_valid(&self, current_dir: &PathBuf) -> bool {
self.working_dir == *current_dir && self.cached_at.elapsed() < CACHE_TTL
}
}
thread_local! {
static BRANCH_CACHE: RefCell<Option<CachedBranch>> = const { RefCell::new(None) };
}
#[must_use]
pub fn get_current_branch() -> Option<String> {
get_branch_info().branch_name().map(String::from)
}
#[must_use]
pub fn get_branch_info() -> BranchInfo {
let current_dir = std::env::current_dir().unwrap_or_default();
let cached = BRANCH_CACHE.with(|cache| {
let borrow = cache.borrow();
if let Some(ref entry) = *borrow {
if entry.is_valid(¤t_dir) {
return Some(entry.info.clone());
}
}
None
});
if let Some(info) = cached {
return info;
}
let info = fetch_branch_info();
BRANCH_CACHE.with(|cache| {
*cache.borrow_mut() = Some(CachedBranch {
working_dir: current_dir,
info: info.clone(),
cached_at: Instant::now(),
});
});
info
}
#[must_use]
pub fn get_branch_info_at_path(path: &std::path::Path) -> BranchInfo {
fetch_branch_info_at_path(path)
}
pub fn clear_cache() {
BRANCH_CACHE.with(|cache| {
*cache.borrow_mut() = None;
});
}
fn fetch_branch_info() -> BranchInfo {
if let Some(info) = get_branch_from_git_command(None) {
return info;
}
get_branch_from_head_file(None)
}
fn fetch_branch_info_at_path(path: &std::path::Path) -> BranchInfo {
if let Some(info) = get_branch_from_git_command(Some(path)) {
return info;
}
get_branch_from_head_file(Some(path))
}
fn get_branch_from_git_command(working_dir: Option<&std::path::Path>) -> Option<BranchInfo> {
let mut cmd = Command::new("git");
cmd.args(["branch", "--show-current"]);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
cmd.stderr(std::process::Stdio::null());
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8(output.stdout).ok()?.trim().to_string();
if branch.is_empty() {
let hash = get_detached_head_hash(working_dir);
Some(BranchInfo::DetachedHead(hash))
} else {
Some(BranchInfo::Branch(branch))
}
}
fn get_detached_head_hash(working_dir: Option<&std::path::Path>) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(["rev-parse", "--short", "HEAD"]);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
cmd.stderr(std::process::Stdio::null());
let output = cmd.output().ok()?;
if output.status.success() {
let hash = String::from_utf8(output.stdout).ok()?.trim().to_string();
if !hash.is_empty() {
return Some(hash);
}
}
None
}
fn get_branch_from_head_file(working_dir: Option<&std::path::Path>) -> BranchInfo {
let git_dir = find_git_dir(working_dir);
let head_path = match git_dir {
Some(dir) => dir.join("HEAD"),
None => return BranchInfo::NotGitRepo,
};
let head_content = match std::fs::read_to_string(&head_path) {
Ok(content) => content,
Err(_) => return BranchInfo::NotGitRepo,
};
parse_head_content(&head_content)
}
fn parse_head_content(head_content: &str) -> BranchInfo {
let trimmed = head_content.trim();
if let Some(ref_path) = trimmed.strip_prefix("ref: ") {
if let Some(branch) = ref_path.strip_prefix("refs/heads/") {
return BranchInfo::Branch(branch.to_string());
}
return BranchInfo::DetachedHead(None);
}
if trimmed.len() >= 7 && trimmed.len() <= 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
let short_hash = if trimmed.len() > 7 {
trimmed[..7].to_string()
} else {
trimmed.to_string()
};
return BranchInfo::DetachedHead(Some(short_hash));
}
BranchInfo::NotGitRepo
}
fn find_git_dir(working_dir: Option<&std::path::Path>) -> Option<PathBuf> {
let start_dir = working_dir
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())?;
let mut current = start_dir.as_path();
loop {
let git_path = current.join(".git");
if git_path.is_dir() {
return Some(git_path);
}
if git_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&git_path) {
if let Some(gitdir) = parse_gitdir_from_dot_git_file(&content) {
let gitdir_path = PathBuf::from(gitdir);
let resolved = if gitdir_path.is_absolute() {
gitdir_path
} else {
current.join(gitdir_path)
};
if resolved.is_dir() {
return Some(resolved);
}
}
}
}
current = current.parent()?;
}
}
fn parse_gitdir_from_dot_git_file(content: &str) -> Option<&str> {
content.lines().find_map(|line| {
line.trim()
.strip_prefix("gitdir:")
.map(str::trim)
.filter(|value| !value.is_empty())
})
}
#[must_use]
pub fn is_in_git_repo() -> bool {
get_branch_info().is_in_git_repo()
}
#[must_use]
pub fn is_in_git_repo_at_path(path: &std::path::Path) -> bool {
get_branch_info_at_path(path).is_in_git_repo()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::process::Command;
fn run_git(repo_path: &Path, args: &[&str]) {
let output = Command::new("git")
.current_dir(repo_path)
.args(args)
.output()
.expect("failed to run git command");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
fn init_git_repo(repo_path: &Path) {
run_git(repo_path, &["init"]);
run_git(
repo_path,
&["config", "user.email", "dcg-tests@example.com"],
);
run_git(repo_path, &["config", "user.name", "DCG Tests"]);
}
fn create_commit(repo_path: &Path, file_name: &str) {
fs::write(repo_path.join(file_name), "test data").expect("write commit fixture file");
run_git(repo_path, &["add", file_name]);
run_git(repo_path, &["commit", "-m", "test commit"]);
}
#[test]
fn test_branch_info_methods() {
let branch = BranchInfo::Branch("main".to_string());
assert_eq!(branch.branch_name(), Some("main"));
assert!(branch.is_in_git_repo());
assert!(branch.is_on_branch());
assert!(!branch.is_detached());
let detached = BranchInfo::DetachedHead(Some("abc1234".to_string()));
assert_eq!(detached.branch_name(), None);
assert!(detached.is_in_git_repo());
assert!(!detached.is_on_branch());
assert!(detached.is_detached());
let not_repo = BranchInfo::NotGitRepo;
assert_eq!(not_repo.branch_name(), None);
assert!(!not_repo.is_in_git_repo());
assert!(!not_repo.is_on_branch());
assert!(!not_repo.is_detached());
}
#[test]
fn test_cache_validity() {
let current_dir = PathBuf::from("/test/path");
let other_dir = PathBuf::from("/other/path");
let cache = CachedBranch {
working_dir: current_dir.clone(),
info: BranchInfo::Branch("main".to_string()),
cached_at: Instant::now(),
};
assert!(cache.is_valid(¤t_dir));
assert!(!cache.is_valid(&other_dir));
}
#[test]
fn test_head_file_parsing_branch() {
let info = parse_head_content("ref: refs/heads/feature/my-branch");
assert_eq!(info, BranchInfo::Branch("feature/my-branch".to_string()));
}
#[test]
fn test_head_file_parsing_non_head_ref_is_detached() {
let info = parse_head_content("ref: refs/remotes/origin/main");
assert_eq!(info, BranchInfo::DetachedHead(None));
}
#[test]
fn test_head_file_parsing_detached() {
let hash = "abc1234def5678";
assert!(hash.len() >= 7 && hash.len() <= 40);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
let info = parse_head_content(hash);
assert_eq!(info, BranchInfo::DetachedHead(Some("abc1234".to_string())));
}
#[test]
fn test_parse_gitdir_from_dot_git_file_multiline() {
let content = "gitdir: ../.git/worktrees/demo\nworktree: /tmp/demo\n";
assert_eq!(
parse_gitdir_from_dot_git_file(content),
Some("../.git/worktrees/demo")
);
}
#[test]
fn test_find_git_dir_resolves_worktree_pointer_file() {
let temp = tempfile::tempdir().expect("tempdir");
let repo_root = temp.path().join("repo");
let worktree = repo_root.join("nested").join("worktree");
let git_dir = repo_root.join(".real-git-dir");
fs::create_dir_all(&worktree).expect("create worktree");
fs::create_dir_all(&git_dir).expect("create git dir");
let dot_git = repo_root.join(".git");
fs::write(
&dot_git,
"gitdir: .real-git-dir\nworktree: nested/worktree\n",
)
.expect("write .git file");
let resolved = find_git_dir(Some(&worktree)).expect("resolve git dir");
assert_eq!(resolved, git_dir);
}
#[test]
fn test_clear_cache() {
clear_cache();
let _ = get_branch_info();
}
#[test]
fn test_get_current_branch_returns_some_in_git_repo() {
let result = get_current_branch();
drop(result);
}
#[test]
fn test_is_in_git_repo() {
let result = is_in_git_repo();
assert!(result, "Expected to be in a git repo");
}
#[test]
fn test_branch_info_at_temp_path() {
let temp_dir = std::env::temp_dir();
let result = get_branch_info_at_path(&temp_dir);
drop(result);
}
#[test]
fn test_get_branch_info_at_path_detects_named_branch() {
let temp = tempfile::tempdir().expect("tempdir");
init_git_repo(temp.path());
run_git(temp.path(), &["checkout", "-b", "feature/test"]);
let info = get_branch_info_at_path(temp.path());
assert_eq!(info, BranchInfo::Branch("feature/test".to_string()));
}
#[test]
fn test_get_branch_info_at_path_detects_detached_head() {
let temp = tempfile::tempdir().expect("tempdir");
init_git_repo(temp.path());
create_commit(temp.path(), "detached.txt");
run_git(temp.path(), &["checkout", "--detach"]);
let info = get_branch_info_at_path(temp.path());
assert!(
matches!(info, BranchInfo::DetachedHead(_)),
"Expected detached HEAD, got {info:?}"
);
}
#[test]
fn test_get_branch_info_at_path_returns_not_git_repo_for_plain_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let info = get_branch_info_at_path(temp.path());
assert_eq!(info, BranchInfo::NotGitRepo);
}
}