use std::path::Path;
use crate::branch_meta::BranchMeta;
pub fn current_branch(project_root: &Path) -> Option<String> {
let head_path = project_root.join(".git/HEAD");
let content = std::fs::read_to_string(head_path).ok()?;
let trimmed = content.trim();
let branch = trimmed.strip_prefix("ref: refs/heads/")?;
Some(branch.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(name) = reference.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)]
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");
}
}