1use std::{
7 collections::{BTreeMap, BTreeSet},
8 error::Error,
9 fmt, fs,
10 path::{Path, PathBuf},
11};
12
13mod changes;
14mod configuration;
15pub(crate) mod feature_flags;
16mod filesystem_delta;
17mod full_snapshot;
18mod git;
19mod grep;
20mod identity;
21mod ids;
22mod languages;
23mod parser;
24mod pipeline;
25mod resolution;
26mod scope;
27mod snapshot;
28mod source;
29pub(crate) mod source_roots;
30
31#[cfg(test)]
32#[path = "tests/source/declarations.rs"]
33mod source_declaration_tests;
34#[cfg(test)]
35#[path = "tests/source/filesystem.rs"]
36mod source_filesystem_tests;
37#[cfg(test)]
38#[path = "tests/source/layout.rs"]
39mod source_layout_tests;
40#[cfg(test)]
41#[path = "tests/fixtures.rs"]
42mod test_fixtures;
43#[cfg(test)]
44mod tests;
45#[cfg(test)]
46#[path = "tests/source/worktree_overlay.rs"]
47mod worktree_overlay_tests;
48
49use crate::domain::{
50 CodeFileFingerprint, CodeIndexMode, CodeIndexSnapshot, CodePathTombstone,
51 CodeRepositoryRegistration, CodeRepositorySelector, RepositoryCodeRange,
52};
53
54use changes::{GitChange, diff_changes, tracked_entries, worktree_changed_paths};
55#[cfg(test)]
56pub(crate) use changes::{
57 reset_tracked_entries_call_count_for_root, tracked_entries_call_count_for_root,
58};
59use filesystem_delta::build_filesystem_delta_snapshot;
60pub(crate) use filesystem_delta::changed_paths_for_filesystem_diff;
61use full_snapshot::build_full_snapshot;
62#[cfg(test)]
63pub(crate) use full_snapshot::mutate_next_filesystem_full_snapshot_read;
64use git::{git_bytes, resolve_ref, resolve_tree};
65pub(crate) use grep::{
66 SOURCE_GREP_CANDIDATE_FILE_LIMIT, SourceGrepKind, SourceGrepMatch, SourceGrepOutcome,
67 SourceGrepRequest, source_grep_matches,
68};
69use ids::{stable_content_hash, stable_hash64, stable_id};
70use parser::parse_indexed_file;
71pub use pipeline::{CodeIndexPlan, prepare_full_index_plan};
72pub use resolution::{
73 resolve_repository_ref, resolve_repository_ref_with_filters,
74 resolve_repository_ref_with_path_filters, resolve_repository_snapshot,
75 resolve_repository_snapshot_with_filters, resolve_repository_snapshot_with_path_filters,
76};
77use scope::{
78 discover_source_layout, effective_index_path_filters, path_is_selected_with_layout,
79 path_scope_overlaps, scoped_source_snapshot_for_filters,
80};
81pub use scope::{partition_changed_paths_for_selector, preview_repository_scope};
82use snapshot::{SnapshotBuild, SnapshotScopeFilters};
83use source::{
84 registration_source, source_bytes_after_content_verification, source_commit_is_filesystem,
85 source_kind,
86};
87
88#[cfg(test)]
89use {
90 identity::resolve_reference_targets,
91 languages::language_id,
92 scope::{path_is_selected, path_scope_allows},
93};
94
95pub(crate) const REGISTRATION_LANGUAGE_FILTER_ERROR: &str = concat!(
96 "registration language filters are not supported; ",
97 "register the full language surface and use query-time --language filters to narrow results"
98);
99
100#[derive(Debug)]
102pub enum CodeIndexError {
103 Io(std::io::Error),
104 Git { args: Vec<String>, message: String },
105 TreeSitter(String),
106 InvalidInput(String),
107}
108
109impl fmt::Display for CodeIndexError {
110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 Self::Io(error) => write!(formatter, "code index I/O failed: {error}"),
113 Self::Git { args, message } => {
114 write!(formatter, "git command failed ({args:?}): {message}")
115 }
116 Self::TreeSitter(message) => write!(formatter, "tree-sitter parse failed: {message}"),
117 Self::InvalidInput(message) => write!(formatter, "invalid code index input: {message}"),
118 }
119 }
120}
121
122impl Error for CodeIndexError {}
123
124impl From<std::io::Error> for CodeIndexError {
125 fn from(error: std::io::Error) -> Self {
126 Self::Io(error)
127 }
128}
129
130pub fn register_repository(
132 path: impl AsRef<Path>,
133 alias: impl Into<String>,
134 path_filters: Vec<String>,
135 language_filters: Vec<String>,
136) -> Result<CodeRepositoryRegistration, CodeIndexError> {
137 if !language_filters.is_empty() {
138 return Err(CodeIndexError::InvalidInput(
139 REGISTRATION_LANGUAGE_FILTER_ERROR.to_owned(),
140 ));
141 }
142 let source = registration_source(path.as_ref())?;
143 let root_identity = source.root.display().to_string();
144 let repository_id = source.identity;
145 let alias = explicit_or_project_alias(alias, &source.root)?;
146
147 CodeRepositoryRegistration::new(
148 repository_id,
149 alias,
150 root_identity,
151 path_filters,
152 language_filters,
153 )
154 .map_err(|error| CodeIndexError::InvalidInput(error.to_string()))
155}
156
157fn explicit_or_project_alias(
158 alias: impl Into<String>,
159 root: &Path,
160) -> Result<String, CodeIndexError> {
161 let alias = alias.into();
162 if !alias.trim().is_empty() {
163 return Ok(alias);
164 }
165
166 root.file_name()
167 .and_then(|name| name.to_str())
168 .map(str::trim)
169 .filter(|name| !name.is_empty())
170 .map(ToOwned::to_owned)
171 .ok_or_else(|| {
172 CodeIndexError::InvalidInput(
173 "repository alias is empty and Git root has no project directory name".to_owned(),
174 )
175 })
176}
177
178pub fn build_index_snapshot(
180 registration: &CodeRepositoryRegistration,
181 selector: &CodeRepositorySelector,
182 mode: CodeIndexMode,
183 previous_hashes: Vec<CodeFileFingerprint>,
184) -> Result<CodeIndexSnapshot, CodeIndexError> {
185 build_index_snapshot_with_base_commit(registration, selector, mode, previous_hashes, None)
186}
187
188pub(crate) fn build_index_snapshot_with_base_commit(
189 registration: &CodeRepositoryRegistration,
190 selector: &CodeRepositorySelector,
191 mode: CodeIndexMode,
192 previous_hashes: Vec<CodeFileFingerprint>,
193 base_resolved_commit_sha: Option<String>,
194) -> Result<CodeIndexSnapshot, CodeIndexError> {
195 let root = PathBuf::from(®istration.root_path);
196 let previous_hashes = previous_hashes
197 .into_iter()
198 .map(|fingerprint| (fingerprint.path, fingerprint.blob_hash))
199 .collect::<BTreeMap<_, _>>();
200
201 match mode {
202 CodeIndexMode::Full => build_full_snapshot(registration, selector, &root),
203 CodeIndexMode::Incremental { base_ref, head_ref } => build_incremental_snapshot(
204 registration,
205 selector,
206 &root,
207 &base_ref,
208 &head_ref,
209 &previous_hashes,
210 base_resolved_commit_sha.as_deref(),
211 ),
212 CodeIndexMode::WorktreeOverlay => build_worktree_overlay_snapshot(
213 registration,
214 selector,
215 &root,
216 &previous_hashes,
217 base_resolved_commit_sha.as_deref(),
218 ),
219 }
220}
221
222pub fn changed_paths_for_diff(
223 root_path: impl AsRef<Path>,
224 base_ref: &str,
225 head_ref: &str,
226) -> Result<Vec<String>, CodeIndexError> {
227 changed_paths_for_diff_with_filters(root_path, base_ref, head_ref, &[], &[])
228}
229
230pub fn changed_paths_for_diff_with_path_filters(
231 root_path: impl AsRef<Path>,
232 base_ref: &str,
233 head_ref: &str,
234 path_filters: &[String],
235) -> Result<Vec<String>, CodeIndexError> {
236 changed_paths_for_diff_with_filters(root_path, base_ref, head_ref, path_filters, &[])
237}
238
239pub fn changed_paths_for_diff_with_filters(
240 root_path: impl AsRef<Path>,
241 base_ref: &str,
242 head_ref: &str,
243 path_filters: &[String],
244 language_filters: &[String],
245) -> Result<Vec<String>, CodeIndexError> {
246 if source_commit_is_filesystem(base_ref) || source_commit_is_filesystem(head_ref) {
247 if base_ref == head_ref {
248 return Ok(Vec::new());
249 }
250 let base_commit = resolve_repository_ref_with_filters(
251 root_path.as_ref(),
252 base_ref,
253 path_filters,
254 language_filters,
255 )?;
256 let head_commit = resolve_repository_ref_with_filters(
257 root_path.as_ref(),
258 head_ref,
259 path_filters,
260 language_filters,
261 )?;
262 if base_commit == head_commit {
263 return Ok(Vec::new());
264 }
265 let snapshot = scoped_source_snapshot_for_filters(
266 root_path.as_ref(),
267 head_ref,
268 path_filters,
269 language_filters,
270 )?;
271 return Ok(snapshot
272 .entries
273 .into_iter()
274 .map(|entry| entry.path)
275 .collect());
276 }
277 if source_kind(root_path.as_ref())?.is_filesystem() {
278 if base_ref == head_ref {
279 return Ok(Vec::new());
280 }
281 let snapshot = scoped_source_snapshot_for_filters(
282 root_path.as_ref(),
283 head_ref,
284 path_filters,
285 language_filters,
286 )?;
287 return Ok(snapshot
288 .entries
289 .into_iter()
290 .map(|entry| entry.path)
291 .collect());
292 }
293 let changes = diff_changes(root_path.as_ref(), base_ref, head_ref)?;
294
295 Ok(impact_paths_from_changes(changes))
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
300pub(crate) struct SourceDeclarationMatch {
301 pub(crate) path: String,
302 pub(crate) excerpt: String,
303 pub(crate) byte_range: RepositoryCodeRange,
304 pub(crate) line_range: RepositoryCodeRange,
305}
306
307const MAX_SOURCE_DECLARATION_FILES: usize = 8;
308const MAX_SOURCE_DECLARATION_BYTES: usize = 512 * 1024;
309const WORKTREE_UNTRACKED_BROAD_SEGMENTS: &[&str] = &[
310 ".cache",
311 ".next",
312 ".nuxt",
313 ".parcel-cache",
314 ".pytest_cache",
315 ".ruff_cache",
316 ".tox",
317 ".venv",
318 "__pycache__",
319 "build",
320 "coverage",
321 "dist",
322 "node_modules",
323 "out",
324 "target",
325 "third_party",
326 "vendor",
327 "venv",
328];
329
330pub(crate) fn source_declarations_for_identity(
332 registration: &CodeRepositoryRegistration,
333 commit: &str,
334 paths: Vec<String>,
335 path_filters: &[String],
336 language_filters: &[String],
337 identity: &str,
338) -> Result<Vec<SourceDeclarationMatch>, CodeIndexError> {
339 if !simple_source_identifier(identity) {
340 return Ok(Vec::new());
341 }
342
343 let root = PathBuf::from(®istration.root_path);
344 let filesystem_hashes = if source_commit_is_filesystem(commit) {
345 match scope::scoped_source_snapshot_for_registration(registration, commit).or_else(|_| {
346 scope::scoped_source_snapshot_for_registration_filters(
347 registration,
348 commit,
349 path_filters,
350 language_filters,
351 )
352 }) {
353 Ok(snapshot) => Some(snapshot.content_hashes),
354 Err(_) => return Ok(Vec::new()),
355 }
356 } else {
357 None
358 };
359 let mut seen = BTreeSet::new();
360 let mut files_considered = 0usize;
361 let mut matches = Vec::new();
362 for path in paths {
363 if files_considered >= MAX_SOURCE_DECLARATION_FILES {
364 break;
365 }
366 if !safe_git_blob_path(&path) || !seen.insert(path.clone()) {
367 continue;
368 }
369 files_considered += 1;
370 let Ok(bytes) = source_bytes_after_content_verification(
371 &root,
372 commit,
373 &path,
374 filesystem_hashes.as_ref(),
375 ) else {
376 continue;
377 };
378 if bytes.len() > MAX_SOURCE_DECLARATION_BYTES {
379 continue;
380 }
381 let Ok(content) = std::str::from_utf8(&bytes) else {
382 continue;
383 };
384 if let Some(declaration) = first_source_declaration_match(&path, content, identity)? {
385 matches.push(declaration);
386 }
387 }
388
389 Ok(matches)
390}
391
392fn first_source_declaration_match(
393 path: &str,
394 content: &str,
395 identity: &str,
396) -> Result<Option<SourceDeclarationMatch>, CodeIndexError> {
397 let mut byte_start = 0usize;
398 for (line_index, line) in content.split_inclusive('\n').enumerate() {
399 let line_without_newline = line.trim_end_matches(['\r', '\n']);
400 let byte_end = byte_start + line_without_newline.len();
401 if source_line_defines_identity(line_without_newline.trim(), identity) {
402 let line_number = line_index + 1;
403 return Ok(Some(SourceDeclarationMatch {
404 path: path.to_owned(),
405 excerpt: line_without_newline.trim().to_owned(),
406 byte_range: RepositoryCodeRange::new("byte_range", byte_start, byte_end)
407 .map_err(|error| CodeIndexError::InvalidInput(error.to_string()))?,
408 line_range: RepositoryCodeRange::new("line_range", line_number, line_number)
409 .map_err(|error| CodeIndexError::InvalidInput(error.to_string()))?,
410 }));
411 }
412 byte_start += line.len();
413 }
414
415 Ok(None)
416}
417
418pub(crate) fn source_line_defines_identity(line: &str, identity: &str) -> bool {
419 if line.is_empty() || !line_contains_identifier(line, identity) {
420 return false;
421 }
422 if line.starts_with("typedef ") || line.contains(" typedef ") {
423 return true;
424 }
425 if line.starts_with("#define ") {
426 return line
427 .strip_prefix("#define ")
428 .is_some_and(|suffix| line_starts_with_identifier(suffix, identity));
429 }
430 if line
431 .strip_prefix("using ")
432 .or_else(|| line.strip_prefix("typealias "))
433 .is_some_and(|suffix| line_starts_with_identifier(suffix, identity))
434 {
435 return true;
436 }
437 if ["struct ", "class ", "enum ", "union ", "interface "]
438 .into_iter()
439 .filter_map(|prefix| line.strip_prefix(prefix))
440 .any(|suffix| line_starts_with_identifier(suffix, identity))
441 {
442 return true;
443 }
444
445 line.contains('(') && line_looks_like_function_definition(line, identity)
446}
447
448fn line_looks_like_function_definition(line: &str, identity: &str) -> bool {
449 line.match_indices(identity).any(|(identity_start, _)| {
450 if !identifier_match_has_boundaries(line, identity, identity_start) {
451 return false;
452 }
453 let prefix = line[..identity_start].trim_start();
454 let suffix = line[identity_start + identity.len()..].trim_start();
455 if !suffix.starts_with('(') || prefix.contains('=') {
456 return false;
457 }
458 if prefix.chars().next_back().is_some_and(|character| {
459 matches!(character, '(' | '.' | '>') || (character == ':' && !prefix.ends_with("::"))
460 }) {
461 return false;
462 }
463 !matches!(
464 prefix.split_whitespace().next(),
465 Some("if" | "for" | "while" | "switch" | "return")
466 )
467 })
468}
469
470fn line_starts_with_identifier(line: &str, identifier: &str) -> bool {
471 let trimmed = line.trim_start();
472 trimmed.starts_with(identifier)
473 && trimmed
474 .get(identifier.len()..)
475 .is_some_and(|suffix| suffix.chars().next().is_none_or(|c| !is_identifier_char(c)))
476}
477
478fn line_contains_identifier(line: &str, identifier: &str) -> bool {
479 line.match_indices(identifier)
480 .any(|(start, _)| identifier_match_has_boundaries(line, identifier, start))
481}
482
483fn identifier_match_has_boundaries(line: &str, identifier: &str, start: usize) -> bool {
484 let end = start + identifier.len();
485 line.get(..start).is_some_and(|prefix| {
486 prefix
487 .chars()
488 .next_back()
489 .is_none_or(|c| !is_identifier_char(c))
490 }) && line
491 .get(end..)
492 .is_some_and(|suffix| suffix.chars().next().is_none_or(|c| !is_identifier_char(c)))
493}
494
495pub(crate) fn simple_source_identifier(value: &str) -> bool {
496 !value.is_empty() && value.chars().all(is_identifier_char)
497}
498
499fn is_identifier_char(character: char) -> bool {
500 character.is_ascii_alphanumeric() || character == '_'
501}
502
503fn safe_git_blob_path(path: &str) -> bool {
504 !path.is_empty()
505 && !path.starts_with('/')
506 && !path.contains('\\')
507 && !path.contains('\0')
508 && !path.contains('\n')
509 && !path.contains('\r')
510 && path.split('/').all(|part| !part.is_empty() && part != "..")
511}
512
513fn impact_paths_from_changes(changes: Vec<GitChange>) -> Vec<String> {
514 let mut paths = Vec::new();
515 for change in changes {
516 match change {
517 GitChange::AddedOrModified { path }
518 | GitChange::Deleted { path }
519 | GitChange::TypeChanged { path } => paths.push(path),
520 GitChange::Renamed { old_path, new_path } => {
521 paths.push(old_path);
522 paths.push(new_path);
523 }
524 GitChange::Copied { new_path, .. } => paths.push(new_path),
525 }
526 }
527 paths.sort();
528 paths.dedup();
529
530 paths
531}
532
533pub fn deleted_symbol_names_for_diff(
535 registration: &CodeRepositoryRegistration,
536 selector: &CodeRepositorySelector,
537 base_ref: &str,
538 head_ref: &str,
539) -> Result<Vec<String>, CodeIndexError> {
540 let root = PathBuf::from(®istration.root_path);
541 if source_commit_is_filesystem(base_ref) || source_commit_is_filesystem(head_ref) {
542 return Ok(Vec::new());
543 }
544 if source_kind(&root)?.is_filesystem() {
545 return Ok(Vec::new());
546 }
547 let base_commit = resolve_ref(&root, base_ref)?;
548 let changes = diff_changes(&root, base_ref, head_ref)?;
549 let base_entries = tracked_entries(&root, &base_commit)?;
550 let source_layout = discover_source_layout(&base_entries);
551 let mut names = Vec::new();
552
553 for change in changes {
554 let deleted_path = match change {
555 GitChange::Deleted { path } | GitChange::Renamed { old_path: path, .. } => path,
556 GitChange::AddedOrModified { .. }
557 | GitChange::Copied { .. }
558 | GitChange::TypeChanged { .. } => continue,
559 };
560 if !path_is_selected_with_layout(&deleted_path, registration, selector, &source_layout) {
561 continue;
562 }
563 let bytes = git_bytes(&root, ["show", &format!("{base_commit}:{deleted_path}")])?;
564 let mut build = SnapshotBuild::new_with_selector(
565 registration,
566 selector,
567 base_commit.clone(),
568 "deleted-symbol-seed".to_owned(),
569 true,
570 1,
571 0,
572 );
573 parse_indexed_file(&mut build, &deleted_path, &bytes)?;
574 names.extend(build.symbols.into_iter().map(|symbol| symbol.name));
575 }
576 names.sort();
577 names.dedup();
578
579 Ok(names)
580}
581
582pub(crate) fn repository_uses_filesystem_source(
583 root_path: impl AsRef<Path>,
584) -> Result<bool, CodeIndexError> {
585 Ok(source_kind(root_path.as_ref())?.is_filesystem())
586}
587
588fn build_incremental_snapshot(
589 registration: &CodeRepositoryRegistration,
590 selector: &CodeRepositorySelector,
591 root: &Path,
592 base_ref: &str,
593 head_ref: &str,
594 previous_hashes: &BTreeMap<String, String>,
595 base_resolved_commit_sha: Option<&str>,
596) -> Result<CodeIndexSnapshot, CodeIndexError> {
597 if source_commit_is_filesystem(base_ref)
598 || source_commit_is_filesystem(head_ref)
599 || base_resolved_commit_sha.is_some_and(source_commit_is_filesystem)
600 || source_kind(root)?.is_filesystem()
601 {
602 return build_filesystem_delta_snapshot(
603 registration,
604 selector,
605 root,
606 head_ref,
607 previous_hashes,
608 base_resolved_commit_sha,
609 );
610 }
611 let base_commit = resolve_ref(root, base_ref)?;
612 let commit = resolve_ref(root, head_ref)?;
613 let tree_hash = resolve_tree(root, &commit)?;
614 let changes = diff_changes(root, base_ref, head_ref)?;
615 let base_entries = tracked_entries(root, &base_commit)?;
616 let base_source_layout = discover_source_layout(&base_entries);
617 let head_entries = tracked_entries(root, &commit)?;
618 let source_layout = discover_source_layout(&head_entries);
619 let path_filters = effective_index_path_filters(registration, selector, &source_layout);
620 let language_filters =
621 snapshot::merged_filters(®istration.language_filters, &selector.language_filters);
622 let mut build = SnapshotBuild::new_with_scope_filters(
623 registration,
624 commit,
625 tree_hash,
626 SnapshotScopeFilters {
627 path_filters,
628 language_filters,
629 },
630 false,
631 changes.len(),
632 0,
633 );
634 build.base_resolved_commit_sha = Some(base_commit.clone());
635 let parse_context = ChangedPathParseContext {
636 registration,
637 selector,
638 root,
639 previous_hashes,
640 source_layout: &source_layout,
641 };
642
643 for change in changes {
644 match change {
645 GitChange::Deleted { path } => {
646 if path_is_selected_with_layout(&path, registration, selector, &base_source_layout)
647 {
648 build.deleted_paths.push(path);
649 }
650 }
651 GitChange::Renamed { old_path, new_path } => {
652 if path_is_selected_with_layout(
653 &old_path,
654 registration,
655 selector,
656 &base_source_layout,
657 ) {
658 build.deleted_paths.push(old_path.clone());
659 build.tombstones.push(CodePathTombstone {
660 repository_id: registration.repository_id.clone(),
661 source_scope: build.source_scope.clone(),
662 old_path,
663 new_path: Some(new_path.clone()),
664 base_ref: base_ref.to_owned(),
665 head_ref: head_ref.to_owned(),
666 });
667 }
668 parse_changed_path(&mut build, &parse_context, &new_path)?;
669 }
670 GitChange::Copied { old_path, new_path } => {
671 if path_is_selected_with_layout(&new_path, registration, selector, &source_layout) {
672 build.tombstones.push(CodePathTombstone {
673 repository_id: registration.repository_id.clone(),
674 source_scope: build.source_scope.clone(),
675 old_path,
676 new_path: Some(new_path.clone()),
677 base_ref: base_ref.to_owned(),
678 head_ref: head_ref.to_owned(),
679 });
680 }
681 parse_changed_path(&mut build, &parse_context, &new_path)?;
682 }
683 GitChange::AddedOrModified { path } | GitChange::TypeChanged { path } => {
684 parse_changed_path(&mut build, &parse_context, &path)?;
685 }
686 }
687 }
688
689 Ok(build.finish())
690}
691
692fn build_worktree_overlay_snapshot(
693 registration: &CodeRepositoryRegistration,
694 selector: &CodeRepositorySelector,
695 root: &Path,
696 previous_hashes: &BTreeMap<String, String>,
697 base_resolved_commit_sha: Option<&str>,
698) -> Result<CodeIndexSnapshot, CodeIndexError> {
699 if source_commit_is_filesystem(&selector.ref_selector)
700 || base_resolved_commit_sha.is_some_and(source_commit_is_filesystem)
701 || source_kind(root)?.is_filesystem()
702 {
703 return build_filesystem_delta_snapshot(
704 registration,
705 selector,
706 root,
707 &selector.ref_selector,
708 previous_hashes,
709 base_resolved_commit_sha,
710 );
711 }
712 let commit = resolve_ref(root, &selector.ref_selector)?;
713 let head_commit = resolve_ref(root, "HEAD")?;
714 if commit != head_commit {
715 return Err(CodeIndexError::InvalidInput(format!(
716 "worktree overlay ref '{}' resolves to {}, but checked-out HEAD is {}",
717 selector.ref_selector, commit, head_commit
718 )));
719 }
720 let status = git_bytes(
721 root,
722 ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
723 )?;
724 let changes = worktree_changed_paths(&status);
725 if changes.is_empty() {
726 return build_full_snapshot(registration, selector, root);
727 }
728 let mut overlay_hash_input = Vec::new();
729 let mut deleted_paths = Vec::new();
730 let mut files_to_parse = Vec::new();
731 let mut skipped_unchanged_count = 0;
732 for change in &changes {
733 if let Some(deleted_path) = &change.deleted_source {
734 if scope::path_is_selected(deleted_path, registration, selector) {
735 overlay_hash_input.extend_from_slice(b"D\0");
736 overlay_hash_input.extend_from_slice(deleted_path.as_bytes());
737 overlay_hash_input.push(0);
738 deleted_paths.push(deleted_path.clone());
739 }
740 }
741 let path = &change.path;
742 if !path_scope_overlaps(path, registration, selector) {
743 continue;
744 }
745 if change.is_untracked()
746 && !worktree_untracked_path_is_selected(path, registration, selector)
747 {
748 continue;
749 }
750 let full_path = root.join(path);
751 let metadata = match fs::symlink_metadata(&full_path) {
752 Ok(metadata) => metadata,
753 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
754 if scope::path_is_selected(path, registration, selector) {
755 overlay_hash_input.extend_from_slice(b"D\0");
756 overlay_hash_input.extend_from_slice(path.as_bytes());
757 overlay_hash_input.push(0);
758 deleted_paths.push(path.clone());
759 }
760 continue;
761 }
762 Err(error) => return Err(error.into()),
763 };
764 let file_type = metadata.file_type();
765 if file_type.is_symlink() {
766 if scope::path_is_selected(path, registration, selector) {
767 record_worktree_status_marker(path, &mut overlay_hash_input);
768 }
769 continue;
770 }
771 if file_type.is_dir() {
772 if !change.is_untracked() || !worktree_directory_is_expandable(root, path)? {
773 if scope::path_is_selected(path, registration, selector) {
774 record_worktree_status_marker(path, &mut overlay_hash_input);
775 }
776 continue;
777 }
778 for nested_path in worktree_directory_files(root, path)? {
779 if worktree_untracked_path_is_selected(&nested_path, registration, selector) {
780 record_worktree_file(
781 root,
782 &nested_path,
783 previous_hashes,
784 &mut overlay_hash_input,
785 &mut files_to_parse,
786 &mut skipped_unchanged_count,
787 )?;
788 }
789 }
790 continue;
791 }
792 if !file_type.is_file() {
793 if scope::path_is_selected(path, registration, selector) {
794 record_worktree_status_marker(path, &mut overlay_hash_input);
795 }
796 continue;
797 }
798 if scope::path_is_selected(path, registration, selector) {
799 record_worktree_file(
800 root,
801 path,
802 previous_hashes,
803 &mut overlay_hash_input,
804 &mut files_to_parse,
805 &mut skipped_unchanged_count,
806 )?;
807 }
808 }
809 if overlay_hash_input.is_empty() {
810 return build_full_snapshot(registration, selector, root);
811 }
812
813 let overlay_hash = format!("{:016x}", stable_hash64(&overlay_hash_input));
814 let tree_hash = format!("worktree:{overlay_hash}");
815 let overlay_commit = format!("worktree:{commit}:{overlay_hash}");
816 let mut build = SnapshotBuild::new_with_selector(
817 registration,
818 selector,
819 overlay_commit,
820 tree_hash,
821 false,
822 changes.len(),
823 skipped_unchanged_count,
824 );
825 build.base_resolved_commit_sha = Some(commit);
826 build.deleted_paths = deleted_paths;
827
828 for (path, bytes) in files_to_parse {
829 parse_indexed_file(&mut build, &path, &bytes)?;
830 }
831
832 Ok(build.finish())
833}
834
835fn worktree_untracked_path_is_selected(
836 path: &str,
837 registration: &CodeRepositoryRegistration,
838 selector: &CodeRepositorySelector,
839) -> bool {
840 scope::path_is_selected(path, registration, selector)
841 && (!worktree_untracked_path_contains_broad_segment(path)
842 || explicit_worktree_path_filter_covers(path, registration, selector))
843}
844
845fn worktree_untracked_path_contains_broad_segment(path: &str) -> bool {
846 normalize_worktree_path(path)
847 .split('/')
848 .any(|segment| WORKTREE_UNTRACKED_BROAD_SEGMENTS.contains(&segment))
849}
850
851fn explicit_worktree_path_filter_covers(
852 path: &str,
853 registration: &CodeRepositoryRegistration,
854 selector: &CodeRepositorySelector,
855) -> bool {
856 registration
857 .path_filters
858 .iter()
859 .chain(selector.path_filters.iter())
860 .any(|filter| explicit_worktree_filter_matches_path(path, filter))
861}
862
863fn explicit_worktree_filter_matches_path(path: &str, filter: &str) -> bool {
864 let path = normalize_worktree_path(path);
865 let filter = normalize_worktree_path(filter);
866 if filter.is_empty() || filter == "." {
867 return false;
868 }
869
870 path == filter
871 || path.starts_with(&format!("{filter}/"))
872 || filter.starts_with(&format!("{path}/"))
873}
874
875fn normalize_worktree_path(path: &str) -> String {
876 path.replace('\\', "/")
877 .trim_start_matches("./")
878 .trim_matches('/')
879 .to_owned()
880}
881
882fn record_worktree_status_marker(path: &str, overlay_hash_input: &mut Vec<u8>) {
883 overlay_hash_input.extend_from_slice(b"S\0");
884 overlay_hash_input.extend_from_slice(path.as_bytes());
885 overlay_hash_input.push(0);
886}
887
888fn record_worktree_file(
889 root: &Path,
890 path: &str,
891 previous_hashes: &BTreeMap<String, String>,
892 overlay_hash_input: &mut Vec<u8>,
893 files_to_parse: &mut Vec<(String, Vec<u8>)>,
894 skipped_unchanged_count: &mut usize,
895) -> Result<(), CodeIndexError> {
896 let bytes = fs::read(root.join(path))?;
897 let blob_hash = stable_content_hash(&bytes);
898 overlay_hash_input.extend_from_slice(b"F\0");
899 overlay_hash_input.extend_from_slice(path.as_bytes());
900 overlay_hash_input.push(0);
901 overlay_hash_input.extend_from_slice(blob_hash.as_bytes());
902 overlay_hash_input.push(0);
903 if previous_hashes.get(path) == Some(&blob_hash) {
904 *skipped_unchanged_count += 1;
905 return Ok(());
906 }
907 files_to_parse.push((path.to_owned(), bytes));
908
909 Ok(())
910}
911
912fn worktree_directory_files(
913 root: &Path,
914 relative_dir: &str,
915) -> Result<Vec<String>, CodeIndexError> {
916 if !worktree_directory_is_expandable(root, relative_dir)? {
917 return Ok(Vec::new());
918 }
919 let mut files = Vec::new();
920 collect_worktree_directory_files(root, Path::new(relative_dir), &mut files)?;
921 files.sort();
922
923 Ok(files)
924}
925
926fn worktree_directory_is_expandable(
927 root: &Path,
928 relative_dir: &str,
929) -> Result<bool, CodeIndexError> {
930 let full_path = root.join(relative_dir);
931 let metadata = fs::symlink_metadata(&full_path)?;
932 if !metadata.file_type().is_dir() {
933 return Ok(false);
934 }
935
936 Ok(!contains_git_metadata(root, Path::new(relative_dir))?)
937}
938
939fn contains_git_metadata(root: &Path, relative: &Path) -> Result<bool, CodeIndexError> {
940 match fs::symlink_metadata(root.join(relative).join(".git")) {
941 Ok(_) => Ok(true),
942 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
943 Err(error) => Err(error.into()),
944 }
945}
946
947fn collect_worktree_directory_files(
948 root: &Path,
949 relative: &Path,
950 files: &mut Vec<String>,
951) -> Result<(), CodeIndexError> {
952 for entry in fs::read_dir(root.join(relative))? {
953 let entry = entry?;
954 let path = relative.join(entry.file_name());
955 let file_type = entry.file_type()?;
956 if file_type.is_dir() {
957 if entry.file_name() == ".git" || contains_git_metadata(root, &path)? {
958 continue;
959 }
960 collect_worktree_directory_files(root, &path, files)?;
961 } else if file_type.is_file() {
962 files.push(path.to_string_lossy().replace('\\', "/"));
963 }
964 }
965
966 Ok(())
967}
968
969struct ChangedPathParseContext<'a> {
970 registration: &'a CodeRepositoryRegistration,
971 selector: &'a CodeRepositorySelector,
972 root: &'a Path,
973 previous_hashes: &'a BTreeMap<String, String>,
974 source_layout: &'a scope::SourceLayoutDiscovery,
975}
976
977fn parse_changed_path(
978 build: &mut SnapshotBuild,
979 context: &ChangedPathParseContext<'_>,
980 path: &str,
981) -> Result<(), CodeIndexError> {
982 if !path_is_selected_with_layout(
983 path,
984 context.registration,
985 context.selector,
986 context.source_layout,
987 ) {
988 return Ok(());
989 }
990 let object = format!("{}:{path}", build.commit);
991 let bytes = git_bytes(context.root, ["show", &object])?;
992 let blob_hash = stable_content_hash(&bytes);
993 if context.previous_hashes.get(path) == Some(&blob_hash) {
994 build.skipped_unchanged_count += 1;
995 return Ok(());
996 }
997
998 parse_indexed_file(build, path, &bytes)
999}