use std::path::{Path, PathBuf};
use crate::branch_meta::BranchMeta;
fn wal_sidecar(db_path: &Path) -> PathBuf {
let mut os = db_path.as_os_str().to_os_string();
os.push("-wal");
PathBuf::from(os)
}
fn file_len(path: &Path) -> u64 {
std::fs::metadata(path).map_or(0, |m| m.len())
}
async fn vacuum_into(parent_db: &Path, new_db_path: &Path) -> std::result::Result<(), String> {
let db = libsql::Builder::new_local(parent_db)
.build()
.await
.map_err(|e| e.to_string())?;
let conn = db.connect().map_err(|e| e.to_string())?;
let dest = new_db_path
.to_str()
.ok_or_else(|| "non-UTF-8 destination path".to_string())?
.to_owned();
conn.execute("VACUUM INTO ?1", [dest])
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn copy_branch_db(parent_db: &Path, new_db_path: &Path) -> crate::errors::Result<()> {
let file_name = new_db_path.file_name().map_or_else(
|| "branch.db".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp =
new_db_path.with_file_name(format!(".{file_name}.{}.{nanos}.tmp", std::process::id()));
if vacuum_into(parent_db, &tmp).await.is_ok() {
if let Err(e) = std::fs::rename(&tmp, new_db_path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
return Ok(());
}
let _ = std::fs::remove_file(&tmp);
let parent_wal = wal_sidecar(parent_db);
if file_len(&parent_wal) > 0 {
if let Ok(db) = libsql::Builder::new_local(parent_db).build().await {
if let Ok(conn) = db.connect() {
let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").await;
}
}
}
std::fs::copy(parent_db, new_db_path)?;
if file_len(&parent_wal) > 0 {
std::fs::copy(&parent_wal, wal_sidecar(new_db_path))?;
}
Ok(())
}
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(std::string::ToString::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(std::string::ToString::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)
}
pub fn unique_branch_db_file(meta: &crate::branch_meta::BranchMeta, branch: &str) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let stem = sanitize_branch_name(branch);
let candidate = format!("branches/{stem}.db");
let collides = meta
.branches
.iter()
.any(|(name, entry)| name != branch && entry.db_file == candidate);
if !collides {
return candidate;
}
let digest = Sha256::digest(branch.as_bytes());
let short = digest.iter().take(4).fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
});
format!("branches/{stem}-{short}.db")
}
pub async fn track_branch_copy(
project_root: &Path,
tokensave_dir: &Path,
branch: &str,
) -> crate::errors::Result<bool> {
use crate::branch_meta;
let Some(mut meta) = branch_meta::load_branch_meta(tokensave_dir) else {
return Ok(false);
};
if branch == meta.default_branch || meta.is_tracked(branch) {
return Ok(false);
}
let parent = find_nearest_tracked_ancestor(project_root, branch, &meta)
.unwrap_or_else(|| meta.default_branch.clone());
let Some(parent_db) = resolve_branch_db_path(tokensave_dir, &parent, &meta) else {
return Ok(false);
};
if !parent_db.exists() {
return Ok(false);
}
let db_file = unique_branch_db_file(&meta, branch);
branch_meta::ensure_branches_dir(tokensave_dir)?;
let new_db_path = tokensave_dir.join(&db_file);
copy_branch_db(&parent_db, &new_db_path).await?;
meta.add_branch(branch, &db_file, &parent);
branch_meta::save_branch_meta(tokensave_dir, &meta)?;
Ok(true)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn unique_branch_db_file_avoids_collision() {
use crate::branch_meta::BranchMeta;
let mut meta = BranchMeta::new("main");
let f1 = unique_branch_db_file(&meta, "feature/foo");
assert_eq!(f1, "branches/feature_foo.db");
meta.add_branch("feature/foo", &f1, "main");
let f2 = unique_branch_db_file(&meta, "feature_foo");
assert_ne!(f2, f1);
assert!(f2.starts_with("branches/feature_foo-") && f2.contains(".db"));
assert_eq!(unique_branch_db_file(&meta, "feature/foo"), f1);
}
#[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");
}
#[tokio::test]
async fn track_branch_copy_copies_ancestor_and_is_idempotent() {
use crate::branch_meta;
let dir = tempfile::TempDir::new().unwrap();
let ts = dir.path(); branch_meta::save_branch_meta(ts, &branch_meta::BranchMeta::new("main")).unwrap();
std::fs::write(ts.join("tokensave.db"), b"DBDATA").unwrap();
assert!(track_branch_copy(ts, ts, "feature-x").await.unwrap());
assert!(ts.join("branches").join("feature-x.db").exists());
assert!(branch_meta::load_branch_meta(ts)
.unwrap()
.is_tracked("feature-x"));
assert!(!track_branch_copy(ts, ts, "feature-x").await.unwrap());
assert!(!track_branch_copy(ts, ts, "main").await.unwrap());
let empty = tempfile::TempDir::new().unwrap();
assert!(!track_branch_copy(empty.path(), empty.path(), "x")
.await
.unwrap());
}
}