use crate::db::index_state::UpdateStats;
use crate::db::Store;
use crate::git::{get_git_tree_sha, git_diff_tree, FileStatus};
use anyhow::{Context, Result};
use std::path::Path;
use tracing::{debug, info};
impl UpdateStats {
pub fn new() -> Self {
Self {
files_processed: 0,
chunks_processed: 0,
embeddings_generated: 0,
}
}
pub fn skipped() -> Self {
Self::new()
}
pub fn cache_hit_rate(&self) -> f64 {
if self.chunks_processed == 0 {
return 1.0;
}
1.0 - (self.embeddings_generated as f64 / self.chunks_processed as f64)
}
pub fn cost(&self) -> f64 {
self.embeddings_generated as f64 * 0.00002
}
}
impl Default for UpdateStats {
fn default() -> Self {
Self::new()
}
}
pub async fn remove_worktree_from_chunks(
store: &(dyn Store + Send + Sync),
worktree_id: i64,
relpath: &str,
) -> Result<i64> {
store
.remove_worktree_from_chunks(worktree_id, relpath)
.await
}
pub async fn incremental_update(
store: &(dyn Store + Send + Sync),
worktree_id: i64,
repo: &str,
worktree: &str,
repo_path: &Path,
) -> Result<UpdateStats> {
let current_tree_sha = get_git_tree_sha(repo_path)
.with_context(|| format!("Failed to get git tree SHA for {:?}", repo_path))?;
debug!(
worktree_id = worktree_id,
tree_sha = %current_tree_sha,
"Got current git tree SHA"
);
let last_indexed = store
.get_last_indexed_tree(worktree_id)
.await
.with_context(|| {
format!(
"Failed to get last indexed tree for worktree {}",
worktree_id
)
})?;
if last_indexed != "init" && last_indexed == current_tree_sha {
info!(
worktree_id = worktree_id,
tree_sha = %current_tree_sha,
"Tree SHA unchanged, skipping incremental update"
);
return Ok(UpdateStats::skipped());
}
if last_indexed == "init" {
debug!(
worktree_id = worktree_id,
"No previous tree SHA; first index is scan's job — not stamping index_state"
);
return Ok(UpdateStats::skipped());
}
debug!(
worktree_id = worktree_id,
last_sha = %last_indexed,
current_sha = %current_tree_sha,
"Tree SHA changed, processing diff"
);
let changes = git_diff_tree(&last_indexed, ¤t_tree_sha, repo_path).with_context(|| {
format!(
"Failed to get diff-tree between {} and {}",
last_indexed, current_tree_sha
)
})?;
let mut stats = UpdateStats::new();
let ignore_matcher = {
let patterns = crate::incremental::ignore::load_ignore_patterns(repo_path)
.unwrap_or_default();
crate::incremental::IgnorePatternMatcher::with_patterns(patterns).ok()
};
let mut to_upsert: Vec<std::path::PathBuf> = Vec::new();
for change in &changes {
let relpath = change.path.to_string_lossy();
match change.status {
FileStatus::Added | FileStatus::Modified => {
if let Some(m) = &ignore_matcher {
if m.should_ignore(&change.path) {
debug!(file = %relpath, "diff entry matches ignore patterns; skipping");
stats.files_processed += 1;
continue;
}
}
let abs = repo_path.join(&change.path);
if abs.is_dir() {
stats.files_processed += 1;
continue;
}
if !abs.is_file() {
if std::fs::symlink_metadata(&abs).is_ok() {
debug!(file = %relpath, "diff entry is a non-regular file (symlink/gitlink); skipping");
stats.files_processed += 1;
continue;
}
anyhow::bail!(
"diff entry {} is missing from the working tree; refusing to advance tree_sha",
relpath
);
}
match std::fs::read_to_string(&abs) {
Ok(_) => to_upsert.push(change.path.clone()),
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
debug!(file = %relpath, "diff entry is not valid UTF-8; skipping (scan parity)");
stats.files_processed += 1;
}
Err(e) => {
return Err(anyhow::anyhow!(e).context(format!(
"diff entry {} is unreadable; refusing to advance tree_sha",
relpath
)));
}
}
}
FileStatus::Deleted => {
let affected = store
.unmap_superseded_file_chunks(worktree_id, &relpath, None)
.await?;
debug!(
file = %relpath,
junction_rows = affected,
"Unmapped worktree from chunks"
);
stats.files_processed += 1;
}
}
}
if !to_upsert.is_empty() {
let commit = crate::git::get_head_commit(repo_path)
.with_context(|| format!("Failed to get HEAD commit for {:?}", repo_path))?;
crate::indexer::upsert_files(store, repo, worktree, repo_path, &commit, &to_upsert)
.await
.context("incremental upsert of changed files failed")?;
stats.files_processed += to_upsert.len() as i32;
}
store
.update_index_state(worktree_id, ¤t_tree_sha, &stats)
.await
.with_context(|| format!("Failed to update index state for worktree {}", worktree_id))?;
info!(
worktree_id = worktree_id,
files_processed = stats.files_processed,
tree_sha = %current_tree_sha,
"Incremental update complete"
);
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_stats_new() {
let stats = UpdateStats::new();
assert_eq!(stats.files_processed, 0);
assert_eq!(stats.chunks_processed, 0);
assert_eq!(stats.embeddings_generated, 0);
}
#[test]
fn test_update_stats_skipped() {
let stats = UpdateStats::skipped();
assert_eq!(stats.files_processed, 0);
assert_eq!(stats.chunks_processed, 0);
assert_eq!(stats.embeddings_generated, 0);
}
#[test]
fn test_cache_hit_rate_no_chunks() {
let stats = UpdateStats::new();
assert_eq!(stats.cache_hit_rate(), 1.0);
}
#[test]
fn test_cache_hit_rate_all_cached() {
let stats = UpdateStats {
files_processed: 10,
chunks_processed: 100,
embeddings_generated: 0,
};
assert_eq!(stats.cache_hit_rate(), 1.0);
}
#[test]
fn test_cache_hit_rate_partial() {
let stats = UpdateStats {
files_processed: 10,
chunks_processed: 100,
embeddings_generated: 50,
};
assert_eq!(stats.cache_hit_rate(), 0.5); }
#[test]
fn test_cost_calculation() {
let stats = UpdateStats {
files_processed: 10,
chunks_processed: 100,
embeddings_generated: 1000,
};
assert_eq!(stats.cost(), 0.02); }
#[test]
fn test_default() {
let stats = UpdateStats::default();
assert_eq!(stats.files_processed, 0);
assert_eq!(stats.chunks_processed, 0);
assert_eq!(stats.embeddings_generated, 0);
}
}