first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -46,5 +46,5 @@ impl ChangedFiles {
}
#[cfg(test)]
#[path = "changed_files_test.rs"]
#[path = "changed_files_tests.rs"]
mod tests;
@@ -1,6 +1,7 @@
use super::*;
use std::path::PathBuf;
use super::*;
// Helper function to create a PathBuf from a string
fn pb(path: &str) -> PathBuf {
PathBuf::from(path)
@@ -1,6 +1,8 @@
use std::path::Path;
use string_offset::ByteOffset;
#[cfg(not(target_family = "wasm"))]
use warp_util::standardized_path::StandardizedPath;
mod naive;
#[cfg(not(target_family = "wasm"))]
@@ -99,7 +101,8 @@ pub fn chunk_code<'a>(code: &'a str, path: &'a Path) -> Vec<Fragment<'a>> {
/// could not be chunked for any reason.
#[cfg(not(target_family = "wasm"))]
fn try_chunk_code_semantically<'a>(code: &'a str, path: &'a Path) -> Option<Vec<Fragment<'a>>> {
let language = languages::language_by_filename(path)?;
let standardized_path = StandardizedPath::try_from_local(path).ok()?;
let language = languages::language_by_filename(&standardized_path)?;
semantic::chunk_code(code, path, MAX_BYTES_PER_CHUNK, &language.grammar).ok()
}
@@ -1,7 +1,9 @@
use crate::index::full_source_code_embedding::chunker::{coalesce_fragments, Fragment};
use std::path::Path;
use itertools::Itertools;
use line_span::{LineSpan, LineSpans};
use std::path::Path;
use crate::index::full_source_code_embedding::chunker::{coalesce_fragments, Fragment};
/// Chunks the given file into [`Fragment`]s. Each chunk is at most `num_lines_per_chunk` lines long, and contains at most `max_bytes_per_chunk` bytes.
pub(super) fn chunk_code<'a>(
@@ -1,6 +1,7 @@
use super::*;
use std::path::Path;
use super::*;
#[test]
fn test_chunker() {
let code = "This is some text content\nthat should be chunked\nusing the naive chunker\nbecause the language isn't recognized.";
@@ -48,7 +48,11 @@ pub(super) fn chunk_code<'a>(
// of the allocator).
//
// See: https://github.com/tree-sitter/tree-sitter/issues/3129
#[cfg(all(target_os = "linux", target_env = "gnu", not(feature = "jemalloc")))]
#[cfg(all(
any(target_os = "linux", target_os = "freebsd"),
target_env = "gnu",
not(feature = "jemalloc")
))]
unsafe {
nix::libc::malloc_trim(0);
}
@@ -1,6 +1,7 @@
use std::path::Path;
use languages::language_by_filename;
use warp_util::standardized_path::StandardizedPath;
use super::*;
@@ -33,12 +34,14 @@ fn main() {
"#;
let max_chunk_size = 128;
let language_path =
StandardizedPath::try_new("/test.rs").expect("test path should be absolute");
let chunks = chunk_code(
source_code,
Path::new("test.rs"),
max_chunk_size,
&language_by_filename(Path::new("test.rs"))
&language_by_filename(&language_path)
.expect("Rust language must exist")
.grammar,
)
@@ -1,3 +1,9 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::anyhow;
use async_channel;
use chrono::{DateTime, Utc};
@@ -5,32 +11,31 @@ use futures::stream::AbortHandle;
use galaxy_core::safe_error;
use galaxyui::{Entity, ModelContext, ModelHandle};
use ignore::gitignore::Gitignore;
use instant::Instant;
#[cfg(feature = "local_fs")]
use repo_metadata::entry::IgnoredPathStrategy;
use repo_metadata::entry::{BudgetExceededBehavior, IgnoredPathStrategy};
use repo_metadata::Repository;
use std::{path::Path, sync::Arc};
use galaxyui_core::{Entity, ModelContext, ModelHandle};
use super::fragment_metadata::{
FragmentMetadata, LeafToFragmentMetadata, LeafToFragmentMetadataUpdates,
};
use super::manager::{
CodebaseIndexFinishedStatus, CodebaseIndexStatus, FragmentMetadataLookupError,
RetrieveFileError,
};
use super::merkle_tree::{MerkleTree, SerializedCodebaseIndex};
#[cfg(feature = "local_fs")]
use super::search_shaping::build_fragments_from_file_contents;
use super::search_shaping::{fragments_to_context_locations, ReadFragmentResult};
use super::store_client::StoreClient;
use super::sync_client::{FlushFragmentResult, SyncOperationError};
use super::{
fragment_metadata::{FragmentMetadata, LeafToFragmentMetadata, LeafToFragmentMetadataUpdates},
manager::{CodebaseIndexFinishedStatus, CodebaseIndexStatus, RetrieveFileError},
merkle_tree::{MerkleTree, SerializedCodebaseIndex},
store_client::StoreClient,
sync_client::{FlushFragmentResult, SyncOperationError},
CodebaseContextConfig, ContentHash, EmbeddingConfig, Error, Fragment, NodeHash, RepoMetadata,
};
use crate::{
index::locations::{CodeContextLocation, FileFragmentLocation},
telemetry::{AITelemetryEvent, CodebaseContextSyncType},
workspace::{WorkspaceMetadata, WorkspaceMetadataEvent},
};
use instant::Instant;
use std::{
collections::{HashMap, HashSet},
ops::Range,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
use crate::index::locations::CodeContextLocation;
use crate::telemetry::{AITelemetryEvent, CodebaseContextSyncType};
use crate::workspace::{WorkspaceMetadata, WorkspaceMetadataEvent};
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
@@ -44,12 +49,11 @@ cfg_if::cfg_if! {
Entry,
matches_gitignores,
full_source_code_embedding::sync_client::CodebaseIndexSyncOperation,
full_source_code_embedding::FragmentLocation
};
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::interval_timer::IntervalTimer;
use galaxyui::r#async::Timer;
use galaxyui::SingletonEntity;
use galaxyui_core::r#async::Timer;
use galaxyui_core::SingletonEntity;
use galaxy_core::sync_queue::SyncQueue;
use sha2::Digest;
}
@@ -316,7 +320,9 @@ pub enum CodebaseIndexEvent {
retrieval_id: RetrievalID,
error: Error,
},
SyncStateUpdated,
SyncStateUpdated {
root_path: PathBuf,
},
IndexMetadataUpdated {
root_path: PathBuf,
event: WorkspaceMetadataEvent,
@@ -498,6 +504,13 @@ impl CodebaseIndex {
store_client: Arc<dyn StoreClient>,
ctx: &mut ModelContext<Self>,
) {
if self
.pending_file_changes
.as_ref()
.is_none_or(|changed_files| changed_files.is_empty())
{
return;
}
let last_server_synced_root_node = self.last_server_synced_root_node();
let old_state = self.update_tree_sync_state(
TreeSourceSyncState::Syncing {
@@ -876,7 +889,9 @@ impl CodebaseIndex {
ctx: &mut ModelContext<Self>,
) -> TreeSourceSyncState {
let old_state = std::mem::replace(&mut self.tree_sync_state, new_state);
ctx.emit(CodebaseIndexEvent::SyncStateUpdated);
ctx.emit(CodebaseIndexEvent::SyncStateUpdated {
root_path: self.repo_path.clone(),
});
old_state
}
@@ -885,7 +900,9 @@ impl CodebaseIndex {
if let TreeSourceSyncState::Syncing { sync_progress, .. } = &mut self.tree_sync_state {
*sync_progress = Some(progress);
ctx.emit(CodebaseIndexEvent::SyncStateUpdated);
ctx.emit(CodebaseIndexEvent::SyncStateUpdated {
root_path: self.repo_path.clone(),
});
}
}
@@ -920,6 +937,9 @@ impl CodebaseIndex {
// First traverse the repo path to retrieve all files we want to parse.
let mut files = Vec::new();
let mut remaining_file_quotas = max_num_files_limit;
// Codebase embedding must not operate on a partial tree: the file limit
// is an intentional cost cap, so exceeding it fails the build rather
// than silently indexing a breadth-first subset of the repository.
let entry = Entry::build_tree(
&repo_path,
&mut files,
@@ -928,6 +948,7 @@ impl CodebaseIndex {
MAX_DEPTH,
0,
&IgnoredPathStrategy::Exclude, // override_ignore_for_files
BudgetExceededBehavior::FailFast,
)?;
Ok(BuildFileTreeResult {
@@ -1318,6 +1339,30 @@ impl CodebaseIndex {
self.leaf_node_to_fragment_metadatas.get(leaf_hash.as_ref())
}
pub(super) fn fragment_metadatas_from_hashes(
&self,
root_hash: &NodeHash,
content_hashes: &[ContentHash],
) -> Result<HashMap<ContentHash, Vec<FragmentMetadata>>, FragmentMetadataLookupError> {
let current_root_hash = self
.last_server_synced_root_node()
.ok_or(FragmentMetadataLookupError::IndexNotSynced)?;
if &current_root_hash != root_hash {
return Err(FragmentMetadataLookupError::RootHashMismatch {
requested: root_hash.clone(),
current: current_root_hash,
});
}
Ok(content_hashes
.iter()
.filter_map(|hash| {
self.fragment_metadatas_from_hash(hash)
.map(|metadata| (hash.clone(), metadata.clone()))
})
.collect())
}
fn repo_metadata(&self) -> RepoMetadata {
RepoMetadata {
path: Some(self.repo_path.to_string_lossy().to_string()),
@@ -1342,12 +1387,20 @@ impl CodebaseIndex {
}
pub(super) fn codebase_index_status(&self) -> CodebaseIndexStatus {
let has_synced_version = self.last_server_synced_root_node().is_some();
let root_hash = self.last_server_synced_root_node();
let has_synced_version = root_hash.is_some();
#[cfg(feature = "local_fs")]
let has_pending_file_changes = self
.pending_file_changes
.as_ref()
.is_some_and(|changes| !changes.is_empty());
#[cfg(not(feature = "local_fs"))]
let has_pending_file_changes = false;
match &self.tree_sync_state {
TreeSourceSyncState::Synced {
server_sync_result, ..
} => CodebaseIndexStatus {
has_pending: false,
has_pending: has_pending_file_changes,
has_synced_version,
last_sync_successful: Some(match server_sync_result {
ServerSyncResult::Success => CodebaseIndexFinishedStatus::Completed,
@@ -1356,18 +1409,21 @@ impl CodebaseIndex {
}
}),
sync_progress: None,
root_hash: root_hash.clone(),
},
TreeSourceSyncState::InitializeTreeFailure(e) => CodebaseIndexStatus {
has_pending: false,
has_synced_version,
last_sync_successful: Some(CodebaseIndexFinishedStatus::Failed(e.into())),
sync_progress: None,
root_hash: root_hash.clone(),
},
TreeSourceSyncState::Syncing { sync_progress, .. } => CodebaseIndexStatus {
has_pending: true,
has_synced_version,
last_sync_successful: None,
sync_progress: *sync_progress,
root_hash: root_hash.clone(),
},
}
}
@@ -1622,82 +1678,21 @@ impl CodebaseIndex {
}
}
// Convert fragments into CodeContextLocations. This function groups and dedupes fragments in the same file.
// It also allows the caller to define a context line number surrounding the relevant fragment.
fn process_fragments(
&self,
fragments: Vec<Fragment>,
context_lines: usize,
) -> HashSet<CodeContextLocation> {
// Map to collect fragments by file path
let mut fragments_by_path: HashMap<&PathBuf, Vec<Range<usize>>> = HashMap::new();
let mut whole_files = HashSet::new();
// First pass - collect all fragments and their line ranges by file path
for fragment in &fragments {
if let Some(metadata) = self
.fragment_metadatas_from_hash(&fragment.content_hash)
.and_then(|metadatas| {
metadatas.iter().find(|m| {
m.absolute_path == fragment.location.absolute_path
&& m.location.byte_range == fragment.location.byte_range
})
})
{
// Add line range with context to the appropriate file's collection
let path = &fragment.location.absolute_path;
let start = metadata.location.start_line.saturating_sub(context_lines);
let end = metadata.location.end_line + 1 + context_lines; // Make the range inclusive on both ends
fragments_by_path.entry(path).or_default().push(start..end);
} else {
// Fallback to whole file if metadata not found
whole_files.insert(fragment.location.absolute_path.clone());
}
}
// Second pass - process each file's fragments
let mut result = HashSet::new();
// Process each file's fragments
for (path, mut line_ranges) in fragments_by_path {
if line_ranges.is_empty() {
continue;
}
// We can skip the fragments if the entire file is already included in the context.
if whole_files.contains(path) {
continue;
}
// Sort ranges by start position
line_ranges.sort_by_key(|range| range.start);
// Merge overlapping or adjacent ranges
let mut merged_ranges: Vec<Range<usize>> = Vec::new();
for range in line_ranges {
if let Some(last) = merged_ranges.last_mut() {
// If current range overlaps or is adjacent to the last one, merge them
if range.start <= last.end {
last.end = last.end.max(range.end);
} else {
merged_ranges.push(range);
}
} else {
merged_ranges.push(range);
}
}
// Add file fragment location with all merged ranges
result.insert(CodeContextLocation::Fragment(FileFragmentLocation {
path: path.clone(),
line_ranges: merged_ranges,
}));
}
// Add whole files to the result set
result.extend(whole_files.into_iter().map(CodeContextLocation::WholeFile));
result
// Keep local and remote search aligned by using the same fragment-to-context expansion
// helper for range merging, deduping, and context-line handling.
fragments_to_context_locations(
fragments,
|content_hash| {
self.fragment_metadatas_from_hash(content_hash)
.map(Vec::as_slice)
},
context_lines,
)
}
/// A new index built from a snapshot. This constructor builds the index and starts
@@ -2100,13 +2095,14 @@ impl CodebaseIndex {
match entry.and_then(|entry| dunce::canonicalize(entry.path())) {
Ok(child_path) => {
// Ignore paths that are excluded by .gitignore, end with .git, or are symlinks.
if matches_gitignores(
&child_path,
is_dir,
&*gitignores,
false, /* check_ancestors */
) || child_path.ends_with(".git")
if child_path.ends_with(".git")
|| child_path.is_symlink()
|| matches_gitignores(
&child_path,
child_path.is_dir(),
&*gitignores,
false, /* check_ancestors */
)
{
continue;
}
@@ -2244,13 +2240,14 @@ impl CodebaseIndex {
match entry.and_then(|entry| dunce::canonicalize(entry.path())) {
Ok(child_path) => {
// Ignore paths that are excluded by .gitignore, end with .git, or are symlinks.
if matches_gitignores(
&child_path,
is_dir,
&*gitignores,
false, /* check_ancestors */
) || child_path.ends_with(".git")
if child_path.ends_with(".git")
|| child_path.is_symlink()
|| matches_gitignores(
&child_path,
child_path.is_dir(),
&*gitignores,
false, /* check_ancestors */
)
{
continue;
}
@@ -2320,98 +2317,22 @@ impl CodebaseIndex {
}
}
#[derive(Default)]
pub struct ReadFragmentResult {
pub successfully_read: Vec<Fragment>,
pub fail_to_read: Vec<ContentHash>,
pub fail_to_read_path: Vec<PathBuf>,
}
#[cfg(feature = "local_fs")]
pub(super) async fn build_fragments_from_metadata(
metadatas: impl IntoIterator<Item = (ContentHash, FragmentMetadata)>,
) -> ReadFragmentResult {
let mut fragments = Vec::new();
let mut fail_to_read = Vec::new();
let mut fail_to_read_path = Vec::new();
// Group fragments by file path
let mut fragments_by_path: HashMap<_, Vec<_>> = HashMap::new();
for (content_hash, metadata) in metadatas {
fragments_by_path
.entry(metadata.absolute_path)
.or_default()
.push((content_hash, metadata.location.byte_range));
}
// Process each file and its fragments
for (file_path, file_fragments) in fragments_by_path {
let mut has_failed_to_read_fragments = false;
// Read the file content once
if let Ok(file_content) = async_fs::read_to_string(&file_path).await {
// Process all fragments for this file
for (content_hash, fragment_ranges) in file_fragments {
let start_idx = fragment_ranges.start.as_usize();
let end_idx = fragment_ranges.end.as_usize();
if start_idx <= end_idx
&& end_idx <= file_content.len()
&& file_content.is_char_boundary(start_idx)
&& file_content.is_char_boundary(end_idx)
{
let content = file_content[start_idx..end_idx].to_string();
if content.is_empty() {
log::trace!(
"Fragment for {:?} with range {:?} is empty",
file_path.display(),
fragment_ranges
);
fail_to_read.push(content_hash);
has_failed_to_read_fragments = true;
} else if ContentHash::from_content(&content) != content_hash {
log::trace!(
"Fragment for {:?} with range {:?} does not match its content hash",
file_path.display(),
fragment_ranges
);
fail_to_read.push(content_hash);
has_failed_to_read_fragments = true;
} else {
fragments.push(Fragment {
content,
content_hash,
location: FragmentLocation {
absolute_path: file_path.clone(),
byte_range: fragment_ranges,
},
});
}
} else {
log::trace!("Invalid byte range {fragment_ranges:?} for file: {file_path:?}");
fail_to_read.push(content_hash);
has_failed_to_read_fragments = true;
}
}
} else {
log::trace!("Failed to read file: {file_path:?}");
fail_to_read.extend(
file_fragments
.into_iter()
.map(|(content_hash, _)| content_hash),
);
has_failed_to_read_fragments = true;
}
if has_failed_to_read_fragments {
fail_to_read_path.push(file_path);
let metadatas = metadatas.into_iter().collect::<Vec<_>>();
let mut file_contents = HashMap::new();
for path in metadatas
.iter()
.map(|(_, metadata)| metadata.absolute_path.clone())
.collect::<HashSet<_>>()
{
if let Ok(file_content) = async_fs::read_to_string(&path).await {
file_contents.insert(path, file_content);
}
}
ReadFragmentResult {
successfully_read: fragments,
fail_to_read,
fail_to_read_path,
}
build_fragments_from_file_contents(metadatas, &file_contents)
}
#[cfg(not(feature = "local_fs"))]
@@ -1,34 +1,32 @@
#![allow(clippy::single_range_in_vec_init)]
use chrono::Utc;
use string_offset::ByteOffset;
use virtual_fs::{Stub, VirtualFS};
use crate::index::full_source_code_embedding::changed_files::ChangedFiles;
use crate::index::full_source_code_embedding::codebase_index::MAX_DEPTH;
use crate::index::full_source_code_embedding::fragment_metadata::{
FragmentLocation, LeafToFragmentMetadata,
};
use crate::index::full_source_code_embedding::merkle_tree::MerkleHash;
use crate::index::full_source_code_embedding::merkle_tree::MerkleTree;
use crate::index::full_source_code_embedding::store_client::MockStoreClient;
use crate::index::full_source_code_embedding::{
ContentHash, EmbeddingConfig, Fragment, FragmentMetadata,
};
use crate::index::locations::{CodeContextLocation, FileFragmentLocation};
use futures::executor::block_on;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{App, SingletonEntity};
use repo_metadata::DirectoryWatcher;
use std::collections::HashMap;
use std::ops::Range;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use futures::executor::block_on;
use repo_metadata::DirectoryWatcher;
use string_offset::ByteOffset;
use virtual_fs::{Stub, VirtualFS};
use warp_util::standardized_path::StandardizedPath;
use galaxyui_core::{App, SingletonEntity};
use super::{
CodebaseIndex, CodebaseIndexTimeStampMetadata, TreeSourceSyncState,
CodebaseIndex, CodebaseIndexTimeStampMetadata, ServerSyncResult, TreeSourceSyncState,
DEFAULT_INCREMENAL_SYNC_FLUSH_INTERVAL,
};
use crate::index::full_source_code_embedding::changed_files::ChangedFiles;
use crate::index::full_source_code_embedding::codebase_index::MAX_DEPTH;
use crate::index::full_source_code_embedding::fragment_metadata::{
FragmentLocation, LeafToFragmentMetadata,
};
use crate::index::full_source_code_embedding::merkle_tree::{MerkleHash, MerkleTree};
use crate::index::full_source_code_embedding::store_client::MockStoreClient;
use crate::index::full_source_code_embedding::{
ContentHash, EmbeddingConfig, Fragment, FragmentMetadata,
};
use crate::index::locations::{CodeContextLocation, FileFragmentLocation};
impl CodebaseIndex {
fn new_for_test(
@@ -104,6 +102,86 @@ fn create_test_metadata(
}
}
#[test]
fn synced_index_with_queued_file_changes_reports_pending_status() {
VirtualFS::test(
"synced_index_with_queued_file_changes_reports_pending_status",
|dirs, mut sandbox| {
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new);
let repo_name = "warp-virtual";
sandbox.mkdir(repo_name);
sandbox.with_files(vec![Stub::FileWithContent(
format!("{repo_name}/existing_file").as_str(),
"existing content",
)]);
let repo_path = dunce::canonicalize(dirs.tests().join(repo_name)).unwrap();
let build_file_tree_result =
block_on(CodebaseIndex::build_file_tree(repo_path.clone(), None)).unwrap();
let (tree, _) =
block_on(MerkleTree::try_new(build_file_tree_result.file_tree)).unwrap();
let mut index = CodebaseIndex::new_for_test(Default::default(), &mut app);
index.tree_sync_state = TreeSourceSyncState::Synced {
tree,
server_sync_result: ServerSyncResult::Success,
};
let mut changed_files = ChangedFiles::default();
changed_files.upsertions.insert(repo_path.join("new_file"));
index.pending_file_changes = Some(changed_files);
let status = index.codebase_index_status();
assert!(status.has_pending());
assert!(status.has_synced_version());
assert_eq!(status.last_sync_successful(), Some(true));
});
},
);
}
#[test]
fn synced_index_without_pending_file_changes_stays_ready_after_flush() {
VirtualFS::test(
"synced_index_without_pending_file_changes_stays_ready_after_flush",
|dirs, mut sandbox| {
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new);
let repo_name = "warp-virtual";
sandbox.mkdir(repo_name);
sandbox.with_files(vec![Stub::FileWithContent(
format!("{repo_name}/existing_file").as_str(),
"existing content",
)]);
let repo_path = dunce::canonicalize(dirs.tests().join(repo_name)).unwrap();
let build_file_tree_result =
block_on(CodebaseIndex::build_file_tree(repo_path, None)).unwrap();
let (tree, _) =
block_on(MerkleTree::try_new(build_file_tree_result.file_tree)).unwrap();
let mut test_index = CodebaseIndex::new_for_test(Default::default(), &mut app);
test_index.tree_sync_state = TreeSourceSyncState::Synced {
tree,
server_sync_result: ServerSyncResult::Success,
};
let index = app.add_model(|_| test_index);
index.update(&mut app, |index, ctx| {
index.flush_pending_file_changes(ctx);
let status = index.codebase_index_status();
assert!(!status.has_pending());
assert!(status.has_synced_version());
assert_eq!(status.last_sync_successful(), Some(true));
});
});
},
);
}
#[test]
fn test_empty_fragments() {
App::test((), |mut app| async move {
@@ -1,4 +1,6 @@
use std::{collections::HashMap, ops::Range, path::PathBuf};
use std::collections::HashMap;
use std::ops::Range;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use string_offset::ByteOffset;
@@ -1,9 +1,7 @@
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use itertools::Itertools;
use repo_metadata::{BuildTreeError, DirectoryWatcher, Repository};
@@ -13,12 +11,12 @@ cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
use chrono::Utc;
use super::changed_files::ChangedFiles;
use crate::index::path_passes_filters;
use crate::index::{is_git_internal_path, matches_gitignores};
use ignore::gitignore::Gitignore;
use notify_debouncer_full::notify::{RecursiveMode, WatchFilter};
use galaxy_core::features::FeatureFlag;
use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent};
use galaxyui::r#async::Timer;
use galaxyui_core::r#async::Timer;
use galaxy_core::{send_telemetry_from_ctx, report_if_error};
use crate::telemetry::AITelemetryEvent;
use instant::Instant;
@@ -27,21 +25,16 @@ cfg_if::cfg_if! {
}
}
use galaxy_core::safe_anyhow;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use galaxyui_core::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use super::{
codebase_index::{CodebaseIndexEvent, RetrievalID, SyncProgress},
fragment_metadata::FragmentMetadata,
priority_queue::{BuildQueue, Priority},
snapshot::*,
store_client::StoreClient,
CodebaseIndex, EmbeddingConfig, Error as CodebaseIndexError, NodeHash,
};
use crate::{
index::locations::CodeContextLocation,
workspace::{WorkspaceMetadata, WorkspaceMetadataEvent},
};
use super::codebase_index::{CodebaseIndexEvent, RetrievalID, SyncProgress};
use super::fragment_metadata::FragmentMetadata;
use super::priority_queue::{BuildQueue, Priority};
use super::snapshot::*;
use super::store_client::StoreClient;
use super::{CodebaseIndex, ContentHash, EmbeddingConfig, Error as CodebaseIndexError, NodeHash};
use crate::index::locations::CodeContextLocation;
use crate::workspace::{WorkspaceMetadata, WorkspaceMetadataEvent};
/// The interval for debouncing filesystem events.
const REPO_WATCHER_DEBOUNCE_DURATION: Duration = Duration::from_secs(10);
@@ -69,6 +62,19 @@ pub enum RetrieveFileError {
IndexNotFound,
}
#[derive(Error, Debug)]
pub enum FragmentMetadataLookupError {
#[error("Codebase index not found")]
IndexNotFound,
#[error("Codebase index has no synced root hash")]
IndexNotSynced,
#[error("Codebase index root hash mismatch: requested {requested}, current {current}")]
RootHashMismatch {
requested: NodeHash,
current: NodeHash,
},
}
pub enum CodebaseIndexManagerEvent {
RetrievalRequestCompleted {
retrieval_id: RetrievalID,
@@ -79,7 +85,9 @@ pub enum CodebaseIndexManagerEvent {
retrieval_id: RetrievalID,
error_message: String,
},
SyncStateUpdated,
SyncStateUpdated {
root_path: PathBuf,
},
IndexMetadataUpdated {
root_path: PathBuf,
event: WorkspaceMetadataEvent,
@@ -87,7 +95,9 @@ pub enum CodebaseIndexManagerEvent {
RemoveExpiredIndexMetadata {
expired_metadata: Arc<Vec<PathBuf>>,
},
NewIndexCreated,
NewIndexCreated {
root_path: PathBuf,
},
}
/// User-facing indexing errors.
@@ -132,6 +142,7 @@ pub struct CodebaseIndexStatus {
pub(super) has_synced_version: bool,
pub(super) last_sync_successful: Option<CodebaseIndexFinishedStatus>,
pub(super) sync_progress: Option<SyncProgress>,
pub(super) root_hash: Option<NodeHash>,
}
impl CodebaseIndexStatus {
@@ -156,17 +167,127 @@ impl CodebaseIndexStatus {
pub fn sync_progress(&self) -> Option<&SyncProgress> {
self.sync_progress.as_ref()
}
pub fn root_hash(&self) -> Option<&NodeHash> {
self.root_hash.as_ref()
}
}
#[derive(Debug, Eq, PartialEq)]
struct CodebaseIndexStatusEventKey {
has_pending: bool,
has_synced_version: bool,
last_sync_status: Option<CodebaseIndexFinishedStatusEventKey>,
sync_progress: Option<SyncProgressEventKey>,
root_hash: Option<String>,
}
impl From<&CodebaseIndexStatus> for CodebaseIndexStatusEventKey {
fn from(status: &CodebaseIndexStatus) -> Self {
Self {
has_pending: status.has_pending,
has_synced_version: status.has_synced_version,
last_sync_status: status
.last_sync_successful
.as_ref()
.map(CodebaseIndexFinishedStatusEventKey::from),
sync_progress: status
.sync_progress
.as_ref()
.map(SyncProgressEventKey::from),
root_hash: status.root_hash.as_ref().map(ToString::to_string),
}
}
}
#[derive(Debug, Eq, PartialEq)]
enum CodebaseIndexFinishedStatusEventKey {
Completed,
Failed(String),
}
impl From<&CodebaseIndexFinishedStatus> for CodebaseIndexFinishedStatusEventKey {
fn from(status: &CodebaseIndexFinishedStatus) -> Self {
match status {
CodebaseIndexFinishedStatus::Completed => Self::Completed,
CodebaseIndexFinishedStatus::Failed(error) => Self::Failed(error.to_string()),
}
}
}
#[derive(Debug, Eq, PartialEq)]
enum SyncProgressEventKey {
Discovering {
total_nodes: usize,
},
Syncing {
completed_nodes: usize,
total_nodes: usize,
},
}
impl From<&SyncProgress> for SyncProgressEventKey {
fn from(progress: &SyncProgress) -> Self {
match progress {
SyncProgress::Discovering { total_nodes } => Self::Discovering {
total_nodes: *total_nodes,
},
SyncProgress::Syncing {
completed_nodes,
total_nodes,
} => Self::Syncing {
completed_nodes: *completed_nodes,
total_nodes: *total_nodes,
},
}
}
}
pub enum BuildSource<'a> {
FromPath(&'a Path),
FromPersistedMetadata(WorkspaceMetadata),
}
pub struct CodebaseIndexManagerConfig {
persisted_index_metadata: Vec<WorkspaceMetadata>,
max_index_count: Option<usize>,
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
store_client: Arc<dyn StoreClient>,
indexing_enabled: bool,
restore_persisted_indices_on_startup: bool,
}
impl CodebaseIndexManagerConfig {
pub fn new(
persisted_index_metadata: Vec<WorkspaceMetadata>,
max_index_count: Option<usize>,
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
store_client: Arc<dyn StoreClient>,
indexing_enabled: bool,
) -> Self {
Self {
persisted_index_metadata,
max_index_count,
max_files_repo_limit,
embedding_generation_batch_size,
store_client,
indexing_enabled,
restore_persisted_indices_on_startup: true,
}
}
pub fn defer_persisted_index_restore(mut self) -> Self {
self.restore_persisted_indices_on_startup = false;
self
}
}
/// Manager for the codebase index states across the app.
pub struct CodebaseIndexManager {
codebase_indices: HashMap<PathBuf, ModelHandle<CodebaseIndex>>,
last_emitted_codebase_index_statuses: HashMap<PathBuf, CodebaseIndexStatusEventKey>,
store_client: Arc<dyn StoreClient>,
#[cfg(feature = "local_fs")]
@@ -179,6 +300,11 @@ pub struct CodebaseIndexManager {
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
indexing_enabled: bool,
#[cfg(feature = "local_fs")]
snapshot_storage: Option<SnapshotStorage>,
}
impl CodebaseIndexManager {
@@ -189,25 +315,100 @@ impl CodebaseIndexManager {
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
store_client: Arc<dyn StoreClient>,
indexing_enabled: bool,
ctx: &mut ModelContext<Self>,
) -> Self {
let config = CodebaseIndexManagerConfig::new(
persisted_index_metadata,
max_index_count,
max_files_repo_limit,
embedding_generation_batch_size,
store_client,
indexing_enabled,
);
Self::new_with_config(config, ctx)
}
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
pub fn new_with_config(
config: CodebaseIndexManagerConfig,
ctx: &mut ModelContext<Self>,
) -> Self {
#[cfg(feature = "local_fs")]
{
report_if_error!(migrate_snapshots_to_secure_dir_if_needed());
Self::new_with_snapshot_storage(config, SnapshotStorage::app_default(), ctx)
}
#[cfg(not(feature = "local_fs"))]
{
Self::new_internal(config, ctx)
}
}
#[cfg(feature = "local_fs")]
pub fn new_with_snapshot_storage(
config: CodebaseIndexManagerConfig,
snapshot_storage: Option<SnapshotStorage>,
ctx: &mut ModelContext<Self>,
) -> Self {
Self::new_internal(config, snapshot_storage, ctx)
}
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
fn new_internal(
config: CodebaseIndexManagerConfig,
#[cfg(feature = "local_fs")] snapshot_storage: Option<SnapshotStorage>,
ctx: &mut ModelContext<Self>,
) -> Self {
let CodebaseIndexManagerConfig {
persisted_index_metadata,
max_index_count,
max_files_repo_limit,
embedding_generation_batch_size,
store_client,
indexing_enabled,
restore_persisted_indices_on_startup,
} = config;
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
let file_watcher = ctx.add_model(|ctx| BulkFilesystemWatcher::new(REPO_WATCHER_DEBOUNCE_DURATION, ctx));
ctx.subscribe_to_model(&file_watcher, Self::handle_watcher_event);
}
}
if !indexing_enabled {
log::debug!(
"Codebase indexing disabled for this launch mode; skipping restore of {:?} persisted codebase indices",
persisted_index_metadata.len()
);
return Self {
codebase_indices: HashMap::new(),
last_emitted_codebase_index_statuses: HashMap::new(),
store_client,
#[cfg(feature = "local_fs")]
watcher: file_watcher,
build_queue: BuildQueue::empty(),
max_indices: max_index_count,
max_files_repo_limit,
embedding_generation_batch_size,
indexing_enabled,
#[cfg(feature = "local_fs")]
snapshot_storage,
};
}
log::debug!(
"Received {:?} persisted codebase indices",
persisted_index_metadata.len()
);
#[cfg(feature = "local_fs")]
report_if_error!(migrate_snapshots_to_secure_dir_if_needed());
let (invalid_metadata, valid_metadata) =
split_snapshot_metadata_by_validity(persisted_index_metadata);
let (invalid_metadata, valid_metadata) = split_snapshot_metadata_by_validity(
persisted_index_metadata,
snapshot_storage.as_ref(),
);
#[cfg(not(feature = "local_fs"))]
let (invalid_metadata, valid_metadata) = (persisted_index_metadata, Vec::new());
ctx.emit(CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata {
expired_metadata: Arc::new(
@@ -217,16 +418,18 @@ impl CodebaseIndexManager {
.collect(),
),
});
if let Some(snapshot_file_dir) = snapshot_dir() {
clean_up_snapshot_files(&snapshot_file_dir, &valid_metadata);
#[cfg(feature = "local_fs")]
if let Some(snapshot_storage) = snapshot_storage.as_ref() {
clean_up_snapshot_files(snapshot_storage.path(), &valid_metadata);
}
// For the moment, we've decided to load all snapshots regardless of the index count.
let build_queue = BuildQueue::new_with_persisted(valid_metadata);
let build_queue =
BuildQueue::new_with_persisted(valid_metadata, restore_persisted_indices_on_startup);
let mut me = Self {
codebase_indices: HashMap::new(),
last_emitted_codebase_index_statuses: HashMap::new(),
store_client,
#[cfg(feature = "local_fs")]
watcher: file_watcher,
@@ -234,12 +437,12 @@ impl CodebaseIndexManager {
max_indices: max_index_count,
max_files_repo_limit,
embedding_generation_batch_size,
indexing_enabled,
#[cfg(feature = "local_fs")]
snapshot_storage,
};
// Start building the first index in the queue.
if let Some(next_repo) = me.build_queue.pick_next_sync() {
me.build_and_sync_codebase_index(BuildSource::FromPersistedMetadata(next_repo), ctx);
}
me.start_next_queued_index(ctx);
me
}
@@ -250,6 +453,7 @@ impl CodebaseIndexManager {
let file_watcher = ctx.add_model(|_| BulkFilesystemWatcher::new_for_test());
Self {
codebase_indices: HashMap::new(),
last_emitted_codebase_index_statuses: HashMap::new(),
store_client,
#[cfg(feature = "local_fs")]
watcher: file_watcher,
@@ -257,6 +461,9 @@ impl CodebaseIndexManager {
max_indices: None,
max_files_repo_limit: 0,
embedding_generation_batch_size: 100,
indexing_enabled: true,
#[cfg(feature = "local_fs")]
snapshot_storage: SnapshotStorage::app_default(),
}
}
@@ -306,8 +513,15 @@ impl CodebaseIndexManager {
// Remove snapshots from disk.
let to_drop_clone = to_drop.clone();
#[cfg(feature = "local_fs")]
let snapshot_storage = self.snapshot_storage.clone();
ctx.spawn(
async move { Self::drop_index_snapshots(to_drop_clone).await },
async move {
#[cfg(feature = "local_fs")]
Self::drop_index_snapshots(snapshot_storage, to_drop_clone).await;
#[cfg(not(feature = "local_fs"))]
let _ = to_drop_clone;
},
|_, _, _| {},
);
@@ -317,11 +531,15 @@ impl CodebaseIndexManager {
});
}
/// Remove the gien index snapshots from disk.
async fn drop_index_snapshots(to_drop: Vec<PathBuf>) {
if let Some(snapshot_dir) = snapshot_dir() {
/// Remove the given index snapshots from disk.
#[cfg(feature = "local_fs")]
async fn drop_index_snapshots(
snapshot_storage: Option<SnapshotStorage>,
to_drop: Vec<PathBuf>,
) {
if let Some(snapshot_storage) = snapshot_storage {
for codebase_root in &to_drop {
Self::drop_index_snapshot(&snapshot_dir, codebase_root).await;
Self::drop_index_snapshot(snapshot_storage.path(), codebase_root).await;
}
}
}
@@ -345,6 +563,7 @@ impl CodebaseIndexManager {
// Drop the in-memory index.
self.codebase_indices.remove(root_path);
self.last_emitted_codebase_index_statuses.remove(root_path);
// Stop the filewatcher from receiving events for this codebase.
#[cfg(feature = "local_fs")]
@@ -359,11 +578,16 @@ impl CodebaseIndexManager {
// Remove snapshot from disk.
let root_path_clone = root_path.clone();
#[cfg(feature = "local_fs")]
let snapshot_storage = self.snapshot_storage.clone();
ctx.spawn(
async move {
if let Some(snapshot_dir) = snapshot_dir() {
Self::drop_index_snapshot(&snapshot_dir, &root_path_clone).await;
#[cfg(feature = "local_fs")]
if let Some(snapshot_storage) = snapshot_storage {
Self::drop_index_snapshot(snapshot_storage.path(), &root_path_clone).await;
}
#[cfg(not(feature = "local_fs"))]
let _ = root_path_clone;
},
|_, _, _| {},
);
@@ -451,6 +675,7 @@ impl CodebaseIndexManager {
#[cfg(feature = "local_fs")]
fn handle_watcher_event(
&mut self,
_: ModelHandle<BulkFilesystemWatcher>,
event: &BulkFilesystemWatcherEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -462,6 +687,9 @@ impl CodebaseIndexManager {
}
pub fn handle_active_session_changed(&mut self, active_directory: &Path) {
if !self.is_indexing_enabled() {
return;
}
let Some(root_path) = self.root_path_for_codebase(active_directory) else {
return;
};
@@ -517,11 +745,17 @@ impl CodebaseIndexManager {
/// Ensures the current number of indices is below the maximum.
pub fn can_create_new_indices(&self) -> bool {
if !self.is_indexing_enabled() {
return false;
}
self.max_indices
.is_none_or(|max_indices| self.codebase_indices.len() < max_indices)
}
pub fn handle_session_bootstrapped(&mut self, working_directory: &Path) {
if !self.is_indexing_enabled() {
return;
}
let Some(root_path) = self.root_path_for_codebase(working_directory) else {
return;
};
@@ -553,6 +787,22 @@ impl CodebaseIndexManager {
})
}
pub fn fragment_metadatas_from_hashes(
&self,
repo_path: &Path,
root_hash: &NodeHash,
content_hashes: &[ContentHash],
app: &AppContext,
) -> Result<HashMap<ContentHash, Vec<FragmentMetadata>>, FragmentMetadataLookupError> {
let (codebase_index, _) = self
.get_codebase_index_internal(repo_path)
.map_err(|_| FragmentMetadataLookupError::IndexNotFound)?;
codebase_index
.as_ref(app)
.fragment_metadatas_from_hashes(root_hash, content_hashes)
}
pub fn get_codebase_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.codebase_indices.keys()
}
@@ -561,13 +811,37 @@ impl CodebaseIndexManager {
self.codebase_indices.len()
}
pub fn index_directory(&mut self, directory: PathBuf, ctx: &mut ModelContext<Self>) {
let directory = dunce::canonicalize(&directory).unwrap_or(directory);
if !self.codebase_indices.contains_key(&directory) {
self.build_and_sync_codebase_index(BuildSource::FromPath(&directory), ctx);
// Starting a new codebase index should be considered into sync state updates.
ctx.emit(CodebaseIndexManagerEvent::SyncStateUpdated);
pub fn is_indexing_enabled(&self) -> bool {
self.indexing_enabled
}
pub fn start_persisted_index_restore(&mut self, ctx: &mut ModelContext<Self>) {
if !self.is_indexing_enabled() {
return;
}
if self.build_queue.start() {
self.start_next_queued_index(ctx);
}
}
pub fn index_directory(&mut self, directory: PathBuf, ctx: &mut ModelContext<Self>) -> bool {
if !self.is_indexing_enabled() {
return false;
}
if self.root_path_for_codebase(&directory).is_none() {
if !self.build_and_sync_codebase_index(BuildSource::FromPath(&directory), ctx) {
return false;
}
let indexed_directory = self
.root_path_for_codebase(&directory)
.unwrap_or_else(|| directory.clone());
self.record_codebase_index_status(&indexed_directory, ctx);
// Starting a new codebase index should be considered into sync state updates.
ctx.emit(CodebaseIndexManagerEvent::NewIndexCreated {
root_path: indexed_directory,
});
}
true
}
#[cfg(feature = "local_fs")]
@@ -577,9 +851,21 @@ impl CodebaseIndexManager {
gitignores: Arc<Vec<Gitignore>>,
ctx: &mut ModelContext<Self>,
) {
let watch_filter = WatchFilter::with_filter(Arc::new(move |path| {
path_passes_filters(path, gitignores.as_slice())
}));
// The codebase indexer only cares about source files:
// skip anything inside `.git/` and anything matched by gitignore
// (including descendants of an ignored ancestor directory).
// The same predicate gates both directory descent and event emission.
let filter = Arc::new(move |path: &Path| {
!is_git_internal_path(path)
&& !matches_gitignores(
path,
path.is_dir(),
gitignores.as_slice(),
true, /* check_ancestors */
)
});
let watch_filter = WatchFilter::with_filter(filter.clone(), filter);
self.watcher.update(ctx, |watcher, _ctx| {
std::mem::drop(watcher.register_path(
root_path,
@@ -607,9 +893,12 @@ impl CodebaseIndexManager {
&mut self,
build_source: BuildSource,
ctx: &mut ModelContext<Self>,
) {
) -> bool {
if !self.is_indexing_enabled() {
return false;
}
if !self.can_create_new_indices() {
return;
return false;
}
let repo_path = match build_source {
@@ -624,7 +913,7 @@ impl CodebaseIndexManager {
Ok(path) => path,
Err(e) => {
log::error!("Failed to canonicalize repository path: {e:?}");
return;
return false;
}
};
@@ -635,7 +924,7 @@ impl CodebaseIndexManager {
Ok(handle) => handle,
Err(e) => {
log::error!("Failed to start tracking repository: {e:?}");
return;
return false;
}
};
@@ -646,11 +935,15 @@ impl CodebaseIndexManager {
.codebase_indices
.entry(canonical_key)
.or_insert_with(|| {
#[cfg(feature = "local_fs")]
let snapshot_storage = self.snapshot_storage.clone();
let index = Self::build_and_sync_codebase_index_internal(
self.store_client.clone(),
handle,
self.max_files_repo_limit,
self.embedding_generation_batch_size,
#[cfg(feature = "local_fs")]
snapshot_storage,
ctx,
);
@@ -666,6 +959,7 @@ impl CodebaseIndexManager {
index.update_timestamps_from_metadata(metadata);
});
}
true
}
/// Checks whether a snapshot exists for the index and attempts to load it;
@@ -675,6 +969,7 @@ impl CodebaseIndexManager {
repository: ModelHandle<Repository>,
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
#[cfg(feature = "local_fs")] snapshot_storage: Option<SnapshotStorage>,
ctx: &mut ModelContext<Self>,
) -> ModelHandle<CodebaseIndex> {
let codebase_index = ctx.add_model(|ctx| {
@@ -684,13 +979,17 @@ impl CodebaseIndexManager {
.as_ref(ctx)
.root_dir()
.to_local_path()
.is_some_and(|p| has_snapshot(&p))
.is_some_and(|p| {
snapshot_storage
.as_ref()
.is_some_and(|storage| storage.has_snapshot(&p))
})
{
if let Some(snapshot_dir) = snapshot_dir() {
if let Some(snapshot_storage) = snapshot_storage.as_ref() {
let read_snapshot_start_time = Instant::now();
match read_snapshot(
store_client.clone(),
snapshot_dir.as_path(),
snapshot_storage.path(),
repository.clone(),
max_files_repo_limit,
embedding_generation_batch_size,
@@ -733,6 +1032,7 @@ impl CodebaseIndexManager {
fn handle_codebase_index_event(
&mut self,
_: ModelHandle<CodebaseIndex>,
event: &CodebaseIndexEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -753,8 +1053,8 @@ impl CodebaseIndexManager {
fragments: fragments.clone(),
out_of_sync_delay: *out_of_sync_delay,
}),
CodebaseIndexEvent::SyncStateUpdated => {
ctx.emit(CodebaseIndexManagerEvent::SyncStateUpdated)
CodebaseIndexEvent::SyncStateUpdated { root_path } => {
self.maybe_emit_sync_state_updated(root_path, ctx);
}
CodebaseIndexEvent::IndexMetadataUpdated { root_path, event } => {
ctx.emit(CodebaseIndexManagerEvent::IndexMetadataUpdated {
@@ -785,11 +1085,42 @@ impl CodebaseIndexManager {
}
}
fn maybe_emit_sync_state_updated(&mut self, root_path: &Path, ctx: &mut ModelContext<Self>) {
if self.record_codebase_index_status(root_path, ctx) {
ctx.emit(CodebaseIndexManagerEvent::SyncStateUpdated {
root_path: root_path.to_path_buf(),
});
}
}
fn record_codebase_index_status(
&mut self,
root_path: &Path,
ctx: &mut ModelContext<Self>,
) -> bool {
let root_path = dunce::canonicalize(root_path).unwrap_or_else(|_| root_path.to_path_buf());
let Some(status) = self.get_codebase_index_status_for_path(root_path.as_path(), ctx) else {
return false;
};
let key = CodebaseIndexStatusEventKey::from(&status);
match self.last_emitted_codebase_index_statuses.get(&root_path) {
Some(previous_key) if previous_key == &key => false,
Some(_) | None => {
self.last_emitted_codebase_index_statuses
.insert(root_path, key);
true
}
}
}
fn on_index_build_finished(&mut self, finished_repo: &Path, ctx: &mut ModelContext<Self>) {
let Ok(_) = self.get_codebase_index_internal(finished_repo) else {
return;
};
self.start_next_queued_index(ctx);
}
fn start_next_queued_index(&mut self, ctx: &mut ModelContext<Self>) {
if let Some(next_repo) = self.build_queue.pick_next_sync() {
self.build_and_sync_codebase_index(BuildSource::FromPersistedMetadata(next_repo), ctx);
}
@@ -810,6 +1141,20 @@ impl CodebaseIndexManager {
.ok()
}
pub fn with_indexed_codebase<T>(
&mut self,
path: &Path,
on_found: impl FnOnce(&mut Self, &Path, &mut ModelContext<Self>) -> T,
on_missing: impl FnOnce(&mut Self, &Path, &mut ModelContext<Self>) -> T,
ctx: &mut ModelContext<Self>,
) -> T {
let Some(indexed_repo_path) = self.root_path_for_codebase(path) else {
return on_missing(self, path, ctx);
};
on_found(self, indexed_repo_path.as_path(), ctx)
}
fn get_codebase_index_internal(
&self,
path: &Path,
@@ -909,15 +1254,15 @@ impl CodebaseIndexManager {
}
};
let snapshot_dir = match snapshot_dir() {
Some(dir) => dir,
let snapshot_storage = match self.snapshot_storage.as_ref() {
Some(storage) => storage,
None => {
log::warn!("No snapshot directory to write to");
Self::schedule_next_snapshot_write(repo_path, ctx);
return;
}
};
let snapshot_path = snapshot_path(&snapshot_dir, repo_path.as_path());
let snapshot_path = snapshot_storage.snapshot_path(repo_path.as_path());
// Update timestamp eagerly so concurrent calls to has_unsnapshotted_changes()
// won't trigger a duplicate snapshot while the background write is in progress.
@@ -974,6 +1319,9 @@ impl CodebaseIndexManager {
directory_path: &Path,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
if !self.is_indexing_enabled() {
return Ok(());
}
// Find the root path for this directory's codebase
let Some(repo_path) = self.root_path_for_codebase(directory_path) else {
return Err(anyhow::anyhow!("Failed to find root path for directory"));
@@ -987,7 +1335,7 @@ impl CodebaseIndexManager {
codebase_index.update(ctx, |index, _ctx| {
// Check if the index is in a state where it can perform incremental updates
let status = index.codebase_index_status();
if status.has_pending {
if status.last_sync_successful() != Some(true) {
return;
}
@@ -1013,3 +1361,7 @@ impl Entity for CodebaseIndexManager {
}
impl SingletonEntity for CodebaseIndexManager {}
#[cfg(test)]
#[path = "manager_tests.rs"]
mod tests;
@@ -0,0 +1,379 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(feature = "local_fs")]
use chrono::Utc;
#[cfg(feature = "local_fs")]
use repo_metadata::DirectoryWatcher;
use galaxyui_core::App;
use super::{
BuildSource, CodebaseIndexFinishedStatus, CodebaseIndexManager, CodebaseIndexManagerConfig,
CodebaseIndexStatus, CodebaseIndexStatusEventKey, CodebaseIndexingError, SyncProgress,
};
use crate::index::full_source_code_embedding::store_client::MockStoreClient;
#[cfg(feature = "local_fs")]
use crate::index::full_source_code_embedding::SnapshotStorage;
use crate::workspace::WorkspaceMetadata;
fn workspace_metadata(path: impl Into<PathBuf>) -> WorkspaceMetadata {
WorkspaceMetadata {
path: path.into(),
navigated_ts: None,
modified_ts: None,
queried_ts: None,
}
}
fn codebase_index_status(
has_pending: bool,
has_synced_version: bool,
last_sync_successful: Option<CodebaseIndexFinishedStatus>,
sync_progress: Option<SyncProgress>,
) -> CodebaseIndexStatus {
CodebaseIndexStatus {
has_pending,
has_synced_version,
last_sync_successful,
sync_progress,
root_hash: None,
}
}
#[test]
fn codebase_index_status_event_key_matches_identical_statuses() {
let first_status = codebase_index_status(
true,
true,
None,
Some(SyncProgress::Syncing {
completed_nodes: 1,
total_nodes: 2,
}),
);
let duplicate_status = codebase_index_status(
true,
true,
None,
Some(SyncProgress::Syncing {
completed_nodes: 1,
total_nodes: 2,
}),
);
assert_eq!(
CodebaseIndexStatusEventKey::from(&first_status),
CodebaseIndexStatusEventKey::from(&duplicate_status)
);
}
#[test]
fn codebase_index_status_event_key_detects_semantic_changes() {
let syncing_status = codebase_index_status(
true,
true,
None,
Some(SyncProgress::Syncing {
completed_nodes: 1,
total_nodes: 2,
}),
);
let completed_status = codebase_index_status(
false,
true,
Some(CodebaseIndexFinishedStatus::Completed),
None,
);
let failed_status = codebase_index_status(
false,
true,
Some(CodebaseIndexFinishedStatus::Failed(
CodebaseIndexingError::BuildTreeError,
)),
None,
);
assert_ne!(
CodebaseIndexStatusEventKey::from(&syncing_status),
CodebaseIndexStatusEventKey::from(&completed_status)
);
assert_ne!(
CodebaseIndexStatusEventKey::from(&completed_status),
CodebaseIndexStatusEventKey::from(&failed_status)
);
}
#[test]
fn initializes_with_indexing_enabled_when_configured() {
App::test((), |app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
vec![workspace_metadata("repo")],
Some(1),
1000,
32,
Arc::new(MockStoreClient),
true,
ctx,
)
});
manager.read(&app, |manager, _| {
assert!(manager.is_indexing_enabled());
assert_eq!(manager.num_active_indices(), 0);
assert!(manager.can_create_new_indices());
});
});
}
#[test]
fn initializes_with_indexing_disabled_when_configured() {
App::test((), |app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
vec![workspace_metadata("repo")],
Some(1),
1000,
32,
Arc::new(MockStoreClient),
false,
ctx,
)
});
manager.read(&app, |manager, _| {
assert!(!manager.is_indexing_enabled());
assert_eq!(manager.num_active_indices(), 0);
assert!(!manager.can_create_new_indices());
});
});
}
#[test]
#[cfg(feature = "local_fs")]
fn initializes_with_injected_snapshot_storage_when_configured() {
App::test((), |app| async move {
let snapshot_dir = tempfile::tempdir().unwrap();
let storage = SnapshotStorage::from_dir(snapshot_dir.path().join("daemon")).unwrap();
let expected_snapshot_dir = storage.path().to_path_buf();
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new_with_snapshot_storage(
CodebaseIndexManagerConfig::new(
vec![workspace_metadata("repo")],
Some(1),
1000,
32,
Arc::new(MockStoreClient),
false,
),
Some(storage),
ctx,
)
});
manager.read(&app, |manager, _| {
let snapshot_storage = manager.snapshot_storage.as_ref().unwrap();
assert_eq!(snapshot_storage.path(), expected_snapshot_dir);
assert!(!snapshot_storage.is_app_default());
});
});
}
#[test]
fn persisted_index_restore_starts_on_startup_by_default() {
App::test((), |app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
Vec::new(),
Some(1),
1000,
32,
Arc::new(MockStoreClient),
true,
ctx,
)
});
manager.read(&app, |manager, _| {
assert!(manager.build_queue.is_running());
});
});
}
#[test]
#[cfg(feature = "local_fs")]
fn deferred_persisted_index_restore_starts_once() {
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new);
let snapshot_dir = tempfile::tempdir().unwrap();
let storage = SnapshotStorage::from_dir(snapshot_dir.path().join("daemon")).unwrap();
let first_repo = tempfile::tempdir().unwrap();
let second_repo = tempfile::tempdir().unwrap();
let mut first_metadata = workspace_metadata(first_repo.path());
first_metadata.modified_ts = Some(Utc::now());
let mut second_metadata = workspace_metadata(second_repo.path());
second_metadata.modified_ts = Some(Utc::now());
std::fs::write(storage.snapshot_path(first_repo.path()), b"snapshot").unwrap();
std::fs::write(storage.snapshot_path(second_repo.path()), b"snapshot").unwrap();
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new_with_snapshot_storage(
CodebaseIndexManagerConfig::new(
vec![first_metadata, second_metadata],
Some(2),
1000,
32,
Arc::new(MockStoreClient),
true,
)
.defer_persisted_index_restore(),
Some(storage),
ctx,
)
});
manager.update(&mut app, |manager, ctx| {
assert!(!manager.build_queue.is_running());
assert_eq!(manager.build_queue.queued_metadata().into_iter().count(), 2);
manager.start_persisted_index_restore(ctx);
assert!(manager.build_queue.is_running());
assert_eq!(manager.build_queue.queued_metadata().into_iter().count(), 1);
manager.start_persisted_index_restore(ctx);
assert_eq!(manager.build_queue.queued_metadata().into_iter().count(), 1);
});
});
}
#[test]
fn can_create_new_indices_honors_max_limit_when_enabled() {
App::test((), |mut app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
Vec::new(),
Some(1),
1000,
32,
Arc::new(MockStoreClient),
true,
ctx,
)
});
manager.update(&mut app, |manager, ctx| {
assert!(manager.can_create_new_indices());
manager.update_max_limits(Some(0), 1000, 32, ctx);
assert!(!manager.can_create_new_indices());
});
});
}
#[test]
fn index_directory_is_noop_when_indexing_disabled() {
App::test((), |mut app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
Vec::new(),
Some(1),
1000,
32,
Arc::new(MockStoreClient),
false,
ctx,
)
});
manager.update(&mut app, |manager, ctx| {
assert!(!manager.index_directory(PathBuf::from("repo"), ctx));
assert_eq!(manager.num_active_indices(), 0);
});
});
}
#[test]
fn index_directory_reports_when_max_index_limit_prevents_creation() {
App::test((), |mut app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
Vec::new(),
Some(0),
1000,
32,
Arc::new(MockStoreClient),
true,
ctx,
)
});
manager.update(&mut app, |manager, ctx| {
assert!(!manager.index_directory(PathBuf::from("repo"), ctx));
assert_eq!(manager.num_active_indices(), 0);
});
});
}
#[test]
fn build_and_sync_is_noop_when_indexing_disabled() {
App::test((), |mut app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
Vec::new(),
Some(1),
1000,
32,
Arc::new(MockStoreClient),
false,
ctx,
)
});
manager.update(&mut app, |manager, ctx| {
assert!(!manager
.build_and_sync_codebase_index(BuildSource::FromPath(Path::new("repo")), ctx));
assert_eq!(manager.num_active_indices(), 0);
});
});
}
#[test]
fn trigger_incremental_sync_returns_err_when_enabled_and_index_missing() {
App::test((), |mut app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
Vec::new(),
Some(1),
1000,
32,
Arc::new(MockStoreClient),
true,
ctx,
)
});
manager.update(&mut app, |manager, ctx| {
let result = manager.trigger_incremental_sync_for_path(Path::new("repo"), ctx);
assert!(result.is_err());
});
});
}
#[test]
fn trigger_incremental_sync_returns_ok_when_indexing_disabled() {
App::test((), |mut app| async move {
let manager = app.add_singleton_model(|ctx| {
CodebaseIndexManager::new(
Vec::new(),
Some(1),
1000,
32,
Arc::new(MockStoreClient),
false,
ctx,
)
});
manager.update(&mut app, |manager, ctx| {
let result = manager.trigger_incremental_sync_for_path(Path::new("repo"), ctx);
assert!(result.is_ok());
});
});
}
@@ -1,13 +1,16 @@
//! Common types for hashes that identify codebase embedding state.
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use generic_array::GenericArray;
use serde::{Deserialize, Serialize};
use sha2::{digest::OutputSizeUser, Digest, Sha256};
use std::{fmt, str::FromStr, sync::Arc};
use crate::index::full_source_code_embedding::chunker::Fragment;
use sha2::digest::OutputSizeUser;
use sha2::{Digest, Sha256};
use super::Error;
use crate::index::full_source_code_embedding::chunker::Fragment;
/// The hash of an *intermediate* node in the [`MerkleTree`].
///
@@ -223,5 +226,5 @@ impl fmt::Display for MerkleHash {
}
#[cfg(test)]
#[path = "hash_test.rs"]
#[path = "hash_tests.rs"]
mod hash_test;
@@ -1,6 +1,7 @@
use std::path::{Path, PathBuf};
use super::MerkleHash;
use crate::index::full_source_code_embedding::chunker::Fragment;
use std::path::{Path, PathBuf};
#[test]
fn test_fragment_hash_from_content() {
@@ -1,4 +1,5 @@
use super::{chunker::Fragment, Error};
use super::chunker::Fragment;
use super::Error;
mod hash;
mod node;
@@ -1,8 +1,8 @@
use crate::index::{
THREADPOOL, {DirectoryEntry, Entry, FileMetadata},
};
use anyhow::anyhow;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::{Path, PathBuf};
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use galaxy_util::standardized_path::StandardizedPath;
use itertools::Itertools;
@@ -10,26 +10,18 @@ use rayon::iter::{IntoParallelIterator, ParallelIterator};
use repo_metadata::entry::is_file_parsable;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, HashSet},
ops::Range,
path::{Path, PathBuf},
};
use string_offset::ByteOffset;
use crate::index::full_source_code_embedding::{
chunker::chunk_code,
fragment_metadata::{FragmentMetadata, LeafToFragmentMetadataUpdates},
Error,
};
use super::{
hash::MerkleHash,
serialized_tree::{SerializedFilesystemInfo, SerializedMerkleNode},
tree::UpdateFileResult,
ContentHash, DirEntryOrFragment, NodeHash,
use super::hash::MerkleHash;
use super::serialized_tree::{SerializedFilesystemInfo, SerializedMerkleNode};
use super::tree::UpdateFileResult;
use super::{ContentHash, DirEntryOrFragment, NodeHash};
use crate::index::full_source_code_embedding::chunker::chunk_code;
use crate::index::full_source_code_embedding::fragment_metadata::{
FragmentMetadata, LeafToFragmentMetadataUpdates,
};
use crate::index::full_source_code_embedding::Error;
use crate::index::{DirectoryEntry, Entry, FileMetadata, THREADPOOL};
/// ID that uniquely identifies a node in the merkle tree. It contains the node type
/// as well as metadata that distinguishes nodes of the same type.
@@ -707,5 +699,5 @@ impl NodeMask {
}
#[cfg(test)]
#[path = "node_test.rs"]
#[path = "node_tests.rs"]
mod tests;
@@ -1,12 +1,11 @@
use crate::index::full_source_code_embedding::{
fragment_metadata::LeafToFragmentMetadataUpdates, merkle_tree::DirEntryOrFragment,
};
use std::collections::HashSet;
use repo_metadata::{DirectoryEntry, Entry};
use virtual_fs::{Stub, VirtualFS};
use std::collections::HashSet;
use super::{MerkleNode, NodeMask};
use crate::index::full_source_code_embedding::fragment_metadata::LeafToFragmentMetadataUpdates;
use crate::index::full_source_code_embedding::merkle_tree::DirEntryOrFragment;
/// Tests that node hashes for directories are sorted (meaning they are resilient to files within
/// the directory being in a different order).
@@ -1,18 +1,17 @@
use std::{
ops::Range,
path::{Path, PathBuf},
};
use super::{hash::MerkleHash, node::NodeId, MerkleTree, NodeHash, NodeLens};
use crate::index::full_source_code_embedding::fragment_metadata::{
FragmentLocation, LeafToFragmentMetadata,
};
use std::ops::Range;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use string_offset::ByteOffset;
use super::hash::MerkleHash;
use super::node::NodeId;
use super::{MerkleTree, NodeHash, NodeLens};
use crate::index::full_source_code_embedding::fragment_metadata::{
FragmentLocation, LeafToFragmentMetadata,
};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct SerializedCodebaseIndex {
tree: SerializedMerkleTree,
@@ -196,5 +195,5 @@ impl SerializedMerkleNode {
}
#[cfg(test)]
#[path = "serialized_tree_test.rs"]
#[path = "serialized_tree_tests.rs"]
mod tests;
@@ -2,12 +2,11 @@ use futures::executor::block_on;
use serde_json;
use virtual_fs::VirtualFS;
use super::SerializedCodebaseIndex;
use crate::index::full_source_code_embedding::merkle_tree::{
construct_test_merkle_tree, MerkleTree,
};
use super::SerializedCodebaseIndex;
#[test]
fn round_trip_index_serialize_deserialize_json() {
VirtualFS::test("test_nodes_from_path_json", |dirs, mut sandbox| {
@@ -1,8 +1,9 @@
use super::MerkleTree;
use crate::index::full_source_code_embedding::fragment_metadata::LeafToFragmentMetadata;
use repo_metadata::{DirectoryEntry, Entry};
use virtual_fs::{Stub, VirtualFS};
use super::MerkleTree;
use crate::index::full_source_code_embedding::fragment_metadata::LeafToFragmentMetadata;
/// Construct a test Merkle tree with the following structure:
/// ```
/// root.txt
@@ -1,21 +1,17 @@
use crate::index::Entry;
use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use anyhow::anyhow;
use cfg_if::cfg_if;
use std::{
collections::{HashSet, VecDeque},
path::PathBuf,
};
use super::node::{ChildrenPath, MerkleNode, NodeLens, NodeMask};
use super::serialized_tree::SerializedMerkleTree;
use super::DirEntryOrFragment;
use crate::index::full_source_code_embedding::fragment_metadata::{
LeafToFragmentMetadata, LeafToFragmentMetadataUpdates,
};
use crate::index::full_source_code_embedding::Error;
use super::{
node::{ChildrenPath, MerkleNode, NodeLens, NodeMask},
serialized_tree::SerializedMerkleTree,
DirEntryOrFragment,
};
use crate::index::Entry;
pub(super) enum UpdateFileResult {
Deleted,
@@ -210,5 +206,5 @@ impl MerkleTree {
}
#[cfg(test)]
#[path = "tree_test.rs"]
#[path = "tree_tests.rs"]
mod tests;
@@ -1,11 +1,9 @@
use crate::index::full_source_code_embedding::merkle_tree::{
construct_test_merkle_tree, node::ChildrenPath,
};
use futures::executor::block_on;
use virtual_fs::VirtualFS;
use super::*;
use crate::index::full_source_code_embedding::merkle_tree::construct_test_merkle_tree;
use crate::index::full_source_code_embedding::merkle_tree::node::ChildrenPath;
#[test]
fn test_nodes_from_path() {
@@ -5,19 +5,21 @@ mod fragment_metadata;
pub mod manager;
mod merkle_tree;
mod priority_queue;
pub mod search_shaping;
mod snapshot;
pub mod store_client;
mod sync_client;
use std::{ops::Range, path::PathBuf, time::Duration};
pub use sync_client::SyncTask;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub use codebase_index::{CodebaseIndex, RetrievalID, SyncProgress};
pub use fragment_metadata::{FragmentLocation as FragmentMetadataLocation, FragmentMetadata};
pub use merkle_tree::{ContentHash, NodeHash};
use fragment_metadata::FragmentMetadata;
use galaxy_graphql::queries::rerank_fragments::FragmentLocationInput;
pub use snapshot::SnapshotStorage;
use string_offset::ByteOffset;
pub use sync_client::SyncTask;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -87,6 +89,7 @@ pub enum EmbeddingConfig {
Voyage3_5_Lite_512,
#[default]
Voyage3_5_512,
Voyage4_512,
}
#[derive(Debug, Clone)]
@@ -115,6 +118,9 @@ impl From<EmbeddingConfig> for galaxy_graphql::full_source_code_embedding::Embed
EmbeddingConfig::Voyage3_5_Lite_512 => {
galaxy_graphql::full_source_code_embedding::EmbeddingConfig::Voyage35Lite512
}
EmbeddingConfig::Voyage4_512 => {
warp_graphql::full_source_code_embedding::EmbeddingConfig::Voyage4512
}
}
}
}
@@ -138,6 +144,9 @@ impl TryFrom<galaxy_graphql::full_source_code_embedding::EmbeddingConfig> for Em
galaxy_graphql::full_source_code_embedding::EmbeddingConfig::Voyage35512 => {
Ok(Self::Voyage3_5_512)
}
warp_graphql::full_source_code_embedding::EmbeddingConfig::Voyage4512 => {
Ok(Self::Voyage4_512)
}
}
}
}
@@ -161,6 +170,36 @@ pub struct Fragment {
location: FragmentLocation,
}
impl Fragment {
pub fn from_byte_range(
content: String,
content_hash: ContentHash,
absolute_path: PathBuf,
byte_range: Range<ByteOffset>,
) -> Self {
Self {
content,
content_hash,
location: FragmentLocation {
absolute_path,
byte_range,
},
}
}
pub fn content_hash(&self) -> &ContentHash {
&self.content_hash
}
pub fn absolute_path(&self) -> &Path {
&self.location.absolute_path
}
pub fn byte_range(&self) -> Range<ByteOffset> {
self.location.byte_range.clone()
}
}
impl From<Fragment> for galaxy_graphql::full_source_code_embedding::Fragment {
fn from(val: Fragment) -> Self {
Self {
@@ -18,6 +18,14 @@ struct QueueEntry {
metadata: WorkspaceMetadata,
}
/// Controls whether queued builds may be consumed.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum BuildQueueState {
Paused,
#[default]
Running,
}
impl Hash for QueueEntry {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.metadata.path.hash(state);
@@ -35,6 +43,7 @@ impl Eq for QueueEntry {}
#[derive(Debug, Default)]
pub(super) struct BuildQueue {
queue: PriorityQueue<QueueEntry, Priority>,
state: BuildQueueState,
}
impl BuildQueue {
@@ -46,7 +55,10 @@ impl BuildQueue {
self.queue.iter().map(|(entry, _)| entry.metadata.clone())
}
pub(super) fn new_with_persisted(snapshots_to_load: Vec<WorkspaceMetadata>) -> Self {
pub(super) fn new_with_persisted(
snapshots_to_load: Vec<WorkspaceMetadata>,
start_immediately: bool,
) -> Self {
let mut queue = PriorityQueue::new();
queue.extend(
snapshots_to_load
@@ -54,12 +66,35 @@ impl BuildQueue {
.sorted_by(WorkspaceMetadata::most_recently_touched)
.map(|entry| (QueueEntry { metadata: entry }, Priority::PersistedSnapshot)),
);
let state = if start_immediately {
BuildQueueState::Running
} else {
BuildQueueState::Paused
};
Self { queue }
Self { queue, state }
}
pub(super) fn is_running(&self) -> bool {
self.state == BuildQueueState::Running
}
/// Starts consuming queued builds. Returns whether the queue transitioned to running.
pub(super) fn start(&mut self) -> bool {
match self.state {
BuildQueueState::Paused => {
self.state = BuildQueueState::Running;
true
}
BuildQueueState::Running => false,
}
}
/// Pulls the next index root path to sync from the priority queue and returns it.
pub fn pick_next_sync(&mut self) -> Option<WorkspaceMetadata> {
if !self.is_running() {
return None;
}
self.queue.pop().map(|(entry, _priority)| entry.metadata)
}
@@ -0,0 +1,178 @@
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::PathBuf;
use super::{ContentHash, Fragment, FragmentLocation, FragmentMetadata};
use crate::index::locations::{CodeContextLocation, FileFragmentLocation};
#[derive(Default)]
pub struct ReadFragmentResult {
pub successfully_read: Vec<Fragment>,
pub fail_to_read: Vec<ContentHash>,
pub fail_to_read_path: Vec<PathBuf>,
}
pub fn build_fragments_from_file_contents(
metadatas: impl IntoIterator<Item = (ContentHash, FragmentMetadata)>,
file_contents: &HashMap<PathBuf, String>,
) -> ReadFragmentResult {
let mut fragments = Vec::new();
let mut fail_to_read = Vec::new();
let mut fail_to_read_path = Vec::new();
// Group fragments by file path.
let mut fragments_by_path: HashMap<_, Vec<_>> = HashMap::new();
for (content_hash, metadata) in metadatas {
fragments_by_path
.entry(metadata.absolute_path)
.or_default()
.push((content_hash, metadata.location.byte_range));
}
// Process each file and its fragments.
for (file_path, file_fragments) in fragments_by_path {
let mut has_failed_to_read_fragments = false;
if let Some(file_content) = file_contents.get(&file_path) {
// Process all fragments for this file.
for (content_hash, fragment_ranges) in file_fragments {
let start_idx = fragment_ranges.start.as_usize();
let end_idx = fragment_ranges.end.as_usize();
if start_idx <= end_idx
&& end_idx <= file_content.len()
&& file_content.is_char_boundary(start_idx)
&& file_content.is_char_boundary(end_idx)
{
let content = file_content[start_idx..end_idx].to_string();
if content.is_empty() {
log::trace!(
"Fragment for {:?} with range {:?} is empty",
file_path.display(),
fragment_ranges
);
fail_to_read.push(content_hash);
has_failed_to_read_fragments = true;
} else if ContentHash::from_content(&content) != content_hash {
log::trace!(
"Fragment for {:?} with range {:?} does not match its content hash",
file_path.display(),
fragment_ranges
);
fail_to_read.push(content_hash);
has_failed_to_read_fragments = true;
} else {
fragments.push(Fragment {
content,
content_hash,
location: FragmentLocation {
absolute_path: file_path.clone(),
byte_range: fragment_ranges,
},
});
}
} else {
log::trace!("Invalid byte range {fragment_ranges:?} for file: {file_path:?}");
fail_to_read.push(content_hash);
has_failed_to_read_fragments = true;
}
}
} else {
log::trace!("Failed to read file: {file_path:?}");
fail_to_read.extend(
file_fragments
.into_iter()
.map(|(content_hash, _)| content_hash),
);
has_failed_to_read_fragments = true;
}
if has_failed_to_read_fragments {
fail_to_read_path.push(file_path);
}
}
ReadFragmentResult {
successfully_read: fragments,
fail_to_read,
fail_to_read_path,
}
}
// Convert fragments into CodeContextLocations. This function groups and dedupes fragments in the same file.
// It also allows the caller to define a context line number surrounding the relevant fragment.
pub fn fragments_to_context_locations<'a>(
fragments: Vec<Fragment>,
metadata_for_hash: impl Fn(&ContentHash) -> Option<&'a [FragmentMetadata]>,
context_lines: usize,
) -> HashSet<CodeContextLocation> {
// Map to collect fragments by file path.
let mut fragments_by_path: HashMap<&PathBuf, Vec<Range<usize>>> = HashMap::new();
let mut whole_files = HashSet::new();
// First pass - collect all fragments and their line ranges by file path.
for fragment in &fragments {
if let Some(metadata) = metadata_for_hash(&fragment.content_hash).and_then(|metadatas| {
metadatas.iter().find(|m| {
m.absolute_path == fragment.location.absolute_path
&& m.location.byte_range == fragment.location.byte_range
})
}) {
// Add line range with context to the appropriate file's collection.
let path = &fragment.location.absolute_path;
let start = metadata.location.start_line.saturating_sub(context_lines);
let end = metadata.location.end_line + 1 + context_lines;
fragments_by_path.entry(path).or_default().push(start..end);
} else {
// Fallback to whole file if metadata not found.
whole_files.insert(fragment.location.absolute_path.clone());
}
}
// Second pass - process each file's fragments.
let mut result = HashSet::new();
// Process each file's fragments.
for (path, mut line_ranges) in fragments_by_path {
if line_ranges.is_empty() {
continue;
}
// We can skip the fragments if the entire file is already included in the context.
if whole_files.contains(path) {
continue;
}
// Sort ranges by start position.
line_ranges.sort_by_key(|range| range.start);
// Merge overlapping or adjacent ranges.
let mut merged_ranges: Vec<Range<usize>> = Vec::new();
for range in line_ranges {
if let Some(last) = merged_ranges.last_mut() {
// If current range overlaps or is adjacent to the last one, merge them.
if range.start <= last.end {
last.end = last.end.max(range.end);
} else {
merged_ranges.push(range);
}
} else {
merged_ranges.push(range);
}
}
// Add file fragment location with all merged ranges.
result.insert(CodeContextLocation::Fragment(FileFragmentLocation {
path: path.clone(),
line_ranges: merged_ranges,
}));
}
// Add whole files to the result set.
result.extend(whole_files.into_iter().map(CodeContextLocation::WholeFile));
result
}
#[cfg(test)]
#[path = "search_shaping_tests.rs"]
mod tests;
@@ -0,0 +1,133 @@
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::PathBuf;
use string_offset::ByteOffset;
use super::super::{ContentHash, Fragment, FragmentLocation, FragmentMetadata};
use super::{build_fragments_from_file_contents, fragments_to_context_locations};
use crate::index::locations::{CodeContextLocation, FileFragmentLocation};
fn metadata(
path: &str,
byte_range: Range<ByteOffset>,
start_line: usize,
end_line: usize,
) -> FragmentMetadata {
FragmentMetadata {
absolute_path: PathBuf::from(path),
location: super::super::fragment_metadata::FragmentLocation {
start_line,
end_line,
byte_range,
},
}
}
fn fragment(content: &str, path: &str, byte_range: Range<ByteOffset>) -> Fragment {
Fragment {
content: content.to_string(),
content_hash: ContentHash::from_content(content),
location: FragmentLocation {
absolute_path: PathBuf::from(path),
byte_range,
},
}
}
#[test]
fn builds_fragments_from_exact_byte_ranges() {
let path = PathBuf::from("/repo/src/lib.rs");
let content = "before\nneedle\nπ-after".to_string();
let fragment_content = "needle";
let start = content.find(fragment_content).unwrap();
let end = start + fragment_content.len();
let content_hash = ContentHash::from_content(fragment_content);
let metadata = metadata(
path.to_string_lossy().as_ref(),
ByteOffset::from(start)..ByteOffset::from(end),
2,
2,
);
let result = build_fragments_from_file_contents(
[(content_hash.clone(), metadata)],
&HashMap::from([(path.clone(), content)]),
);
assert_eq!(result.fail_to_read.len(), 0);
assert_eq!(result.successfully_read.len(), 1);
let fragment = &result.successfully_read[0];
assert_eq!(fragment.content, fragment_content);
assert_eq!(fragment.content_hash, content_hash);
assert_eq!(fragment.location.absolute_path, path);
}
#[test]
fn rejects_invalid_hashes_and_byte_ranges() {
let path = PathBuf::from("/repo/src/lib.rs");
let content = "abcπdef".to_string();
let bad_hash_metadata = metadata(
path.to_string_lossy().as_ref(),
ByteOffset::from(0)..ByteOffset::from(3),
1,
1,
);
let invalid_boundary_metadata = metadata(
path.to_string_lossy().as_ref(),
ByteOffset::from(4)..ByteOffset::from(5),
1,
1,
);
let result = build_fragments_from_file_contents(
[
(ContentHash::from_content("not abc"), bad_hash_metadata),
(ContentHash::from_content("π"), invalid_boundary_metadata),
],
&HashMap::from([(path.clone(), content)]),
);
assert!(result.successfully_read.is_empty());
assert_eq!(result.fail_to_read.len(), 2);
assert_eq!(result.fail_to_read_path, vec![path]);
}
#[test]
fn shapes_fragments_into_merged_context_locations() {
let path = "/repo/src/lib.rs";
let fragment_a = fragment("a", path, ByteOffset::from(0)..ByteOffset::from(1));
let fragment_b = fragment("b", path, ByteOffset::from(2)..ByteOffset::from(3));
let metadata_a = metadata(path, ByteOffset::from(0)..ByteOffset::from(1), 10, 12);
let metadata_b = metadata(path, ByteOffset::from(2)..ByteOffset::from(3), 15, 17);
let metadata_by_hash = HashMap::from([
(fragment_a.content_hash.clone(), vec![metadata_a]),
(fragment_b.content_hash.clone(), vec![metadata_b]),
]);
let result = fragments_to_context_locations(
vec![fragment_a, fragment_b],
|hash| metadata_by_hash.get(hash).map(Vec::as_slice),
2,
);
assert_eq!(
result,
HashSet::from([CodeContextLocation::Fragment(FileFragmentLocation {
path: PathBuf::from(path),
line_ranges: std::iter::once(8..20).collect(),
})])
);
}
#[test]
fn falls_back_to_whole_file_when_metadata_is_missing() {
let path = "/repo/src/lib.rs";
let fragment = fragment("a", path, ByteOffset::from(0)..ByteOffset::from(1));
let result = fragments_to_context_locations(vec![fragment], |_| None, 2);
assert_eq!(
result,
HashSet::from([CodeContextLocation::WholeFile(PathBuf::from(path))])
);
}
@@ -1,20 +1,21 @@
use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::Utc;
#[cfg(feature = "local_fs")]
use galaxyui::ModelHandle;
#[cfg(feature = "local_fs")]
use repo_metadata::Repository;
use std::{
collections::HashSet,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
time::Duration,
};
#[cfg(feature = "local_fs")]
use galaxyui_core::ModelHandle;
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
use super::Error as CodebaseIndexError;
use std::sync::Arc;
use galaxyui::ModelContext;
use galaxyui_core::ModelContext;
use anyhow::Context;
use galaxy_core::safe_info;
use super::{store_client::StoreClient, CodebaseIndex, EmbeddingConfig};
@@ -33,11 +34,47 @@ const REPO_SNAPSHOT_SHELF_LIFE_DURATION: Duration =
/// Subdirectory inside the app's statedirectory that holds snapshot files.
const REPO_SNAPSHOT_SUBDIR_NAME: &str = "codebase_index_snapshots";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SnapshotStorage {
dir: PathBuf,
}
impl SnapshotStorage {
/// Construct snapshot storage using the app's default secure snapshot directory.
pub fn app_default() -> Option<Self> {
snapshot_dir().map(|dir| Self { dir })
}
/// Construct snapshot storage rooted at the supplied directory, creating it if needed.
pub fn from_dir(dir: PathBuf) -> Option<Self> {
if !dir.is_dir() {
std::fs::create_dir_all(&dir).ok()?;
}
Some(Self { dir })
}
pub fn path(&self) -> &Path {
&self.dir
}
#[cfg(feature = "local_fs")]
pub(super) fn is_app_default(&self) -> bool {
self.dir == default_snapshot_dir_path()
}
pub(super) fn has_snapshot(&self, repo_path: &Path) -> bool {
self.snapshot_path(repo_path).is_file()
}
pub(super) fn snapshot_path(&self, repo_path: &Path) -> PathBuf {
snapshot_path(&self.dir, repo_path)
}
}
/// Splits a list of codebase indices into invalid and valid indices,
/// based on their last write date and whether they have a corresponding snapshot file.
pub(super) fn split_snapshot_metadata_by_validity(
persisted_codebase_indices: Vec<WorkspaceMetadata>,
snapshot_storage: Option<&SnapshotStorage>,
) -> (Vec<WorkspaceMetadata>, Vec<WorkspaceMetadata>) {
let now = Utc::now();
persisted_codebase_indices
@@ -48,7 +85,8 @@ pub(super) fn split_snapshot_metadata_by_validity(
index_metadata.path
);
index_metadata.is_expired(now, REPO_SNAPSHOT_SHELF_LIFE_DAYS)
|| !has_snapshot(&index_metadata.path)
|| !snapshot_storage
.is_some_and(|storage| storage.has_snapshot(&index_metadata.path))
})
}
@@ -159,12 +197,7 @@ pub(super) fn read_snapshot(
}
pub(super) fn has_snapshot(repo_path: &Path) -> bool {
let Some(snapshot_dir) = snapshot_dir() else {
return false;
};
let snapshot_path = snapshot_path(snapshot_dir.as_path(), repo_path);
snapshot_path.is_file()
SnapshotStorage::app_default().is_some_and(|storage| storage.has_snapshot(repo_path))
}
/// Construct a directory to store index snapshots, if it doesn't already exist,
@@ -175,9 +208,7 @@ pub(super) fn snapshot_dir() -> Option<PathBuf> {
#[cfg(feature = "local_fs")]
{
let base_dir =
galaxy_core::paths::secure_state_dir().unwrap_or_else(galaxy_core::paths::state_dir);
let snapshot_dir_path = base_dir.join(REPO_SNAPSHOT_SUBDIR_NAME);
let snapshot_dir_path = default_snapshot_dir_path();
if !snapshot_dir_path.is_dir() {
std::fs::create_dir_all(&snapshot_dir_path).ok()?;
@@ -186,9 +217,15 @@ pub(super) fn snapshot_dir() -> Option<PathBuf> {
}
}
#[cfg(feature = "local_fs")]
fn default_snapshot_dir_path() -> PathBuf {
galaxy_core::paths::secure_state_dir()
.unwrap_or_else(galaxy_core::paths::state_dir)
.join(REPO_SNAPSHOT_SUBDIR_NAME)
}
/// Constructs a snapshot path given a base directory and the codebase index's root path.
pub(super) fn snapshot_path(snapshot_dir: &Path, repo_path: &Path) -> PathBuf {
// Use a hash the repo_path to create a unique filename
// Use a hash of the repo_path to create a unique filename
let mut hasher = DefaultHasher::new();
repo_path.hash(&mut hasher);
let snapshot_file_name = format!("snapshot_{}", hasher.finish());
@@ -1,7 +1,57 @@
use chrono::Duration;
use chrono::{Duration, Utc};
use virtual_fs::{Stub, VirtualFS};
use super::*;
fn workspace_metadata(path: impl Into<PathBuf>) -> WorkspaceMetadata {
WorkspaceMetadata {
path: path.into(),
navigated_ts: None,
modified_ts: Some(Utc::now()),
queried_ts: None,
}
}
#[test]
#[cfg(feature = "local_fs")]
fn snapshot_storage_app_default_matches_snapshot_dir() {
VirtualFS::test(
"snapshot_storage_app_default_matches_snapshot_dir",
|_dirs, _sandbox| {
let storage = SnapshotStorage::app_default().unwrap();
assert_eq!(storage.path(), snapshot_dir().unwrap());
assert!(storage.is_app_default());
},
);
}
#[test]
fn split_snapshot_metadata_by_validity_uses_injected_snapshot_dir() {
let snapshot_dir = tempfile::tempdir().unwrap();
let storage = SnapshotStorage::from_dir(snapshot_dir.path().join("daemon")).unwrap();
let repo_path = PathBuf::from("/remote/repo");
std::fs::write(storage.snapshot_path(&repo_path), b"snapshot").unwrap();
let (invalid_metadata, valid_metadata) =
split_snapshot_metadata_by_validity(vec![workspace_metadata(&repo_path)], Some(&storage));
assert!(invalid_metadata.is_empty());
assert_eq!(valid_metadata.len(), 1);
assert_eq!(valid_metadata[0].path, repo_path);
}
#[test]
fn split_snapshot_metadata_by_validity_rejects_missing_injected_snapshot() {
let snapshot_dir = tempfile::tempdir().unwrap();
let storage = SnapshotStorage::from_dir(snapshot_dir.path().join("daemon")).unwrap();
let repo_path = PathBuf::from("/remote/repo");
let (invalid_metadata, valid_metadata) =
split_snapshot_metadata_by_validity(vec![workspace_metadata(&repo_path)], Some(&storage));
assert_eq!(invalid_metadata.len(), 1);
assert_eq!(invalid_metadata[0].path, repo_path);
assert!(valid_metadata.is_empty());
}
#[test]
fn test_clean_up_snapshot_files() {
@@ -71,12 +121,7 @@ fn test_clean_up_snapshot_files() {
.unwrap();
// Create test metadata that only includes the valid file
let metadata = vec![WorkspaceMetadata {
path: test_path,
navigated_ts: None,
modified_ts: None,
queried_ts: None,
}];
let metadata = vec![workspace_metadata(test_path)];
// Run cleanup
clean_up_snapshot_files(&snapshot_dir_absolute_path, &metadata);
@@ -1,9 +1,8 @@
use std::collections::{HashMap, HashSet};
use std::fmt::Debug;
use std::time::Duration;
use async_trait::async_trait;
use std::{
collections::{HashMap, HashSet},
fmt::Debug,
time::Duration,
};
use super::{
CodebaseContextConfig, ContentHash, EmbeddingConfig, Error, Fragment, NodeHash, RepoMetadata,
@@ -1,28 +1,23 @@
use anyhow::{anyhow, Result};
use galaxy_core::sync_queue::{IsTransientError, SyncQueue, SyncQueueTaskTrait};
use itertools::Itertools;
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::mem;
use std::ops::AddAssign;
use std::pin::Pin;
use std::{
collections::{HashMap, HashSet},
mem,
sync::Arc,
};
use std::sync::Arc;
use super::{CodebaseContextConfig, NodeHash};
use crate::index::full_source_code_embedding::store_client::IntermediateNode;
use anyhow::{anyhow, Result};
use itertools::Itertools;
use galaxy_core::sync_queue::{IsTransientError, SyncQueue, SyncQueueTaskTrait};
use super::changed_files::ChangedFiles;
use super::codebase_index::{build_fragments_from_metadata, SyncProgress};
use super::fragment_metadata::LeafToFragmentMetadataMapping;
use super::merkle_tree::{MerkleTree, NodeLens};
use super::store_client::StoreClient;
use super::{
changed_files::ChangedFiles,
codebase_index::{build_fragments_from_metadata, SyncProgress},
fragment_metadata::LeafToFragmentMetadataMapping,
merkle_tree::{MerkleTree, NodeLens},
store_client::StoreClient,
EmbeddingConfig, Error, RepoMetadata,
CodebaseContextConfig, ContentHash, EmbeddingConfig, Error, Fragment, NodeHash, RepoMetadata,
};
use super::{ContentHash, Fragment};
use crate::index::full_source_code_embedding::store_client::IntermediateNode;
const SYNC_NODE_BATCH_SIZE: usize = 500;
// Minimum node batch size used for updates.
@@ -1,12 +1,11 @@
use std::collections::HashMap;
use futures::executor::block_on;
use virtual_fs::VirtualFS;
use super::batch_leaves_by_size;
use crate::index::full_source_code_embedding::merkle_tree::{construct_test_merkle_tree, NodeLens};
use virtual_fs::VirtualFS;
/// Collect all leaf nodes from a merkle tree by walking it recursively.
fn collect_leaves<'a>(node: NodeLens<'a>) -> Vec<NodeLens<'a>> {
if node.is_leaf() {