use std::path::Path;
use crate::branch_meta::BranchMeta;
pub fn current_branch(project_root: &Path) -> Option<String> {
if let Some(branch) = current_branch_gix(project_root) {
return Some(branch);
}
current_branch_git(project_root)
}
fn current_branch_gix(project_root: &Path) -> Option<String> {
let repo = gix::open(project_root).ok()?;
let head = repo.head().ok()?;
let name = head.name().as_bstr();
let name_str = std::str::from_utf8(name).ok()?;
name_str.strip_prefix("refs/heads/").map(|s| s.to_string())
}
fn current_branch_git(project_root: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["symbolic-ref", "-q", "HEAD"])
.current_dir(project_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let name = std::str::from_utf8(&output.stdout).ok()?;
name.strip_prefix("refs/heads/")
.and_then(|s| s.strip_suffix('\n'))
.map(|s| s.to_string())
}
pub fn detect_default_branch(project_root: &Path) -> Option<String> {
let repo = gix::open(project_root).ok()?;
if let Ok(reference) = repo.find_reference("refs/remotes/origin/HEAD") {
if let Some(Ok(target)) = reference.follow() {
if let Some(name) = target
.name()
.as_bstr()
.to_string()
.strip_prefix("refs/remotes/origin/")
{
return Some(name.to_string());
}
}
}
for candidate in &["main", "master"] {
let refname = format!("refs/heads/{candidate}");
if repo.find_reference(&refname).is_ok() {
return Some((*candidate).to_string());
}
}
None
}
pub fn sanitize_branch_name(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | ' ' | '.' => '_',
c => c,
})
.collect();
let mut result = String::with_capacity(sanitized.len());
let mut prev_underscore = false;
for c in sanitized.chars() {
if c == '_' {
if !prev_underscore {
result.push(c);
}
prev_underscore = true;
} else {
result.push(c);
prev_underscore = false;
}
}
result.trim_matches('_').to_string()
}
pub fn resolve_branch_db_path(
tokensave_dir: &Path,
branch: &str,
meta: &BranchMeta,
) -> Option<std::path::PathBuf> {
let entry = meta.branches.get(branch)?;
let resolved = tokensave_dir.join(&entry.db_file);
if let (Ok(canonical_dir), Ok(canonical_path)) =
(tokensave_dir.canonicalize(), resolved.canonicalize())
{
if !canonical_path.starts_with(&canonical_dir) {
return None;
}
}
Some(resolved)
}
pub fn find_nearest_tracked_ancestor(
project_root: &Path,
branch: &str,
meta: &BranchMeta,
) -> Option<String> {
let repo = gix::open(project_root).ok()?;
let branch_ref = format!("refs/heads/{branch}");
let branch_commit = repo
.find_reference(&branch_ref)
.ok()?
.peel_to_commit()
.ok()?;
let mut best: Option<(String, gix::date::Time)> = None;
for tracked_name in meta.branches.keys() {
if tracked_name == branch {
continue;
}
let tracked_ref = format!("refs/heads/{tracked_name}");
let Some(tracked_commit) = repo
.find_reference(&tracked_ref)
.ok()
.and_then(|mut r| r.peel_to_commit().ok())
else {
continue;
};
let Ok(base_id) = repo.merge_base(branch_commit.id, tracked_commit.id) else {
continue;
};
let Ok(base_commit) = repo.find_commit(base_id) else {
continue;
};
let time = base_commit
.time()
.ok()
.unwrap_or_else(|| gix::date::Time::new(0, 0));
if best
.as_ref()
.is_none_or(|(_, best_time)| time.seconds > best_time.seconds)
{
best = Some((tracked_name.clone(), time));
}
}
best.map(|(name, _)| name)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn sanitize_simple() {
assert_eq!(sanitize_branch_name("main"), "main");
}
#[test]
fn sanitize_slashes() {
assert_eq!(sanitize_branch_name("feature/foo/bar"), "feature_foo_bar");
}
#[test]
fn sanitize_special_chars() {
assert_eq!(sanitize_branch_name("fix: bug <1>"), "fix_bug_1");
}
#[test]
fn sanitize_dots_prevented() {
assert_eq!(sanitize_branch_name(".."), "");
assert_eq!(sanitize_branch_name("foo/../bar"), "foo_bar");
}
}