use std::path::{Path, PathBuf};
use rusqlite::params;
use seshat_core::BranchId;
use seshat_storage::{
BranchRepository, Database, FileIRRepository, IR_SCHEMA_VERSION, NodeRepository,
SqliteBranchRepository, SqliteFileIRRepository, SqliteNodeRepository,
};
use crate::error::CliError;
use gix::bstr::ByteSlice;
const PROTECTED_BRANCHES: &[&str] = &["main", "master"];
pub(crate) enum ServeTarget {
ExistingDb {
db_path: PathBuf,
project_root: PathBuf,
},
AutoScan {
project_root: PathBuf,
db_path: PathBuf,
},
}
pub struct ResolvedProject {
pub project_root: PathBuf,
pub git_root: Option<PathBuf>,
pub project_name: String,
pub db_path: PathBuf,
}
impl ResolvedProject {
pub fn sync_root(&self) -> &Path {
self.git_root.as_deref().unwrap_or(&self.project_root)
}
}
pub fn sync_root_for(path: &Path) -> PathBuf {
find_git_root(path).unwrap_or_else(|| path.to_path_buf())
}
pub(crate) fn unix_now() -> i64 {
chrono::Utc::now().timestamp()
}
pub(crate) struct ProjectInfo {
pub branch: BranchId,
pub file_count: usize,
pub convention_count: usize,
}
pub(crate) fn load_project_info(db: &Database) -> ProjectInfo {
let conn = db.connection().clone();
let branch_repo = SqliteBranchRepository::new(conn.clone());
let branch = branch_repo.get_current_branch().unwrap_or_else(|_| {
tracing::debug!("Could not detect git branch from DB, defaulting to 'main'");
BranchId::from("main")
});
let file_repo = SqliteFileIRRepository::new(conn.clone());
let file_count = file_repo
.get_file_hashes_by_branch(&branch)
.map(|h| h.len())
.unwrap_or(0);
let node_repo = SqliteNodeRepository::new(conn);
let convention_count = node_repo
.find_by_branch(&branch)
.map(|nodes| nodes.len())
.unwrap_or(0);
ProjectInfo {
branch,
file_count,
convention_count,
}
}
pub(crate) fn count_files_any_schema(db: &Database, branch_id: &str) -> usize {
let conn = db.connection().clone();
let Ok(guard) = conn.lock() else { return 0 };
guard
.query_row(
"SELECT COUNT(*) FROM files_ir WHERE branch_id = ?1",
params![branch_id],
|row| row.get::<_, i64>(0),
)
.map(|n| n as usize)
.unwrap_or(0)
}
pub(crate) fn count_conventions(db: &Database, branch_id: &str) -> usize {
let conn = db.connection().clone();
let Ok(guard) = conn.lock() else { return 0 };
guard
.query_row(
"SELECT COUNT(*) FROM nodes WHERE branch_id = ?1",
params![branch_id],
|row| row.get::<_, i64>(0),
)
.map(|n| n as usize)
.unwrap_or(0)
}
pub(crate) fn submodule_ir_schema_is_current(db: &Database, branch_id: &str) -> bool {
let conn = db.connection().clone();
let Ok(guard) = conn.lock() else { return true };
let stale_count: i64 = guard
.query_row(
"SELECT COUNT(*) FROM files_ir
WHERE branch_id = ?1 AND ir_schema_version != ?2",
params![branch_id, i64::from(IR_SCHEMA_VERSION)],
|row| row.get(0),
)
.unwrap_or(0);
stale_count == 0
}
pub(crate) fn xdg_repos_dir() -> Result<PathBuf, CliError> {
let data_dir = dirs::data_dir().ok_or_else(|| CliError::CommandFailed {
command: "seshat".to_owned(),
reason: "could not determine XDG data directory".to_owned(),
})?;
Ok(data_dir.join("seshat").join("repos"))
}
pub(crate) fn project_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_owned())
}
pub(crate) fn resolve_submodule_db_path(
project_name: &str,
mount_path: &str,
) -> Result<PathBuf, CliError> {
let repos_dir = xdg_repos_dir()?;
let db_path = repos_dir
.join(project_name)
.join(format!("{mount_path}.db"));
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| CliError::CommandFailed {
command: "scan".to_owned(),
reason: format!("failed to create submodule database directory: {e}"),
})?;
}
Ok(db_path)
}
const GIT_ROOT_MAX_ITERATIONS: u32 = 64;
pub fn find_git_root(from: &Path) -> Option<PathBuf> {
let mut current = if from.is_absolute() {
from.to_path_buf()
} else {
std::env::current_dir().ok()?.join(from)
};
for _ in 0..GIT_ROOT_MAX_ITERATIONS {
let git_path = current.join(".git");
if git_path.is_dir() {
return Some(current);
}
if git_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&git_path) {
if let Some(gitdir) = content.strip_prefix("gitdir: ") {
let gitdir_path = PathBuf::from(gitdir.trim());
let raw_resolved = if gitdir_path.is_absolute() {
gitdir_path
} else {
git_path.parent()?.join(gitdir_path)
};
let mut normalized = PathBuf::new();
for component in raw_resolved.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
_ => {
normalized.push(component);
}
}
}
let mut candidate = normalized.clone();
for _ in 0..GIT_ROOT_MAX_ITERATIONS {
if let Some(parent) = candidate.parent() {
if parent.join("HEAD").exists() || parent.join("config").exists() {
if parent.file_name().map(|n| n == ".git").unwrap_or(false) {
return parent
.parent()
.map(PathBuf::from)
.or(Some(parent.to_path_buf()));
}
return Some(parent.to_path_buf());
}
if !candidate.pop() {
break;
}
} else {
break;
}
}
}
}
}
if !current.pop() {
return None;
}
}
tracing::warn!(
path = %from.display(),
"find_git_root reached iteration limit; possible symlink cycle"
);
None
}
pub fn detect_branch(path: &Path) -> String {
get_current_branch(path).unwrap_or_else(|| {
tracing::debug!(path = %path.display(), "Could not detect git branch, defaulting to 'main'");
"main".to_string()
})
}
pub fn get_current_branch(path: &Path) -> Option<String> {
read_head_file(path)
}
fn read_head_file(path: &Path) -> Option<String> {
let gitdir = resolve_gitdir(path)?;
read_head_in_gitdir(&gitdir)
}
fn resolve_gitdir(path: &Path) -> Option<PathBuf> {
let git_dir = find_git_dir(path)?;
match git_dir {
GitDir::Dir(dir) => Some(dir),
GitDir::File(file) => {
let content = std::fs::read_to_string(&file).ok()?;
let gitdir = content.strip_prefix("gitdir: ")?.trim();
let gitdir_path = PathBuf::from(gitdir);
if gitdir_path.is_absolute() {
Some(gitdir_path)
} else {
Some(file.parent()?.join(gitdir_path))
}
}
}
}
pub(crate) enum GitDir {
Dir(PathBuf),
File(PathBuf),
}
pub(crate) fn find_git_dir(path: &Path) -> Option<GitDir> {
let mut current = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
for _ in 0..GIT_ROOT_MAX_ITERATIONS {
let git_path = current.join(".git");
if git_path.is_dir() {
return Some(GitDir::Dir(git_path));
}
if git_path.is_file() {
return Some(GitDir::File(git_path));
}
if !current.pop() {
return None;
}
}
tracing::warn!(
path = %path.display(),
"find_git_dir reached iteration limit; possible symlink cycle"
);
None
}
fn read_head_in_gitdir(gitdir: &Path) -> Option<String> {
let content = std::fs::read_to_string(gitdir.join("HEAD")).ok()?;
if let Some(rest) = content.strip_prefix("ref: ") {
if let Some(branch) = rest.trim().strip_prefix("refs/heads/") {
return Some(branch.to_string());
}
}
let trimmed = content.trim();
if trimmed.len() >= 7 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(trimmed.to_string());
}
None
}
pub fn get_git_branches(path: &Path) -> Vec<String> {
let repo = match gix::open(path) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let mut branches = Vec::new();
if let Ok(all_refs) = repo.references() {
if let Ok(mut local_branches) = all_refs.local_branches() {
while let Some(Ok(entry)) = local_branches.next() {
let full_name = entry.name().as_bstr();
let name_str = full_name.to_str().unwrap_or("");
if let Some(short_name) = name_str.strip_prefix("refs/heads/") {
branches.push(short_name.to_string());
}
}
}
}
branches
}
fn is_valid_git_repo(path: &Path) -> bool {
gix::open(path).is_ok()
}
pub fn gc_branch_snapshots(db: &Database, repo_path: &Path) -> Result<Vec<String>, CliError> {
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let db_branches = branch_repo
.list_branches()
.map_err(|e| CliError::CommandFailed {
command: "gc_branch_snapshots".to_owned(),
reason: format!("failed to list branches from database: {e}"),
})?;
if db_branches.is_empty() {
return Ok(Vec::new());
}
if !is_valid_git_repo(repo_path) {
tracing::warn!(
repo_path = %repo_path.display(),
"repo_path is not a valid git repository; skipping git branch comparison"
);
}
let git_branches = get_git_branches(repo_path);
let git_set: std::collections::HashSet<&str> =
git_branches.iter().map(|s| s.as_str()).collect();
let current_branch = get_current_branch(repo_path).unwrap_or_default();
let mut deleted = Vec::new();
for branch_id in &db_branches {
let name = &branch_id.0;
if PROTECTED_BRANCHES.contains(&name.as_str()) {
continue;
}
if name == ¤t_branch {
continue;
}
if git_set.contains(name.as_str()) {
continue;
}
tracing::info!(
branch = %name,
current_branch = %current_branch,
"Deleting orphan branch snapshot"
);
branch_repo
.delete_branch(branch_id)
.map_err(|e| CliError::CommandFailed {
command: "gc_branch_snapshots".to_owned(),
reason: format!("failed to delete branch '{name}': {e}"),
})?;
deleted.push(name.clone());
}
if !deleted.is_empty() {
tracing::info!(
deleted_count = deleted.len(),
deleted_branches = ?deleted,
"Branch snapshot garbage collection complete"
);
}
Ok(deleted)
}
pub(crate) fn list_available_projects(
repos_dir: &Path,
) -> Result<Vec<(PathBuf, String)>, CliError> {
if !repos_dir.is_dir() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(repos_dir).map_err(|e| CliError::CommandFailed {
command: "seshat".to_owned(),
reason: format!("failed to read repos directory: {e}"),
})?;
let mut projects: Vec<(PathBuf, String)> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| CliError::CommandFailed {
command: "seshat".to_owned(),
reason: format!("failed to read directory entry: {e}"),
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "db") {
let name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if !name.is_empty() {
projects.push((path, name));
}
}
}
projects.sort_by(|a, b| a.1.cmp(&b.1));
Ok(projects)
}
fn read_project_root_from_db(db_path: &Path) -> Option<PathBuf> {
use seshat_storage::{Database, RepoMetadataRepository, SqliteRepoMetadataRepository};
let db = Database::open(db_path).ok()?;
let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
let root_str = match meta_repo.get("project_root") {
Ok(Some(s)) => s,
_ => return None,
};
Some(PathBuf::from(root_str))
}
fn identity_from_dir(input: &Path) -> Result<ResolvedProject, CliError> {
let canonical = input.canonicalize().unwrap_or_else(|_| input.to_path_buf());
let git_root = find_git_root(&canonical);
let name_source = git_root.as_deref().unwrap_or(&canonical);
let project_name = project_name(name_source);
let repos_dir = xdg_repos_dir()?;
let db_path = repos_dir.join(format!("{project_name}.db"));
Ok(ResolvedProject {
project_root: canonical,
git_root,
project_name,
db_path,
})
}
fn identity_from_db(
project_name: String,
db_path: PathBuf,
stored_root: Option<PathBuf>,
) -> ResolvedProject {
let project_root = stored_root.unwrap_or_else(|| {
db_path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
});
let git_root = find_git_root(&project_root);
ResolvedProject {
project_root,
git_root,
project_name,
db_path,
}
}
pub fn resolve_project(
explicit_path: Option<&Path>,
command_name: &str,
) -> Result<ResolvedProject, CliError> {
if let Some(arg) = explicit_path {
if arg.is_dir() {
return identity_from_dir(arg);
}
let repos_dir = xdg_repos_dir()?;
let name = arg.to_string_lossy().to_string();
let by_name = repos_dir.join(format!("{name}.db"));
if by_name.is_file() {
let stored = read_project_root_from_db(&by_name);
return Ok(identity_from_db(name, by_name, stored));
}
let name_from_path = project_name(arg);
let by_path_name = repos_dir.join(format!("{name_from_path}.db"));
if by_path_name.is_file() {
let stored = read_project_root_from_db(&by_path_name);
return Ok(identity_from_db(name_from_path, by_path_name, stored));
}
return Err(CliError::CommandFailed {
command: command_name.to_owned(),
reason: format!(
"project '{}' has not been found.\n\
hint: run `seshat scan {}` first",
name,
arg.display()
),
});
}
if let Ok(cwd) = std::env::current_dir() {
let identity = identity_from_dir(&cwd)?;
if identity.db_path.is_file() {
tracing::info!(
project = %identity.project_name,
"Auto-detected project from cwd"
);
}
return Ok(identity);
}
let repos_dir = xdg_repos_dir()?;
let projects = list_available_projects(&repos_dir)?;
match projects.len() {
0 => Err(CliError::CommandFailed {
command: command_name.to_owned(),
reason: "no scanned projects found.\n\
hint: run `seshat scan <path>` first to index a project"
.to_string(),
}),
1 => {
let (path, name) = &projects[0];
tracing::info!(project = %name, "Auto-selected only available project");
let stored = read_project_root_from_db(path);
Ok(identity_from_db(name.clone(), path.clone(), stored))
}
_ => {
let project_list = projects
.iter()
.map(|(_, name)| format!(" ‣ {name}"))
.collect::<Vec<_>>()
.join("\n");
Err(CliError::CommandFailed {
command: command_name.to_owned(),
reason: format!(
"could not determine which project to use.\n\n\
Available scanned projects:\n\
{project_list}\n\n\
hint: run from the project directory, or specify:\n\
\x20 seshat <command> <project-name>\n\
\x20 seshat <command> <path-to-project>"
),
})
}
}
}
pub(crate) fn build_dangerous_cwd_hint() -> String {
concat!(
"Suggestions:\n",
" • Change to a real project directory: cd /path/to/your/project\n",
" • Index a specific path: seshat scan /path/to/project\n",
" • Bypass this guardrail by passing the path explicitly: seshat serve /path/to/project",
)
.to_owned()
}
pub(crate) fn build_repo_override_warning(project_root: &Path) -> String {
format!(
concat!(
"⚠️ Serving from a dangerous location: {}\n",
" This path is on the dangerous-cwd denylist (e.g. $HOME, ~/Library, /, drive roots).\n",
" Proceeding because an explicit repo path was passed. Watch memory usage on large trees.",
),
project_root.display()
)
}
pub(crate) fn check_serve_dangerous_cwd(
explicit_repo: Option<&Path>,
additional: &[String],
cwd: &Path,
home: Option<&Path>,
) -> Result<(), CliError> {
if explicit_repo.is_some() {
return Ok(());
}
if !crate::dangerous_path::is_dangerous_cwd_with_home(cwd, additional, home) {
return Ok(());
}
if let Some(git_root) = find_git_root(cwd) {
if !crate::dangerous_path::is_exact_denylist_entry(&git_root, additional, home) {
return Ok(());
}
tracing::warn!(
cwd = %cwd.display(),
git_root = %git_root.display(),
"found .git exactly at a denylist root; ignoring it for guard purposes"
);
}
Err(CliError::DangerousCwd {
path: cwd.to_path_buf(),
hint: build_dangerous_cwd_hint(),
})
}
pub(crate) fn check_repo_override_dangerous(
explicit_repo: Option<&Path>,
additional: &[String],
project_root: &Path,
home: Option<&Path>,
) -> Option<String> {
explicit_repo?;
if !crate::dangerous_path::is_dangerous_cwd_with_home(project_root, additional, home) {
return None;
}
if let Some(git_root) = find_git_root(project_root) {
if !crate::dangerous_path::is_exact_denylist_entry(&git_root, additional, home) {
return None;
}
}
Some(build_repo_override_warning(project_root))
}
pub(crate) fn resolve_serve_db_or_project_root(
explicit_repo: Option<&Path>,
additional_denylist_paths: &[String],
) -> Result<ServeTarget, CliError> {
if explicit_repo.is_none() {
let cwd = std::env::current_dir().map_err(|e| CliError::IoWithPath {
message: format!("could not read current working directory: {e}"),
path: PathBuf::from("."),
})?;
check_serve_dangerous_cwd(
explicit_repo,
additional_denylist_paths,
&cwd,
dirs::home_dir().as_deref(),
)?;
}
let resolved = resolve_project(explicit_repo, "serve")?;
if let Some(warning) = check_repo_override_dangerous(
explicit_repo,
additional_denylist_paths,
&resolved.project_root,
dirs::home_dir().as_deref(),
) {
tracing::warn!("{warning}");
}
if resolved.db_path.exists() {
Ok(ServeTarget::ExistingDb {
db_path: resolved.db_path,
project_root: resolved.project_root,
})
} else {
Ok(ServeTarget::AutoScan {
project_root: resolved.project_root,
db_path: resolved.db_path,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
struct CleanupDir(PathBuf);
impl Drop for CleanupDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn setup_repos_dir() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().expect("create temp dir");
let repos = tmp.path().join("seshat").join("repos");
fs::create_dir_all(&repos).expect("create repos dir");
(tmp, repos)
}
#[test]
fn project_name_extracts_last_component() {
assert_eq!(
project_name(Path::new("/Users/me/Projects/my-app")),
"my-app"
);
assert_eq!(project_name(Path::new("my-app")), "my-app");
assert_eq!(project_name(Path::new(".")), "unknown");
}
#[test]
fn find_git_root_finds_parent_with_dotgit() {
let tmp = tempfile::tempdir().expect("create temp dir");
let project = tmp.path().join("my-project");
let subdir = project.join("src").join("api");
fs::create_dir_all(&subdir).expect("create subdirs");
fs::create_dir(project.join(".git")).expect("create .git");
let root = find_git_root(&subdir);
assert_eq!(root, Some(project));
}
#[test]
fn find_git_root_returns_none_without_dotgit() {
let tmp = tempfile::tempdir().expect("create temp dir");
let subdir = tmp.path().join("no-git").join("src");
fs::create_dir_all(&subdir).expect("create subdirs");
assert!(find_git_root(&subdir).is_none());
}
#[test]
fn list_available_projects_returns_sorted() {
let (_tmp, repos) = setup_repos_dir();
fs::write(repos.join("zebra.db"), "").unwrap();
fs::write(repos.join("alpha.db"), "").unwrap();
fs::write(repos.join("middle.db"), "").unwrap();
fs::write(repos.join("notes.txt"), "").unwrap();
let projects = list_available_projects(&repos).unwrap();
let names: Vec<&str> = projects.iter().map(|(_, n)| n.as_str()).collect();
assert_eq!(names, vec!["alpha", "middle", "zebra"]);
}
#[test]
fn list_available_projects_empty_dir() {
let (_tmp, repos) = setup_repos_dir();
let projects = list_available_projects(&repos).unwrap();
assert!(projects.is_empty());
}
#[test]
fn list_available_projects_nonexistent_dir() {
let projects = list_available_projects(Path::new("/nonexistent/path")).unwrap();
assert!(projects.is_empty());
}
#[test]
fn submodule_ir_schema_is_current_empty_db_returns_true() {
let tmp = tempfile::tempdir().expect("create temp dir");
let db_path = tmp.path().join("sub.db");
let db = Database::open(&db_path).expect("open");
assert!(submodule_ir_schema_is_current(&db, "main"));
}
#[test]
fn submodule_ir_schema_is_current_detects_stale_rows() {
use seshat_core::test_helpers::make_project_file;
use seshat_storage::{FileIRRepository, SqliteFileIRRepository};
let tmp = tempfile::tempdir().expect("create temp dir");
let db_path = tmp.path().join("sub.db");
let db = Database::open(&db_path).expect("open");
let branch = BranchId::from("main");
let file = make_project_file(seshat_core::Language::Rust);
SqliteFileIRRepository::new(db.connection().clone())
.upsert(&branch, &file, None)
.expect("upsert");
assert!(submodule_ir_schema_is_current(&db, "main"));
{
let guard = db.connection().lock().expect("lock");
guard
.execute(
"UPDATE files_ir SET ir_schema_version = 0 WHERE branch_id = 'main'",
[],
)
.expect("update");
}
assert!(!submodule_ir_schema_is_current(&db, "main"));
}
#[test]
fn resolve_submodule_db_path_creates_parent_dirs() {
let project = "db-test-submod-nested";
let result = resolve_submodule_db_path(project, "libs/shared");
assert!(result.is_ok());
let path = result.unwrap();
assert!(
path.ends_with(format!("{project}/libs/shared.db")),
"Expected path ending with {project}/libs/shared.db, got: {}",
path.display()
);
if let Ok(repos) = xdg_repos_dir() {
let _ = fs::remove_dir_all(repos.join(project));
}
}
#[test]
fn resolve_serve_db_or_project_root_returns_auto_scan_when_no_db() {
let tmp_dir = tempfile::tempdir().expect("create temp dir");
let project_dir = tmp_dir.path().join("new-project");
fs::create_dir_all(&project_dir).unwrap();
let expected_root = std::fs::canonicalize(&project_dir).unwrap();
let result = resolve_serve_db_or_project_root(Some(&project_dir), &[]);
assert!(result.is_ok());
match result.unwrap() {
ServeTarget::AutoScan {
project_root,
db_path,
} => {
assert_eq!(project_root, expected_root);
assert!(db_path.to_string_lossy().ends_with("new-project.db"));
}
ServeTarget::ExistingDb { .. } => {
panic!("Expected AutoScan, got ExistingDb");
}
}
}
#[test]
fn resolve_serve_db_or_project_root_returns_existing_db_when_present() {
let repos_dir = xdg_repos_dir().expect("repos dir");
fs::create_dir_all(&repos_dir).expect("create repos dir");
let _cleanup = CleanupDir(repos_dir.join("_test_serve_existing"));
let project_name = "_test_serve_existing";
let db_path = repos_dir.join(format!("{project_name}.db"));
fs::write(&db_path, "").unwrap();
let project_dir = tempfile::tempdir().expect("temp dir");
let result = resolve_serve_db_or_project_root(
Some(project_dir.path().join(project_name).as_path()),
&[],
);
if let Ok(ServeTarget::ExistingDb {
db_path: resolved,
project_root,
}) = result
{
assert!(
resolved
.to_string_lossy()
.ends_with("_test_serve_existing.db")
);
assert_eq!(project_root, repos_dir);
}
}
#[test]
fn resolve_serve_db_or_project_root_uses_cwd_when_no_git() {
let tmp_dir = tempfile::tempdir().expect("create temp dir");
let project_dir = tmp_dir.path().join("no-git-project");
fs::create_dir_all(&project_dir).unwrap();
let expected_root = std::fs::canonicalize(&project_dir).unwrap();
let result = resolve_serve_db_or_project_root(Some(&project_dir), &[]);
assert!(result.is_ok());
match result.unwrap() {
ServeTarget::AutoScan { project_root, .. } => {
assert_eq!(project_root, expected_root);
}
ServeTarget::ExistingDb { .. } => {
panic!("Expected AutoScan, got ExistingDb");
}
}
}
#[test]
fn existing_db_project_root_is_used_for_branch_detection() {
let tmp_dir = tempfile::tempdir().expect("create temp dir");
let project_dir = tmp_dir.path().join("my-project");
fs::create_dir_all(&project_dir).unwrap();
let git_output = std::process::Command::new("git")
.arg("init")
.arg("-b")
.arg("feature-x")
.current_dir(&project_dir)
.output()
.expect("git init");
assert!(git_output.status.success(), "git init failed");
let repos_dir = xdg_repos_dir().expect("repos dir");
fs::create_dir_all(&repos_dir).expect("create repos dir");
let db_path = repos_dir.join("my-project.db");
let _cleanup = CleanupDir(db_path.clone());
fs::write(&db_path, "").unwrap();
let result = resolve_serve_db_or_project_root(Some(&project_dir), &[]);
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
let (resolved_root, db_file) = match result.unwrap() {
ServeTarget::ExistingDb {
project_root,
db_path,
} => (project_root, db_path),
_ => panic!("Expected ExistingDb"),
};
let expected_root = std::fs::canonicalize(&project_dir).unwrap();
assert_eq!(resolved_root, expected_root);
assert!(db_file.to_string_lossy().ends_with("my-project.db"));
let branch = detect_branch(&resolved_root);
assert_eq!(branch.as_str(), "feature-x");
}
#[test]
fn find_git_root_handles_worktree_gitfile() {
let tmp = tempfile::tempdir().expect("create temp dir");
let main_project = tmp.path().join("main-repo");
fs::create_dir_all(&main_project).expect("create main project");
fs::write(main_project.join("HEAD"), "ref: refs/heads/main").expect("write HEAD");
let worktree = tmp.path().join("worktree");
fs::create_dir_all(&worktree).expect("create worktree");
let main_git = main_project.join(".git");
let rel = main_git.strip_prefix(worktree.parent().unwrap()).unwrap();
let gitdir_rel = PathBuf::from("../").join(rel);
let gitdir_content = format!("gitdir: {}\n", gitdir_rel.display());
fs::write(worktree.join(".git"), gitdir_content).expect("write .git file");
let result = find_git_root(&worktree);
assert_eq!(result, Some(main_project));
}
#[test]
fn find_git_root_handles_nested_worktree() {
let tmp = tempfile::tempdir().expect("create temp dir");
let main_project = tmp.path().join("main-project");
fs::create_dir_all(&main_project).expect("create main project");
fs::create_dir(main_project.join(".git")).expect("create .git dir");
let worktree = main_project.join("worktree");
fs::create_dir_all(&worktree).expect("create worktree");
let rel = main_project
.strip_prefix(worktree.parent().unwrap())
.unwrap();
let gitdir_content = format!("gitdir: {}\n", rel.display());
fs::write(worktree.join(".git"), gitdir_content).expect("write .git file");
let subdir = worktree.join("src").join("api");
fs::create_dir_all(&subdir).expect("create subdir");
let root = find_git_root(&subdir);
assert_eq!(root, Some(main_project));
}
#[test]
fn get_current_branch_from_git_repo() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("README.md"), "# Test").expect("write file");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
let branch = get_current_branch(&repo);
assert_eq!(branch, Some("main".to_string()));
}
#[test]
fn get_current_branch_worktree() {
let dir = tempfile::tempdir().expect("tempdir");
let main_repo = dir.path().join("main-repo");
fs::create_dir_all(&main_repo).expect("create main repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&main_repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&main_repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&main_repo)
.output()
.expect("git config name");
fs::write(main_repo.join("README.md"), "# Main").expect("write");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&main_repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&main_repo)
.output()
.expect("git commit");
let worktree = main_repo.join("worktree");
let status = std::process::Command::new("git")
.args(["worktree", "add", "../worktree"])
.current_dir(&main_repo)
.status()
.expect("git worktree add");
assert!(status.success(), "git worktree add failed");
let branch = get_current_branch(&worktree);
assert_eq!(branch, Some("main".to_string()));
}
#[test]
fn get_current_branch_detached_head() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("file.txt"), "content").expect("write");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["checkout", "--detach", "HEAD"])
.current_dir(&repo)
.output()
.expect("git checkout detach");
let branch = get_current_branch(&repo);
assert!(
branch
.as_deref()
.is_some_and(|b| b.len() == 40 && b.chars().all(|c| c.is_ascii_hexdigit())),
"detached HEAD should return commit hash, got: {:?}",
branch
);
}
#[test]
fn gc_deletes_orphan_branches() {
let git_dir = tempfile::tempdir().expect("tempdir");
let repo = git_dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("README.md"), "# Test").expect("write file");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(&repo)
.output()
.expect("git checkout feature");
fs::write(repo.join("feature.txt"), "feat").expect("write");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "feature"])
.current_dir(&repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(&repo)
.output()
.expect("git checkout main");
let db_dir = tempfile::tempdir().expect("tempdir");
let db_path = db_dir.path().join("test.db");
let db = Database::open(&db_path).expect("open db");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let file_repo = SqliteFileIRRepository::new(db.connection().clone());
branch_repo
.switch_branch(&BranchId::from("main"))
.expect("switch to main");
use seshat_core::test_helpers::make_project_file;
let file = make_project_file(seshat_core::Language::Rust);
file_repo
.upsert(&BranchId::from("main"), &file, None)
.expect("upsert file");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("feature"))
.expect("snapshot feature");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-branch"))
.expect("snapshot orphan");
let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
assert_eq!(deleted, vec!["orphan-branch"]);
let remaining = branch_repo.list_branches().expect("list branches");
let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
assert!(names.contains(&"main"));
assert!(names.contains(&"feature"));
assert!(!names.contains(&"orphan-branch"));
}
#[test]
fn gc_preserves_current_branch() {
let git_dir = tempfile::tempdir().expect("tempdir");
let repo = git_dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
let db_dir = tempfile::tempdir().expect("tempdir");
let db_path = db_dir.path().join("test.db");
let db = Database::open(&db_path).expect("open db");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let file_repo = SqliteFileIRRepository::new(db.connection().clone());
branch_repo
.switch_branch(&BranchId::from("main"))
.expect("switch to main");
use seshat_core::test_helpers::make_project_file;
let file = make_project_file(seshat_core::Language::Rust);
file_repo
.upsert(&BranchId::from("main"), &file, None)
.expect("upsert file");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
.expect("snapshot some-branch");
let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
assert!(!deleted.contains(&"main".to_string()));
assert!(deleted.contains(&"some-branch".to_string()));
let remaining = branch_repo.list_branches().expect("list branches");
let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
assert!(names.contains(&"main"));
assert!(!names.contains(&"some-branch"));
}
#[test]
fn gc_preserves_main() {
let git_dir = tempfile::tempdir().expect("tempdir");
let repo = git_dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
let db_dir = tempfile::tempdir().expect("tempdir");
let db_path = db_dir.path().join("test.db");
let db = Database::open(&db_path).expect("open db");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let file_repo = SqliteFileIRRepository::new(db.connection().clone());
branch_repo
.switch_branch(&BranchId::from("main"))
.expect("switch to main");
use seshat_core::test_helpers::make_project_file;
let file = make_project_file(seshat_core::Language::Rust);
file_repo
.upsert(&BranchId::from("main"), &file, None)
.expect("upsert file");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
.expect("snapshot some-branch");
let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
assert!(!deleted.contains(&"main".to_string()));
let remaining = branch_repo.list_branches().expect("list branches");
let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
assert!(names.contains(&"main"));
}
#[test]
fn gc_preserves_master() {
let git_dir = tempfile::tempdir().expect("tempdir");
let repo = git_dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
let db_dir = tempfile::tempdir().expect("tempdir");
let db_path = db_dir.path().join("test.db");
let db = Database::open(&db_path).expect("open db");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let file_repo = SqliteFileIRRepository::new(db.connection().clone());
branch_repo
.switch_branch(&BranchId::from("master"))
.expect("switch to master");
use seshat_core::test_helpers::make_project_file;
let file = make_project_file(seshat_core::Language::Rust);
file_repo
.upsert(&BranchId::from("master"), &file, None)
.expect("upsert file");
branch_repo
.create_snapshot(&BranchId::from("master"), &BranchId::from("some-branch"))
.expect("snapshot some-branch");
let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
assert!(!deleted.contains(&"master".to_string()));
let remaining = branch_repo.list_branches().expect("list branches");
let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
assert!(names.contains(&"master"));
}
#[test]
fn gc_preserves_current_branch_not_in_git() {
let git_dir = tempfile::tempdir().expect("tempdir");
let repo = git_dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("README.md"), "# Test").expect("write file");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(&repo)
.output()
.expect("git checkout feature");
std::process::Command::new("git")
.args(["branch", "-D", "feature"])
.current_dir(&repo)
.output()
.expect("git branch -D feature");
std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(&repo)
.output()
.expect("git checkout main");
let db_dir = tempfile::tempdir().expect("tempdir");
let db_path = db_dir.path().join("test.db");
let db = Database::open(&db_path).expect("open db");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let file_repo = SqliteFileIRRepository::new(db.connection().clone());
branch_repo
.switch_branch(&BranchId::from("main"))
.expect("switch to main");
use seshat_core::test_helpers::make_project_file;
let file = make_project_file(seshat_core::Language::Rust);
file_repo
.upsert(&BranchId::from("main"), &file, None)
.expect("upsert file");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("feature-branch"))
.expect("snapshot feature-branch");
let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
assert!(
!deleted.contains(&"main".to_string()),
"main should be preserved as current branch"
);
assert!(
deleted.contains(&"feature-branch".to_string()),
"feature-branch should be deleted (not current, not in git, not protected)"
);
let remaining = branch_repo.list_branches().expect("list branches");
let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
assert!(names.contains(&"main"));
assert!(!names.contains(&"feature-branch"));
}
#[test]
fn gc_handles_detached_head() {
let git_dir = tempfile::tempdir().expect("tempdir");
let repo = git_dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("README.md"), "# Test").expect("write file");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["checkout", "--detach", "HEAD"])
.current_dir(&repo)
.output()
.expect("git checkout detach");
let db_dir = tempfile::tempdir().expect("tempdir");
let db_path = db_dir.path().join("test.db");
let db = Database::open(&db_path).expect("open db");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let file_repo = SqliteFileIRRepository::new(db.connection().clone());
branch_repo
.switch_branch(&BranchId::from("main"))
.expect("switch to main");
use seshat_core::test_helpers::make_project_file;
let file = make_project_file(seshat_core::Language::Rust);
file_repo
.upsert(&BranchId::from("main"), &file, None)
.expect("upsert file");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
.expect("snapshot some-branch");
let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
assert!(
!deleted.contains(&"main".to_string()),
"main should be preserved even in detached HEAD"
);
assert!(
deleted.contains(&"some-branch".to_string()),
"some-branch should be deleted"
);
let remaining = branch_repo.list_branches().expect("list branches");
let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
assert!(names.contains(&"main"));
assert!(!names.contains(&"some-branch"));
}
#[test]
fn gc_deletes_all_orphans() {
let git_dir = tempfile::tempdir().expect("tempdir");
let repo = git_dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("README.md"), "# Test").expect("write file");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
let db_dir = tempfile::tempdir().expect("tempdir");
let db_path = db_dir.path().join("test.db");
let db = Database::open(&db_path).expect("open db");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let file_repo = SqliteFileIRRepository::new(db.connection().clone());
branch_repo
.switch_branch(&BranchId::from("main"))
.expect("switch to main");
use seshat_core::test_helpers::make_project_file;
let file = make_project_file(seshat_core::Language::Rust);
file_repo
.upsert(&BranchId::from("main"), &file, None)
.expect("upsert file");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-1"))
.expect("snapshot orphan-1");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-2"))
.expect("snapshot orphan-2");
branch_repo
.create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-3"))
.expect("snapshot orphan-3");
let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
assert_eq!(deleted.len(), 3, "should delete all 3 orphans");
assert!(deleted.contains(&"orphan-1".to_string()));
assert!(deleted.contains(&"orphan-2".to_string()));
assert!(deleted.contains(&"orphan-3".to_string()));
assert!(!deleted.contains(&"main".to_string()));
let remaining = branch_repo.list_branches().expect("list branches");
let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
assert_eq!(names.len(), 1);
assert!(names.contains(&"main"));
assert!(!names.contains(&"orphan-1"));
assert!(!names.contains(&"orphan-2"));
assert!(!names.contains(&"orphan-3"));
}
#[test]
fn detect_branch_normal_repo() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("README.md"), "# Test").expect("write file");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
let branch = detect_branch(&repo);
assert_eq!(branch, "main");
}
#[test]
fn detect_branch_worktree_file() {
let dir = tempfile::tempdir().expect("tempdir");
let main_repo = dir.path().join("main-repo");
fs::create_dir_all(&main_repo).expect("create main repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&main_repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&main_repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&main_repo)
.output()
.expect("git config name");
fs::write(main_repo.join("README.md"), "# Main").expect("write");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&main_repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&main_repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["branch", "wt-test-branch-1"])
.current_dir(&main_repo)
.output()
.expect("git branch wt-test-branch-1");
let worktree = dir.path().join("wt-on-test");
let status = std::process::Command::new("git")
.args([
"worktree",
"add",
worktree.to_str().unwrap(),
"wt-test-branch-1",
])
.current_dir(&main_repo)
.status()
.expect("git worktree add wt-test-branch-1");
assert!(status.success(), "git worktree add wt-test-branch-1 failed");
let branch = detect_branch(&worktree);
assert_eq!(branch, "wt-test-branch-1");
}
#[test]
fn detect_branch_worktree_nested() {
let dir = tempfile::tempdir().expect("tempdir");
let main_repo = dir.path().join("main-repo");
fs::create_dir_all(&main_repo).expect("create main repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&main_repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&main_repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&main_repo)
.output()
.expect("git config name");
fs::write(main_repo.join("README.md"), "# Main").expect("write");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&main_repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&main_repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["branch", "wt-test-branch-2"])
.current_dir(&main_repo)
.output()
.expect("git branch wt-test-branch-2");
let worktree = dir.path().join("wt-nested-on-test");
let status = std::process::Command::new("git")
.args([
"worktree",
"add",
worktree.to_str().unwrap(),
"wt-test-branch-2",
])
.current_dir(&main_repo)
.status()
.expect("git worktree add wt-test-branch-2");
assert!(status.success(), "git worktree add wt-test-branch-2 failed");
let subdir = worktree.join("src").join("api");
fs::create_dir_all(&subdir).expect("create subdir");
let branch = detect_branch(&subdir);
assert_eq!(branch, "wt-test-branch-2");
}
#[test]
fn detect_branch_detached_head() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&repo)
.output()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo)
.output()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo)
.output()
.expect("git config name");
fs::write(repo.join("file.txt"), "content").expect("write");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.output()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo)
.output()
.expect("git commit");
std::process::Command::new("git")
.args(["checkout", "--detach", "HEAD"])
.current_dir(&repo)
.output()
.expect("git checkout detach");
let branch = detect_branch(&repo);
assert_eq!(branch.len(), 40);
assert!(branch.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn detect_branch_no_git() {
let dir = tempfile::tempdir().expect("tempdir");
let no_git = dir.path().join("no-git-project");
fs::create_dir_all(&no_git).expect("create dir");
let branch = detect_branch(&no_git);
assert_eq!(branch, "main");
}
#[test]
fn unix_now_returns_recent_timestamp() {
let now = unix_now();
assert!(
now > 1_735_689_600,
"expected post-2025 unix time, got {now}"
);
}
#[test]
fn xdg_repos_dir_path_shape() {
let dir = xdg_repos_dir().expect("should resolve");
assert!(dir.ends_with("repos"));
assert!(dir.parent().unwrap().ends_with("seshat"));
}
#[test]
fn resolved_project_uses_project_filename_for_non_git_dir() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("my-app");
fs::create_dir_all(&project).unwrap();
let resolved = resolve_project(Some(&project), "test").expect("resolve");
assert_eq!(resolved.project_name, "my-app");
assert_eq!(
resolved.db_path.file_name().unwrap().to_string_lossy(),
"my-app.db"
);
assert!(resolved.db_path.parent().unwrap().ends_with("repos"));
assert!(resolved.git_root.is_none());
}
#[test]
fn resolve_submodule_db_path_creates_parent_and_uses_mount() {
let unique = format!("seshat-test-{}", unix_now());
let result = resolve_submodule_db_path(&unique, "libs/shared").expect("resolve");
assert!(result.ends_with("libs/shared.db"));
let parent = result.parent().unwrap();
assert!(parent.is_dir(), "parent dir should be created: {parent:?}");
if let Some(repos) = parent.parent() {
if repos.file_name().and_then(|s| s.to_str()) == Some(&unique) {
let _ = fs::remove_dir_all(repos);
}
}
}
#[test]
fn count_files_any_schema_empty_db_returns_zero() {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(dir.path().join("c.db")).unwrap();
assert_eq!(count_files_any_schema(&db, "main"), 0);
}
#[test]
fn count_conventions_empty_db_returns_zero() {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(dir.path().join("c.db")).unwrap();
assert_eq!(count_conventions(&db, "main"), 0);
}
#[test]
fn count_conventions_seeded_returns_count() {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(dir.path().join("c.db")).unwrap();
{
let g = db.connection().lock().unwrap();
for desc in &["a", "b", "c"] {
g.execute(
"INSERT INTO nodes (branch_id, nature, weight, confidence,
adoption_count, total_count, description, ext_data)
VALUES ('main', 'convention', 'strong', 0.9, 1, 1, ?1, NULL)",
params![*desc],
)
.unwrap();
}
}
assert_eq!(count_conventions(&db, "main"), 3);
assert_eq!(count_conventions(&db, "other"), 0);
}
#[test]
fn load_project_info_defaults_for_empty_db() {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(dir.path().join("c.db")).unwrap();
let info = load_project_info(&db);
assert_eq!(info.branch.0, "main");
assert_eq!(info.file_count, 0);
assert_eq!(info.convention_count, 0);
}
#[test]
fn read_head_in_gitdir_ref_form() {
let dir = tempfile::tempdir().unwrap();
let gitdir = dir.path();
fs::write(gitdir.join("HEAD"), "ref: refs/heads/feature/my-branch\n").unwrap();
let result = read_head_in_gitdir(gitdir);
assert_eq!(result.as_deref(), Some("feature/my-branch"));
}
#[test]
fn read_head_in_gitdir_detached_full_hash() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("HEAD"),
"0123456789abcdef0123456789abcdef01234567\n",
)
.unwrap();
let result = read_head_in_gitdir(dir.path());
assert_eq!(
result.as_deref(),
Some("0123456789abcdef0123456789abcdef01234567")
);
}
#[test]
fn read_head_in_gitdir_detached_abbreviated_hash() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("HEAD"), "deadbee\n").unwrap();
let result = read_head_in_gitdir(dir.path());
assert_eq!(result.as_deref(), Some("deadbee"));
}
#[test]
fn read_head_in_gitdir_unknown_ref_namespace_returns_none() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("HEAD"), "ref: refs/tags/v1.0\n").unwrap();
assert!(read_head_in_gitdir(dir.path()).is_none());
}
#[test]
fn read_head_in_gitdir_garbage_returns_none() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("HEAD"), "not a hash and not a ref").unwrap();
assert!(read_head_in_gitdir(dir.path()).is_none());
}
#[test]
fn read_head_in_gitdir_missing_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(read_head_in_gitdir(dir.path()).is_none());
}
#[test]
fn find_git_dir_returns_dir_variant_when_dotgit_is_directory() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("p");
fs::create_dir_all(project.join(".git").join("subdir")).unwrap();
match find_git_dir(&project) {
Some(GitDir::Dir(p)) => assert!(p.ends_with(".git")),
Some(GitDir::File(_)) => panic!("expected GitDir::Dir, got File"),
None => panic!("expected GitDir::Dir, got None"),
}
}
#[test]
fn find_git_dir_returns_file_variant_when_dotgit_is_file() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path().join("wt");
fs::create_dir_all(&worktree).unwrap();
fs::write(worktree.join(".git"), "gitdir: /tmp/some-elsewhere").unwrap();
match find_git_dir(&worktree) {
Some(GitDir::File(p)) => assert!(p.ends_with(".git")),
Some(GitDir::Dir(_)) => panic!("expected GitDir::File, got Dir"),
None => panic!("expected GitDir::File, got None"),
}
}
#[test]
fn find_git_dir_walks_up_from_subdir() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("p");
let nested = project.join("a").join("b");
fs::create_dir_all(&nested).unwrap();
fs::create_dir_all(project.join(".git")).unwrap();
let result = find_git_dir(&nested);
assert!(matches!(result, Some(GitDir::Dir(_))));
}
#[test]
fn find_git_dir_returns_none_when_no_dotgit() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("no-git");
fs::create_dir_all(&project).unwrap();
let _ = find_git_dir(&project);
}
#[test]
fn gc_branch_snapshots_empty_db_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(dir.path().join("c.db")).unwrap();
let deleted = gc_branch_snapshots(&db, dir.path()).unwrap();
assert!(deleted.is_empty());
}
fn fake_home_with_subdir(name: &str) -> (tempfile::TempDir, PathBuf, PathBuf) {
let tmp = tempfile::tempdir().expect("create temp dir");
let home = tmp.path().to_path_buf();
let cwd = home.join(name);
fs::create_dir_all(&cwd).expect("create cwd subdir");
(tmp, home, cwd)
}
#[test]
fn check_serve_dangerous_cwd_refuses_when_in_home_with_no_git() {
let (_tmp, home, cwd) = fake_home_with_subdir("scratchpad");
let result = check_serve_dangerous_cwd(None, &[], &cwd, Some(&home));
match result {
Err(CliError::DangerousCwd { path, hint }) => {
let expected = std::fs::canonicalize(&cwd).unwrap_or(cwd.clone());
let got = std::fs::canonicalize(&path).unwrap_or(path.clone());
assert_eq!(got, expected, "path should reflect offending cwd");
assert!(
hint.contains("seshat scan"),
"hint missing scan suggestion: {hint}"
);
assert!(
hint.contains("seshat serve /"),
"hint missing positional-repo override suggestion: {hint}"
);
assert!(hint.contains("cd "), "hint missing cd suggestion: {hint}");
}
other => panic!("expected DangerousCwd, got {other:?}"),
}
}
#[test]
fn check_serve_dangerous_cwd_proceeds_when_inside_git_repo() {
let (_tmp, home, cwd) = fake_home_with_subdir("real-project");
fs::create_dir(cwd.join(".git")).expect("create .git dir");
let result = check_serve_dangerous_cwd(None, &[], &cwd, Some(&home));
assert!(
result.is_ok(),
"expected Ok when cwd is inside a git repo, got {result:?}"
);
}
#[test]
fn check_serve_dangerous_cwd_refuses_when_stray_git_lives_at_dangerous_root() {
let (_tmp, home, cwd) = fake_home_with_subdir("scratchpad");
fs::create_dir(home.join(".git")).expect("create stray .git at home");
let result = check_serve_dangerous_cwd(None, &[], &cwd, Some(&home));
match result {
Err(CliError::DangerousCwd { .. }) => {}
other => panic!("expected DangerousCwd despite stray ~/.git, got {other:?}"),
}
}
#[test]
fn check_serve_dangerous_cwd_skipped_when_explicit_repo_provided() {
let (_tmp, home, cwd) = fake_home_with_subdir("scratchpad");
let safe_repo = PathBuf::from("/totally/unrelated/path");
let result = check_serve_dangerous_cwd(Some(&safe_repo), &[], &cwd, Some(&home));
assert!(
result.is_ok(),
"explicit --repo must bypass the cwd gate, got {result:?}"
);
}
#[test]
fn check_repo_override_dangerous_returns_warn_for_dangerous_path_no_git() {
let (_tmp, home, project_root) = fake_home_with_subdir("inside-home");
let warn =
check_repo_override_dangerous(Some(&project_root), &[], &project_root, Some(&home));
let msg = warn.expect("expected warn message for dangerous explicit repo");
assert!(msg.contains("⚠️"), "warn message missing ⚠️ prefix: {msg}");
assert!(
msg.contains("explicit repo path"),
"warn message must explain the explicit-repo override: {msg}"
);
assert!(msg.lines().count() >= 2, "warn must be multi-line: {msg}");
}
#[test]
fn check_repo_override_dangerous_silent_when_project_root_is_git_repo() {
let (_tmp, home, project_root) = fake_home_with_subdir("real-project");
fs::create_dir(project_root.join(".git")).expect("create .git");
let warn =
check_repo_override_dangerous(Some(&project_root), &[], &project_root, Some(&home));
assert!(
warn.is_none(),
"git-rooted --repo path must not warn, got {warn:?}"
);
}
#[test]
fn check_repo_override_dangerous_skipped_when_no_explicit_repo() {
let (_tmp, home, project_root) = fake_home_with_subdir("inside-home");
let warn = check_repo_override_dangerous(None, &[], &project_root, Some(&home));
assert!(warn.is_none(), "no explicit_repo → no override warn");
}
}