use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
fn registry_path() -> PathBuf {
crate::config::SemantexConfig::semantex_home().join("projects.json")
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct BranchEntry {
pub branch: String,
pub branch_key: String,
#[serde(default)]
pub last_indexed_ts: i64,
#[serde(default)]
pub head_commit: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProjectEntry {
pub path: PathBuf,
#[serde(default)]
pub project_id: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub branches: Vec<BranchEntry>,
#[serde(default)]
pub embedder_fingerprint: String,
#[serde(default)]
pub is_worktree: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RegistryV2 {
pub version: u32,
pub projects: Vec<ProjectEntry>,
}
impl Default for RegistryV2 {
fn default() -> Self {
Self {
version: 2,
projects: Vec::new(),
}
}
}
pub fn project_id_for_path(canonical: &Path) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let mut hasher = Sha256::new();
hasher.update(canonical.to_string_lossy().as_bytes());
let digest = hasher.finalize();
digest[..8].iter().fold(String::new(), |mut out, b| {
let _ = write!(out, "{b:02x}");
out
})
}
fn display_name_for_path(path: &Path) -> String {
path.file_name().map_or_else(
|| path.to_string_lossy().to_string(),
|n| n.to_string_lossy().to_string(),
)
}
fn load_from(path: &Path) -> RegistryV2 {
let Ok(content) = std::fs::read_to_string(path) else {
return RegistryV2::default();
};
if let Ok(v2) = serde_json::from_str::<RegistryV2>(&content) {
return v2;
}
if let Ok(v1_paths) = serde_json::from_str::<Vec<String>>(&content) {
let projects = v1_paths
.into_iter()
.map(|p| {
let path = PathBuf::from(&p);
ProjectEntry {
project_id: project_id_for_path(&path),
display_name: display_name_for_path(&path),
path,
branches: Vec::new(),
embedder_fingerprint: String::new(),
is_worktree: false,
}
})
.collect();
return RegistryV2 {
version: 2,
projects,
};
}
RegistryV2::default()
}
pub fn load() -> RegistryV2 {
load_from(®istry_path())
}
fn save_to(path: &Path, registry: &RegistryV2) -> bool {
let Some(parent) = path.parent() else {
return false;
};
if std::fs::create_dir_all(parent).is_err() {
return false;
}
let Ok(json) = serde_json::to_string_pretty(registry) else {
return false;
};
let tmp = parent.join(format!(
".{}.tmp.{}",
path.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default(),
std::process::id()
));
if std::fs::write(&tmp, json).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
let ok = std::fs::rename(&tmp, path).is_ok();
if !ok {
let _ = std::fs::remove_file(&tmp);
}
ok
}
pub fn read_all() -> Vec<PathBuf> {
load().projects.into_iter().map(|p| p.path).collect()
}
pub fn read_all_v2() -> RegistryV2 {
load()
}
pub fn is_system_temp_root(path: &Path) -> bool {
let mut candidates = vec![
std::env::temp_dir(),
PathBuf::from("/tmp"),
PathBuf::from("/private/tmp"),
];
candidates.dedup();
candidates
.iter()
.any(|c| path == c || c.canonicalize().is_ok_and(|canon| canon == path))
}
pub fn is_under_system_temp_root(path: &Path) -> bool {
let mut candidates = vec![
std::env::temp_dir(),
PathBuf::from("/tmp"),
PathBuf::from("/private/tmp"),
];
candidates.dedup();
candidates
.iter()
.any(|c| path.starts_with(c) || c.canonicalize().is_ok_and(|canon| path.starts_with(canon)))
}
const MULTI_REPO_THRESHOLD: usize = 3;
pub fn is_likely_multi_repo_container(path: &Path) -> bool {
if path.join(".git").exists() {
return false;
}
let Ok(entries) = std::fs::read_dir(path) else {
return false;
};
let nested_repo_count = entries
.filter_map(Result::ok)
.filter(|e| e.path().join(".git").exists())
.count();
nested_repo_count >= MULTI_REPO_THRESHOLD
}
pub fn is_worktree_checkout(path: &Path) -> bool {
let (Some(git_dir), Some(common_dir)) = (
git_rev_parse(path, "--git-dir"),
git_rev_parse(path, "--git-common-dir"),
) else {
return false;
};
match (
resolve_under(path, &git_dir),
resolve_under(path, &common_dir),
) {
(Some(g), Some(c)) => g != c,
_ => false,
}
}
fn git_rev_parse(path: &Path, flag: &str) -> Option<String> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", flag])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
fn resolve_under(path: &Path, git_path: &str) -> Option<PathBuf> {
let p = Path::new(git_path);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
path.join(p)
};
joined.canonicalize().ok()
}
fn retain_at(path: &Path, mut keep: impl FnMut(&ProjectEntry) -> bool) -> usize {
let mut registry = load_from(path);
let before = registry.projects.len();
registry.projects.retain(|p| keep(p));
let removed = before - registry.projects.len();
if removed > 0 {
save_to(path, ®istry);
}
removed
}
pub fn retain(keep: impl FnMut(&ProjectEntry) -> bool) -> usize {
retain_at(®istry_path(), keep)
}
fn register_at(path: &Path, canonical: &Path) {
if is_system_temp_root(canonical) {
return;
}
let mut registry = load_from(path);
if registry.projects.iter().any(|p| p.path == canonical) {
return; }
registry.projects.push(ProjectEntry {
path: canonical.to_path_buf(),
project_id: project_id_for_path(canonical),
display_name: display_name_for_path(canonical),
branches: Vec::new(),
embedder_fingerprint: String::new(),
is_worktree: is_worktree_checkout(canonical),
});
save_to(path, ®istry);
}
pub fn register(canonical: &Path) {
register_at(®istry_path(), canonical);
}
#[allow(clippy::too_many_arguments)]
fn upsert_branch_at(
path: &Path,
canonical: &Path,
branch: &str,
branch_key: &str,
last_indexed_ts: i64,
head_commit: Option<String>,
embedder_fingerprint: &str,
) {
if is_system_temp_root(canonical) {
return;
}
let mut registry = load_from(path);
let entry = if let Some(existing) = registry.projects.iter_mut().find(|p| p.path == canonical) {
existing
} else {
registry.projects.push(ProjectEntry {
path: canonical.to_path_buf(),
project_id: project_id_for_path(canonical),
display_name: display_name_for_path(canonical),
branches: Vec::new(),
embedder_fingerprint: String::new(),
is_worktree: false, });
registry.projects.last_mut().expect("just pushed")
};
entry.embedder_fingerprint = embedder_fingerprint.to_string();
entry.is_worktree = is_worktree_checkout(canonical);
if let Some(existing_branch) = entry
.branches
.iter_mut()
.find(|b| b.branch_key == branch_key)
{
existing_branch.branch = branch.to_string();
existing_branch.last_indexed_ts = last_indexed_ts;
existing_branch.head_commit = head_commit;
} else {
entry.branches.push(BranchEntry {
branch: branch.to_string(),
branch_key: branch_key.to_string(),
last_indexed_ts,
head_commit,
});
}
save_to(path, ®istry);
}
pub fn upsert_branch(
canonical: &Path,
branch: &str,
branch_key: &str,
last_indexed_ts: i64,
head_commit: Option<String>,
embedder_fingerprint: &str,
) {
upsert_branch_at(
®istry_path(),
canonical,
branch,
branch_key,
last_indexed_ts,
head_commit,
embedder_fingerprint,
);
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp_registry() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("projects.json");
(tmp, path)
}
fn git_test(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("git must be on PATH for these tests");
assert!(status.success(), "git {args:?} failed");
}
fn init_git_repo(dir: &Path) {
git_test(dir, &["init", "-q"]);
git_test(dir, &["config", "user.email", "test@example.com"]);
git_test(dir, &["config", "user.name", "Test User"]);
std::fs::write(dir.join("a.txt"), "a").unwrap();
git_test(dir, &["add", "."]);
git_test(dir, &["commit", "-q", "-m", "init"]);
git_test(dir, &["branch", "-M", "main"]);
}
#[test]
fn is_worktree_checkout_false_for_non_git_dir() {
let tmp = TempDir::new().unwrap();
assert!(!is_worktree_checkout(tmp.path()));
}
#[test]
fn is_worktree_checkout_distinguishes_main_from_linked_worktree() {
let main_repo = TempDir::new().unwrap();
init_git_repo(main_repo.path());
let worktree_dir = TempDir::new().unwrap();
std::fs::remove_dir(worktree_dir.path()).unwrap();
git_test(
main_repo.path(),
&[
"worktree",
"add",
"-q",
"-b",
"feature-x",
worktree_dir.path().to_str().unwrap(),
],
);
assert!(!is_worktree_checkout(main_repo.path()));
assert!(is_worktree_checkout(worktree_dir.path()));
}
#[test]
fn register_at_stamps_is_worktree_correctly() {
let main_repo = TempDir::new().unwrap();
init_git_repo(main_repo.path());
let worktree_dir = TempDir::new().unwrap();
std::fs::remove_dir(worktree_dir.path()).unwrap();
git_test(
main_repo.path(),
&[
"worktree",
"add",
"-q",
"-b",
"feature-y",
worktree_dir.path().to_str().unwrap(),
],
);
let (_tmp, reg) = tmp_registry();
let main_canonical = main_repo.path().canonicalize().unwrap();
let worktree_canonical = worktree_dir.path().canonicalize().unwrap();
register_at(®, &main_canonical);
register_at(®, &worktree_canonical);
let registry = load_from(®);
let main_entry = registry
.projects
.iter()
.find(|p| p.path == main_canonical)
.unwrap();
let worktree_entry = registry
.projects
.iter()
.find(|p| p.path == worktree_canonical)
.unwrap();
assert!(!main_entry.is_worktree);
assert!(worktree_entry.is_worktree);
}
#[test]
fn register_and_read_all_round_trip() {
let (_tmp, reg) = tmp_registry();
let p1 = PathBuf::from("/tmp/project-one");
let p2 = PathBuf::from("/tmp/project-two");
register_at(®, &p1);
register_at(®, &p2);
register_at(®, &p1);
let all: Vec<PathBuf> = load_from(®)
.projects
.into_iter()
.map(|p| p.path)
.collect();
assert_eq!(all.len(), 2);
assert!(all.contains(&p1));
assert!(all.contains(&p2));
let v2 = load_from(®);
assert_eq!(v2.version, 2);
let entry = v2.projects.iter().find(|p| p.path == p1).unwrap();
assert_eq!(entry.project_id, project_id_for_path(&p1));
assert_eq!(entry.display_name, "project-one");
}
#[test]
fn multi_repo_container_true_above_threshold_when_root_has_no_git() {
let tmp = TempDir::new().unwrap();
for name in ["repo-a", "repo-b", "repo-c"] {
std::fs::create_dir_all(tmp.path().join(name).join(".git")).unwrap();
}
assert!(is_likely_multi_repo_container(tmp.path()));
}
#[test]
fn multi_repo_container_false_below_threshold() {
let tmp = TempDir::new().unwrap();
for name in ["repo-a", "repo-b"] {
std::fs::create_dir_all(tmp.path().join(name).join(".git")).unwrap();
}
assert!(!is_likely_multi_repo_container(tmp.path()));
}
#[test]
fn multi_repo_container_false_when_root_itself_is_a_repo() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
for name in ["sub-a", "sub-b", "sub-c"] {
std::fs::create_dir_all(tmp.path().join(name).join(".git")).unwrap();
}
assert!(!is_likely_multi_repo_container(tmp.path()));
}
#[test]
fn multi_repo_container_false_for_plain_non_git_directory() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
assert!(!is_likely_multi_repo_container(tmp.path()));
}
#[test]
fn is_system_temp_root_true_for_env_temp_dir_and_unix_aliases() {
assert!(is_system_temp_root(&std::env::temp_dir()));
assert!(is_system_temp_root(Path::new("/tmp")));
assert!(is_system_temp_root(Path::new("/private/tmp")));
}
#[test]
fn is_system_temp_root_false_for_a_subdirectory_or_unrelated_path() {
assert!(!is_system_temp_root(Path::new("/tmp/some-scratch-repo")));
assert!(!is_system_temp_root(Path::new("/Users/dev/my-project")));
}
#[test]
fn is_under_system_temp_root_true_for_root_and_any_depth_subdirectory() {
assert!(is_under_system_temp_root(Path::new("/tmp")));
assert!(is_under_system_temp_root(Path::new(
"/tmp/some-scratch-repo"
)));
assert!(is_under_system_temp_root(Path::new(
"/private/tmp/pytest-of-tk/pytest-1/tiny_corpus"
)));
}
#[test]
fn is_under_system_temp_root_false_for_unrelated_path() {
assert!(!is_under_system_temp_root(Path::new(
"/Users/dev/my-project"
)));
}
#[test]
fn register_at_refuses_the_system_temp_root() {
let (_tmp, reg) = tmp_registry();
register_at(®, &std::env::temp_dir());
assert!(load_from(®).projects.is_empty());
}
#[test]
fn upsert_branch_at_refuses_the_system_temp_root() {
let (_tmp, reg) = tmp_registry();
upsert_branch_at(
®,
&std::env::temp_dir(),
"main",
"main-abc",
1,
None,
"fp",
);
assert!(load_from(®).projects.is_empty());
}
#[test]
fn retain_removes_only_entries_the_predicate_rejects() {
let (_tmp, reg) = tmp_registry();
let p1 = PathBuf::from("/tmp/keep-me");
let p2 = PathBuf::from("/tmp/drop-me");
register_at(®, &p1);
register_at(®, &p2);
let removed = retain_at(®, |p| p.path != p2);
assert_eq!(removed, 1);
let all: Vec<PathBuf> = load_from(®)
.projects
.into_iter()
.map(|p| p.path)
.collect();
assert_eq!(all, vec![p1]);
}
#[test]
fn retain_is_a_no_op_write_when_nothing_is_removed() {
let (_tmp, reg) = tmp_registry();
let p1 = PathBuf::from("/tmp/keep-me");
register_at(®, &p1);
let removed = retain_at(®, |_| true);
assert_eq!(removed, 0);
let all: Vec<PathBuf> = load_from(®)
.projects
.into_iter()
.map(|p| p.path)
.collect();
assert_eq!(all, vec![p1]);
}
#[test]
fn v1_array_is_upgraded_to_v2_on_load() {
let (_tmp, reg) = tmp_registry();
let v1_json =
serde_json::to_string(&vec!["/repo/a".to_string(), "/repo/b".to_string()]).unwrap();
std::fs::write(®, v1_json).unwrap();
let v2 = load_from(®);
assert_eq!(v2.version, 2);
assert_eq!(v2.projects.len(), 2);
assert!(v2.projects.iter().any(|p| p.path == Path::new("/repo/a")));
register_at(®, Path::new("/repo/c"));
let raw = std::fs::read_to_string(®).unwrap();
let persisted: RegistryV2 = serde_json::from_str(&raw).unwrap();
assert_eq!(persisted.version, 2);
assert_eq!(persisted.projects.len(), 3);
}
#[test]
fn upsert_branch_creates_project_and_updates_existing_branch() {
let (_tmp, reg) = tmp_registry();
let proj = PathBuf::from("/tmp/branchy-project");
upsert_branch_at(
®,
&proj,
"main",
"main-abc12345",
100,
Some("c1".into()),
"fp1",
);
let v2 = load_from(®);
let entry = v2.projects.iter().find(|p| p.path == proj).unwrap();
assert_eq!(entry.branches.len(), 1);
assert_eq!(entry.branches[0].last_indexed_ts, 100);
assert_eq!(entry.embedder_fingerprint, "fp1");
upsert_branch_at(
®,
&proj,
"main",
"main-abc12345",
200,
Some("c2".into()),
"fp2",
);
let v2 = load_from(®);
let entry = v2.projects.iter().find(|p| p.path == proj).unwrap();
assert_eq!(entry.branches.len(), 1);
assert_eq!(entry.branches[0].last_indexed_ts, 200);
assert_eq!(entry.branches[0].head_commit, Some("c2".to_string()));
assert_eq!(entry.embedder_fingerprint, "fp2");
upsert_branch_at(®, &proj, "develop", "develop-def67890", 50, None, "fp2");
let v2 = load_from(®);
let entry = v2.projects.iter().find(|p| p.path == proj).unwrap();
assert_eq!(entry.branches.len(), 2);
}
#[test]
fn upsert_branch_refreshes_is_worktree_for_existing_entries() {
let main_repo = TempDir::new().unwrap();
init_git_repo(main_repo.path());
let worktree_dir = TempDir::new().unwrap();
std::fs::remove_dir(worktree_dir.path()).unwrap();
git_test(
main_repo.path(),
&[
"worktree",
"add",
"-q",
"-b",
"feature-z",
worktree_dir.path().to_str().unwrap(),
],
);
let (_tmp, reg) = tmp_registry();
let worktree_canonical = worktree_dir.path().canonicalize().unwrap();
let mut registry = RegistryV2 {
version: 2,
projects: vec![ProjectEntry {
path: worktree_canonical.clone(),
project_id: project_id_for_path(&worktree_canonical),
display_name: display_name_for_path(&worktree_canonical),
branches: Vec::new(),
embedder_fingerprint: String::new(),
is_worktree: false, }],
};
save_to(®, ®istry);
registry = load_from(®);
assert!(!registry.projects[0].is_worktree);
upsert_branch_at(
®,
&worktree_canonical,
"feature-z",
"feature-z-abc",
100,
None,
"fp",
);
registry = load_from(®);
let entry = registry
.projects
.iter()
.find(|p| p.path == worktree_canonical)
.unwrap();
assert!(
entry.is_worktree,
"upsert_branch_at must re-detect is_worktree for existing entries"
);
}
#[test]
fn save_is_atomic_no_tmp_file_left_behind() {
let (_tmp, reg) = tmp_registry();
register_at(®, Path::new("/tmp/atomic-project"));
assert!(reg.exists());
let entries: Vec<_> = std::fs::read_dir(reg.parent().unwrap())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries, vec!["projects.json".to_string()], "{entries:?}");
}
#[test]
fn corrupt_registry_loads_as_empty_and_is_replaced_on_next_save() {
let (_tmp, reg) = tmp_registry();
std::fs::write(®, "{ this is not json").unwrap();
assert!(load_from(®).projects.is_empty());
register_at(®, Path::new("/tmp/recovered"));
assert_eq!(load_from(®).projects.len(), 1);
}
#[test]
fn registry_path_honors_semantex_home_layout() {
assert_eq!(
registry_path(),
crate::config::SemantexConfig::semantex_home().join("projects.json")
);
}
#[test]
fn project_id_is_stable_for_same_path() {
let p = PathBuf::from("/tmp/stable-id-project");
assert_eq!(project_id_for_path(&p), project_id_for_path(&p));
assert_ne!(
project_id_for_path(&p),
project_id_for_path(&PathBuf::from("/tmp/other-project"))
);
}
}