use std::path::{Path, PathBuf};
fn is_git_root_marker(path: &Path) -> bool {
let git = path.join(".git");
if git.is_dir() {
return true;
}
if git.is_file()
&& let Ok(content) = std::fs::read_to_string(&git)
{
return content.starts_with("gitdir:");
}
false
}
pub fn find_git_root(cwd: &Path) -> PathBuf {
let cwd = if let Ok(canon) = cwd.canonicalize() {
canon
} else {
cwd.to_path_buf()
};
let mut current = cwd.clone();
loop {
if is_git_root_marker(¤t) {
return current;
}
let parent = match current.parent() {
Some(p) => p.to_path_buf(),
None => return cwd.clone(),
};
if parent == current {
return cwd.clone();
}
current = parent;
}
}
pub fn project_root_override() -> Option<PathBuf> {
std::env::var("DIRGE_PROJECT_ROOT")
.ok()
.map(PathBuf::from)
.filter(|p| p.is_dir())
}
pub fn project_root(cwd: &Path) -> PathBuf {
project_root_override().unwrap_or_else(|| find_git_root(cwd))
}
#[derive(Debug, Clone)]
pub struct ProjectPaths {
pub root: PathBuf,
}
impl ProjectPaths {
pub fn new(cwd: &Path) -> Self {
ProjectPaths {
root: project_root(cwd),
}
}
pub fn dirge_dir(&self) -> PathBuf {
self.root.join(".dirge")
}
pub fn memory_dir(&self) -> PathBuf {
self.dirge_dir().join("memory")
}
pub fn skills_dir(&self) -> PathBuf {
self.dirge_dir().join("skills")
}
pub fn sessions_dir(&self) -> PathBuf {
self.dirge_dir().join("sessions")
}
pub fn session_db_path(&self) -> PathBuf {
self.sessions_dir().join("state.db")
}
pub fn memory_file(&self, name: &str) -> PathBuf {
self.memory_dir().join(name)
}
#[allow(dead_code)]
pub fn config_path(&self) -> PathBuf {
self.dirge_dir().join("config.yaml")
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard;
impl EnvGuard {
fn set(value: &str) -> Self {
unsafe { std::env::set_var("DIRGE_PROJECT_ROOT", value) };
EnvGuard
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var("DIRGE_PROJECT_ROOT") };
}
}
#[test]
fn find_git_root_in_this_repo() {
let cwd = std::env::current_dir().unwrap();
let root = find_git_root(&cwd);
let dot_git = root.join(".git");
let is_git = dot_git.is_dir()
|| (dot_git.is_file()
&& std::fs::read_to_string(&dot_git)
.map(|c| c.starts_with("gitdir:"))
.unwrap_or(false));
assert!(
is_git,
"expected {root:?} to contain .git/ or a worktree .git file"
);
}
#[test]
fn find_git_root_falls_back_to_cwd_outside_repo() {
let tmp = std::env::temp_dir();
let expected = tmp.canonicalize().unwrap_or_else(|_| tmp.clone());
let root = find_git_root(&tmp);
assert_eq!(root, expected);
}
#[test]
fn env_override_wins_over_git_detection() {
let _lock = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir();
let _guard = EnvGuard::set(tmp.to_str().unwrap());
let cwd = std::env::current_dir().unwrap();
let root = project_root(&cwd);
assert_eq!(root, tmp);
}
#[test]
fn env_override_ignores_missing_directory() {
let _lock = ENV_LOCK.lock().unwrap();
let _guard = EnvGuard::set("/nonexistent/dirge/project/root");
let cwd = std::env::current_dir().unwrap();
let root = project_root(&cwd);
assert_ne!(root, PathBuf::from("/nonexistent/dirge/project/root"));
}
#[test]
fn subdirs_are_under_dirge_dir() {
let cwd = std::env::current_dir().unwrap();
let paths = ProjectPaths::new(&cwd);
let dirge = paths.dirge_dir();
assert!(paths.memory_dir().starts_with(&dirge));
assert!(paths.skills_dir().starts_with(&dirge));
assert!(paths.sessions_dir().starts_with(&dirge));
assert!(paths.config_path().starts_with(&dirge));
}
#[test]
fn session_db_is_in_sessions_dir() {
let cwd = std::env::current_dir().unwrap();
let paths = ProjectPaths::new(&cwd);
let db = paths.session_db_path();
assert!(db.starts_with(paths.sessions_dir()));
assert!(db.ends_with("state.db"));
}
#[test]
fn memory_file_is_in_memory_dir() {
let cwd = std::env::current_dir().unwrap();
let paths = ProjectPaths::new(&cwd);
let f = paths.memory_file("MEMORY.md");
assert_eq!(f.file_name().unwrap(), "MEMORY.md");
assert!(f.starts_with(paths.memory_dir()));
}
#[test]
fn find_git_root_recognises_worktree_marker() {
let dir = std::env::temp_dir().join(format!("dirge-worktree-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".git"),
"gitdir: /some/real/path/.git/worktrees/foo\n",
)
.unwrap();
let root = find_git_root(&dir);
let expected = dir.canonicalize().unwrap_or_else(|_| dir.clone());
assert_eq!(root, expected, "should stop at worktree .git file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_git_root_with_symlinks() {
let cwd = std::env::current_dir().unwrap();
let root = find_git_root(&cwd);
assert!(
root.join(".git").is_dir() || {
let git_file = root.join(".git");
git_file.is_file()
&& std::fs::read_to_string(&git_file)
.map(|c| c.starts_with("gitdir:"))
.unwrap_or(false)
},
"expected {root:?} to be a git root"
);
}
}