use std::{
fs::{self, File, OpenOptions},
hash::{DefaultHasher, Hash, Hasher},
io::{Read, Seek, Write},
path::{Path, PathBuf},
};
use directories::BaseDirs;
use gitcortex_core::error::{GitCortexError, Result};
pub fn sanitize(branch: &str) -> String {
let expanded = branch.replace('/', "__");
let mut s: String = expanded
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if s.starts_with(|c: char| c.is_ascii_digit()) {
s.insert_str(0, "b_");
}
s
}
pub fn repo_id(repo_root: &Path) -> String {
let digest = blake3::hash(repo_root.to_string_lossy().as_bytes());
digest.to_hex()[..16].to_owned()
}
fn legacy_repo_id(repo_root: &Path) -> String {
let mut hasher = DefaultHasher::new();
repo_root.to_string_lossy().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn storage_repo_id(repo_root: &Path) -> String {
let stable = repo_id(repo_root);
if data_dir(&stable).exists() {
return stable;
}
let legacy = legacy_repo_id(repo_root);
if data_dir(&legacy).exists() {
legacy
} else {
stable
}
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| BaseDirs::new().map(|dirs| dirs.home_dir().to_owned()))
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn data_root() -> PathBuf {
if let Some(path) = std::env::var_os("GCX_STORE_PATH") {
return PathBuf::from(path);
}
if let Some(path) = std::env::var_os("XDG_DATA_HOME") {
return PathBuf::from(path).join("gitcortex");
}
let native = BaseDirs::new()
.map(|dirs| dirs.data_local_dir().join("gitcortex"))
.unwrap_or_else(|| home_dir().join(".local/share/gitcortex"));
let legacy = home_dir().join(".local/share/gitcortex");
if cfg!(target_os = "macos") && legacy.exists() && !native.exists() {
legacy
} else {
native
}
}
pub fn data_dir(repo_id: &str) -> PathBuf {
data_root().join(repo_id)
}
pub struct RepositoryLock {
file: File,
}
impl RepositoryLock {
pub fn try_acquire(repo_root: &Path) -> Result<Option<Self>> {
let repo_id = storage_repo_id(repo_root);
let dir = data_dir(&repo_id);
fs::create_dir_all(&dir)?;
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(dir.join("serve.lock"))?;
match fs2::FileExt::try_lock_exclusive(&file) {
Ok(()) => Ok(Some(Self { file })),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(error) => Err(GitCortexError::Io(error)),
}
}
pub fn owner(&mut self) -> String {
let mut owner = String::new();
if self.file.rewind().is_ok() {
let _ = self.file.read_to_string(&mut owner);
}
owner.trim().to_owned()
}
pub fn set_owner(&mut self, owner: &str) -> Result<()> {
self.file.set_len(0)?;
self.file.rewind()?;
write!(self.file, "{owner}")?;
self.file.sync_data()?;
Ok(())
}
}
pub fn repository_lock_owner(repo_root: &Path) -> String {
let repo_id = storage_repo_id(repo_root);
fs::read_to_string(data_dir(&repo_id).join("serve.lock"))
.unwrap_or_default()
.trim()
.to_owned()
}
pub fn cache_root() -> PathBuf {
if let Some(path) = std::env::var_os("GCX_CACHE_PATH") {
return PathBuf::from(path);
}
if let Some(path) = std::env::var_os("XDG_CACHE_HOME") {
return PathBuf::from(path).join("gitcortex");
}
BaseDirs::new()
.map(|dirs| dirs.cache_dir().join("gitcortex"))
.unwrap_or_else(|| home_dir().join(".cache/gitcortex"))
}
pub fn models_dir() -> PathBuf {
let target = cache_root().join("models");
let legacy = data_root().join("models");
if !target.exists() && legacy.exists() {
if let Some(parent) = target.parent() {
let _ = fs::create_dir_all(parent);
}
if fs::rename(&legacy, &target).is_err() {
return legacy;
}
}
target
}
pub fn db_path(repo_id: &str) -> PathBuf {
data_dir(repo_id).join("graph.kuzu")
}
pub fn last_sha_path(repo_id: &str, branch: &str) -> PathBuf {
data_dir(repo_id).join(format!("{}.sha", sanitize(branch)))
}
pub fn schema_version_path(repo_id: &str) -> PathBuf {
data_dir(repo_id).join("schema_version")
}
pub fn read_schema_version(repo_id: &str) -> u32 {
let path = schema_version_path(repo_id);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}
pub fn write_schema_version(repo_id: &str, version: u32) -> Result<()> {
let path = schema_version_path(repo_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, version.to_string()).map_err(GitCortexError::Io)
}
pub fn has_repo_data(repo_id: &str) -> Result<bool> {
let entries = match fs::read_dir(data_dir(repo_id)) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(GitCortexError::Io(error)),
};
for entry in entries {
if entry?.file_name() != "serve.lock" {
return Ok(true);
}
}
Ok(false)
}
pub fn wipe_repo_data(repo_id: &str) -> Result<()> {
let dir = data_dir(repo_id);
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(GitCortexError::Io(error)),
};
for entry in entries {
let entry = entry?;
if entry.file_name() == "serve.lock" {
continue;
}
let file_type = entry.file_type()?;
if file_type.is_dir() {
fs::remove_dir_all(entry.path())?;
} else {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
pub fn read_last_sha(repo_id: &str, branch: &str) -> Result<Option<String>> {
let path = last_sha_path(repo_id, branch);
match fs::read_to_string(&path) {
Ok(s) => Ok(Some(s.trim().to_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(GitCortexError::Io(e)),
}
}
pub fn write_last_sha(repo_id: &str, branch: &str, sha: &str) -> Result<()> {
let path = last_sha_path(repo_id, branch);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, sha).map_err(GitCortexError::Io)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_plain() {
assert_eq!(sanitize("main"), "main");
}
#[test]
fn sanitize_slash_becomes_double_underscore() {
assert_eq!(sanitize("feat/auth"), "feat__auth");
}
#[test]
fn sanitize_dash_and_dot() {
assert_eq!(sanitize("release/v1.0-rc"), "release__v1_0_rc");
}
#[test]
fn sanitize_leading_digit() {
assert_eq!(sanitize("1-hotfix"), "b_1_hotfix");
}
#[test]
fn repo_id_is_stable() {
let path = Path::new("/home/user/myproject");
assert_eq!(repo_id(path), "b6dd9f32aba035a6");
}
#[test]
fn repo_id_differs_across_paths() {
let a = repo_id(Path::new("/home/user/proj-a"));
let b = repo_id(Path::new("/home/user/proj-b"));
assert_ne!(a, b);
}
}