use crate::index::layout;
use crate::index::registry;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchSwitchAction {
FirstBuild,
Unchanged { branch_key: String },
Restored {
from_branch_key: String,
to_branch_key: String,
},
SnapshottedOutgoing {
from_branch_key: String,
to_branch_key: String,
},
}
impl BranchSwitchAction {
pub fn switched(&self) -> bool {
matches!(
self,
BranchSwitchAction::Restored { .. } | BranchSwitchAction::SnapshottedOutgoing { .. }
)
}
}
pub fn branch_switch_pending(project_root: &Path) -> bool {
let Some(root_meta) = layout::read_root_branch_meta(project_root) else {
return false;
};
root_meta.branch_key != layout::current_branch_key(project_root)
}
pub fn detect_and_handle_branch_switch(project_root: &Path) -> Result<BranchSwitchAction> {
let new_key = layout::current_branch_key(project_root);
let Some(root_meta) = layout::read_root_branch_meta(project_root) else {
return Ok(BranchSwitchAction::FirstBuild);
};
if root_meta.branch_key == new_key {
return Ok(BranchSwitchAction::Unchanged {
branch_key: new_key,
});
}
let container = layout::container_dir(project_root);
std::fs::create_dir_all(&container)?;
let lock_file = std::fs::File::create(container.join(".semantex.lock"))?;
lock_file.lock()?;
let Some(root_meta) = layout::read_root_branch_meta(project_root) else {
return Ok(BranchSwitchAction::FirstBuild);
};
if root_meta.branch_key == new_key {
return Ok(BranchSwitchAction::Unchanged {
branch_key: new_key,
});
}
layout::cleanup_stale_tmp_artifacts(project_root);
let new_branch_dir = layout::branch_index_dir(project_root, &new_key);
if new_branch_dir.join("chunks.db").exists() {
layout::restore_branch_dir_into_root(project_root, &new_key)?;
tracing::info!(
from = %root_meta.branch_key,
to = %new_key,
"Branch switch detected: restored existing snapshot into root"
);
Ok(BranchSwitchAction::Restored {
from_branch_key: root_meta.branch_key,
to_branch_key: new_key,
})
} else {
let outgoing_snapshot_current = root_meta.head_commit.is_some()
&& layout::branch_index_dir(project_root, &root_meta.branch_key)
.join("chunks.db")
.exists()
&& layout::read_branch_dir_meta(project_root, &root_meta.branch_key)
.is_some_and(|snap| snap.head_commit == root_meta.head_commit);
if outgoing_snapshot_current {
tracing::info!(
from = %root_meta.branch_key,
to = %new_key,
"Branch switch detected: outgoing branch snapshot already current, keeping it"
);
} else {
layout::mirror_root_as(project_root, &root_meta)?;
tracing::info!(
from = %root_meta.branch_key,
to = %new_key,
"Branch switch detected: snapshotted outgoing branch, root will be re-indexed in place"
);
}
Ok(BranchSwitchAction::SnapshottedOutgoing {
from_branch_key: root_meta.branch_key,
to_branch_key: new_key,
})
}
}
fn max_branch_indexes() -> usize {
std::env::var("SEMANTEX_MAX_BRANCH_INDEXES")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(5)
}
pub fn enforce_retention(project_root: &Path, current_branch_key: &str) -> Result<Vec<String>> {
enforce_retention_with_cap(project_root, current_branch_key, max_branch_indexes())
}
pub fn enforce_retention_with_cap(
project_root: &Path,
current_branch_key: &str,
cap: usize,
) -> Result<Vec<String>> {
let indexes_root = layout::indexes_root(project_root);
if !indexes_root.is_dir() {
return Ok(Vec::new());
}
let mut candidates: Vec<(String, std::time::SystemTime)> = Vec::new();
for entry in std::fs::read_dir(&indexes_root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.contains(".tmp-") {
continue;
}
let branch_json = indexes_root.join(&name).join("branch.json");
let mtime = std::fs::metadata(&branch_json)
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
candidates.push((name, mtime));
}
if candidates.len() <= cap {
return Ok(Vec::new());
}
candidates.sort_by(|a, b| b.1.cmp(&a.1));
let mut keep: std::collections::HashSet<String> = std::collections::HashSet::new();
keep.insert(current_branch_key.to_string());
for (name, _) in &candidates {
if keep.len() >= cap {
break;
}
keep.insert(name.clone());
}
let mut evicted = Vec::new();
for (name, _) in &candidates {
if keep.contains(name) {
continue;
}
std::fs::remove_dir_all(indexes_root.join(name))?;
evicted.push(name.clone());
}
if !evicted.is_empty() {
tracing::info!(?evicted, cap, "Evicted stale branch index snapshots");
}
Ok(evicted)
}
fn root_embedder_fingerprint(project_root: &Path) -> String {
let meta_path = layout::container_dir(project_root).join("meta.json");
std::fs::read_to_string(meta_path)
.ok()
.and_then(|s| serde_json::from_str::<crate::types::IndexMeta>(&s).ok())
.map(|m| m.embedder_fingerprint)
.unwrap_or_default()
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn record_branch_indexed(project_root: &Path) {
let branch = layout::current_branch_name(project_root);
let branch_key = layout::current_branch_key(project_root);
let head_commit = layout::resolve_git_head_commit(project_root);
let fingerprint = root_embedder_fingerprint(project_root);
registry::upsert_branch(
project_root,
&branch,
&branch_key,
unix_now(),
head_commit,
&fingerprint,
);
if let Err(e) = enforce_retention(project_root, &branch_key) {
tracing::warn!("Branch snapshot retention pass failed: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::storage::ChunkStore;
use crate::types::{Chunk, ChunkType, FileEntry, IndexMeta};
use tempfile::TempDir;
fn sample_index_meta() -> IndexMeta {
IndexMeta {
schema_version: IndexMeta::CURRENT_SCHEMA_VERSION,
project_path: std::path::PathBuf::from("/x"),
created_at: "0".to_string(),
updated_at: "0".to_string(),
file_count: 1,
chunk_count: 1,
embedding_model: "CodeRankEmbed".to_string(),
embedding_dim: 768,
use_bm25_stemmer: true,
dense_backend: "coderank-hnsw".to_string(),
embedder_fingerprint: "fp".to_string(),
}
}
fn write_fake_git_head(project: &Path, branch: &str) {
let git = project.join(".git");
std::fs::create_dir_all(git.join("refs").join("heads")).unwrap();
std::fs::write(git.join("HEAD"), format!("ref: refs/heads/{branch}\n")).unwrap();
std::fs::write(git.join("refs").join("heads").join(branch), "deadbeef\n").unwrap();
}
fn build_root_index(project: &Path, content: &str) -> u64 {
build_root_index_with_hash(project, content, 1)
}
fn build_root_index_with_hash(project: &Path, content: &str, file_hash: u64) -> u64 {
let container = layout::container_dir(project);
std::fs::create_dir_all(&container).unwrap();
let store = ChunkStore::open(&container.join("chunks.db")).unwrap();
let id = store
.insert_chunk(
&Chunk {
id: 0,
file_path: std::path::PathBuf::from("src/a.rs"),
start_line: 1,
end_line: 1,
content: content.to_string(),
chunk_type: ChunkType::TextWindow { window_index: 0 },
},
file_hash,
0,
)
.unwrap();
store
.set_file_entry(&FileEntry {
path: std::path::PathBuf::from("src/a.rs"),
hash: file_hash,
size: content.len() as u64,
mtime: 0,
})
.unwrap();
std::fs::write(
container.join("meta.json"),
serde_json::to_string(&sample_index_meta()).unwrap(),
)
.unwrap();
id
}
#[test]
fn round_trip_switch_a_to_b_and_back_restores_a() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "a");
let a_id = build_root_index(project, "fn on_a() {}");
layout::sync_v13_layout(project, "proj").unwrap();
write_fake_git_head(project, "b");
let action = detect_and_handle_branch_switch(project).unwrap();
assert!(
matches!(action, BranchSwitchAction::SnapshottedOutgoing { .. }),
"{action:?}"
);
let root_store =
ChunkStore::open(&layout::container_dir(project).join("chunks.db")).unwrap();
assert_eq!(root_store.get_chunk(a_id).unwrap().content, "fn on_a() {}");
drop(root_store);
std::fs::remove_file(layout::container_dir(project).join("chunks.db")).unwrap();
build_root_index_with_hash(project, "fn on_b() {}", 2);
layout::sync_v13_layout(project, "proj").unwrap();
let b_key = layout::current_branch_key(project);
assert!(
layout::branch_index_dir(project, &b_key)
.join("chunks.db")
.exists()
);
write_fake_git_head(project, "a");
let action_back = detect_and_handle_branch_switch(project).unwrap();
assert!(
matches!(action_back, BranchSwitchAction::Restored { .. }),
"{action_back:?}"
);
let restored_store =
ChunkStore::open(&layout::container_dir(project).join("chunks.db")).unwrap();
assert_eq!(
restored_store.get_chunk(a_id).unwrap().content,
"fn on_a() {}",
"switching back to A must restore A's snapshot, not keep B's content"
);
assert_eq!(
restored_store.get_file_hash(Path::new("src/a.rs")).unwrap(),
Some(1),
"restored root must carry A's stored file hashes so the updater skips unchanged files"
);
}
#[test]
fn unchanged_branch_is_a_no_op() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "main");
build_root_index(project, "fn f() {}");
layout::sync_v13_layout(project, "proj").unwrap();
let action = detect_and_handle_branch_switch(project).unwrap();
assert!(matches!(action, BranchSwitchAction::Unchanged { .. }));
}
#[test]
fn brand_new_project_is_first_build() {
let tmp = TempDir::new().unwrap();
let action = detect_and_handle_branch_switch(tmp.path()).unwrap();
assert_eq!(action, BranchSwitchAction::FirstBuild);
}
#[test]
fn branch_switch_pending_is_cheap_and_read_only() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "main");
build_root_index(project, "fn f() {}");
layout::sync_v13_layout(project, "proj").unwrap();
assert!(!branch_switch_pending(project));
write_fake_git_head(project, "feature");
assert!(branch_switch_pending(project));
assert!(
layout::read_root_branch_meta(project)
.unwrap()
.branch
.eq("main"),
"branch_switch_pending must not mutate the root sidecar"
);
}
#[test]
fn retention_evicts_oldest_but_never_the_current_branch() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
for (i, name) in ["one", "two", "three"].iter().enumerate() {
write_fake_git_head(project, name);
build_root_index(project, &format!("fn on_{name}() {{}}"));
layout::sync_v13_layout(project, "proj").unwrap();
let key = layout::branch_key_for_branch(name);
let bump =
std::time::SystemTime::now() + std::time::Duration::from_secs((i as u64 + 1) * 2);
let branch_json = layout::branch_index_dir(project, &key).join("branch.json");
let f = std::fs::File::open(&branch_json).unwrap();
let _ = f.set_modified(bump);
}
let current_key = layout::branch_key_for_branch("three");
let evicted = enforce_retention_with_cap(project, ¤t_key, 2).unwrap();
assert_eq!(evicted, vec![layout::branch_key_for_branch("one")]);
assert!(
layout::branch_index_dir(project, ¤t_key)
.join("chunks.db")
.exists()
);
assert!(
layout::branch_index_dir(project, &layout::branch_key_for_branch("two"))
.join("chunks.db")
.exists()
);
assert!(!layout::branch_index_dir(project, &layout::branch_key_for_branch("one")).exists());
}
#[test]
fn retention_default_cap_is_five_and_keeps_all_when_under_cap() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
for name in ["a", "b", "c"] {
write_fake_git_head(project, name);
build_root_index(project, &format!("fn on_{name}() {{}}"));
layout::sync_v13_layout(project, "proj").unwrap();
}
let current_key = layout::branch_key_for_branch("c");
let evicted = enforce_retention_with_cap(project, ¤t_key, 5).unwrap();
assert!(evicted.is_empty(), "3 snapshots is under a cap of 5");
}
#[test]
fn reconcile_blocks_while_index_lock_is_held() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().to_path_buf();
write_fake_git_head(&project, "a");
build_root_index(&project, "fn on_a() {}");
layout::sync_v13_layout(&project, "proj").unwrap();
write_fake_git_head(&project, "b");
detect_and_handle_branch_switch(&project).unwrap();
std::fs::remove_file(layout::container_dir(&project).join("chunks.db")).unwrap();
build_root_index_with_hash(&project, "fn on_b() {}", 2);
layout::sync_v13_layout(&project, "proj").unwrap();
write_fake_git_head(&project, "a");
assert!(branch_switch_pending(&project));
let lock_path = layout::container_dir(&project).join(".semantex.lock");
let lock_file = std::fs::File::create(&lock_path).unwrap();
lock_file.lock().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let p2 = project.clone();
let handle = std::thread::spawn(move || {
let result = detect_and_handle_branch_switch(&p2);
let _ = tx.send(());
result
});
assert!(
rx.recv_timeout(std::time::Duration::from_millis(400))
.is_err(),
"reconcile must block on the exclusive index lock, not mutate the live root"
);
drop(lock_file);
rx.recv_timeout(std::time::Duration::from_secs(30))
.expect("reconcile should complete once the lock is released");
let action = handle.join().unwrap().unwrap();
assert!(
matches!(action, BranchSwitchAction::Restored { .. }),
"{action:?}"
);
}
#[test]
fn restore_removes_stale_wal_and_shm_beside_replaced_chunks_db() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "a");
let a_id = build_root_index(project, "fn on_a() {}");
layout::sync_v13_layout(project, "proj").unwrap();
write_fake_git_head(project, "b");
detect_and_handle_branch_switch(project).unwrap();
std::fs::remove_file(layout::container_dir(project).join("chunks.db")).unwrap();
build_root_index_with_hash(project, "fn on_b() {}", 2);
layout::sync_v13_layout(project, "proj").unwrap();
let container = layout::container_dir(project);
std::fs::write(container.join("chunks.db-wal"), b"stale wal frames").unwrap();
std::fs::write(container.join("chunks.db-shm"), b"stale shm").unwrap();
write_fake_git_head(project, "a");
let action = detect_and_handle_branch_switch(project).unwrap();
assert!(matches!(action, BranchSwitchAction::Restored { .. }));
assert!(
!container.join("chunks.db-wal").exists(),
"stale -wal must be removed with the replaced chunks.db"
);
assert!(
!container.join("chunks.db-shm").exists(),
"stale -shm must be removed with the replaced chunks.db"
);
let store = ChunkStore::open(&container.join("chunks.db")).unwrap();
assert_eq!(store.get_chunk(a_id).unwrap().content, "fn on_a() {}");
}
#[test]
fn failed_build_does_not_contaminate_outgoing_branch_snapshot() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "a");
let a_id = build_root_index(project, "fn on_a() {}");
layout::sync_v13_layout(project, "proj").unwrap();
write_fake_git_head(project, "b");
let action = detect_and_handle_branch_switch(project).unwrap();
assert!(matches!(
action,
BranchSwitchAction::SnapshottedOutgoing { .. }
));
std::fs::remove_file(layout::container_dir(project).join("chunks.db")).unwrap();
build_root_index_with_hash(project, "fn hybrid_torn_build() {}", 99);
let retry = detect_and_handle_branch_switch(project).unwrap();
assert!(matches!(
retry,
BranchSwitchAction::SnapshottedOutgoing { .. }
));
let a_key = layout::branch_key_for_branch("a");
let snap =
ChunkStore::open(&layout::branch_index_dir(project, &a_key).join("chunks.db")).unwrap();
assert_eq!(
snap.get_chunk(a_id).unwrap().content,
"fn on_a() {}",
"outgoing branch's clean snapshot must not be overwritten by a hybrid root"
);
}
#[test]
fn stale_outgoing_snapshot_is_remirrored_on_switch() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "a");
let a_id = build_root_index(project, "fn on_a_v2() {}");
layout::sync_v13_layout(project, "proj").unwrap();
let a_key = layout::branch_key_for_branch("a");
let snap_sidecar = layout::branch_index_dir(project, &a_key).join("branch.json");
let mut meta: layout::BranchMeta =
serde_json::from_str(&std::fs::read_to_string(&snap_sidecar).unwrap()).unwrap();
meta.head_commit = Some("0000000000000000000000000000000000000000".into());
std::fs::write(&snap_sidecar, serde_json::to_string(&meta).unwrap()).unwrap();
write_fake_git_head(project, "b");
let action = detect_and_handle_branch_switch(project).unwrap();
assert!(matches!(
action,
BranchSwitchAction::SnapshottedOutgoing { .. }
));
let snap =
ChunkStore::open(&layout::branch_index_dir(project, &a_key).join("chunks.db")).unwrap();
assert_eq!(
snap.get_chunk(a_id).unwrap().content,
"fn on_a_v2() {}",
"a stale outgoing snapshot must be refreshed from the root on switch"
);
}
#[test]
fn orphaned_tmp_artifacts_are_swept_on_switch() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
write_fake_git_head(project, "a");
build_root_index(project, "fn on_a() {}");
layout::sync_v13_layout(project, "proj").unwrap();
let indexes = layout::indexes_root(project);
let orphan_dir = indexes.join("dead-b1c2d3e4.tmp-mirror.99999");
std::fs::create_dir_all(&orphan_dir).unwrap();
std::fs::write(orphan_dir.join("chunks.db"), b"half-written").unwrap();
let orphan_file = indexes.join("dead-b1c2d3e4.chunks.tmp-restore.99999");
std::fs::write(&orphan_file, b"half-written").unwrap();
write_fake_git_head(project, "b");
detect_and_handle_branch_switch(project).unwrap();
assert!(
!orphan_dir.exists(),
"orphaned tmp-mirror dir must be swept"
);
assert!(
!orphan_file.exists(),
"orphaned tmp-restore file must be swept"
);
}
#[test]
fn max_branch_indexes_parses_env_override_and_rejects_garbage() {
unsafe {
std::env::set_var("SEMANTEX_MAX_BRANCH_INDEXES", "3");
}
assert_eq!(max_branch_indexes(), 3);
unsafe {
std::env::set_var("SEMANTEX_MAX_BRANCH_INDEXES", "not-a-number");
}
assert_eq!(max_branch_indexes(), 5);
unsafe {
std::env::set_var("SEMANTEX_MAX_BRANCH_INDEXES", "0");
}
assert_eq!(max_branch_indexes(), 5);
unsafe {
std::env::remove_var("SEMANTEX_MAX_BRANCH_INDEXES");
}
assert_eq!(max_branch_indexes(), 5);
}
}